From 198164c8b4742080ea736c391ecb03f367e7be62 Mon Sep 17 00:00:00 2001 From: Jake Magar Date: Thu, 20 Aug 2026 00:45:06 -0400 Subject: [PATCH 1/9] fix(skills): preserve extension results through rmcp --- Cargo.lock | 3 +-- Cargo.toml | 5 ++++ .../src/upstream/pool/skills_tests.rs | 25 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8f5bcdb54..d5bd8d0e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2801,8 +2801,7 @@ dependencies = [ [[package]] name = "rmcp" version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad26b216c966e987e80e86daf784a455c039c43d98575ceed57b8faa259e5695" +source = "git+https://github.com/dinglebear-ai/rust-sdk?rev=c9c3e518ce94b8d715519657c88195515978bda4#c9c3e518ce94b8d715519657c88195515978bda4" dependencies = [ "async-trait", "base64 0.23.0", diff --git a/Cargo.toml b/Cargo.toml index e84642dc3..9db413488 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -271,6 +271,11 @@ private_intra_doc_links = "warn" redundant_explicit_links = "warn" unescaped_backticks = "warn" +# Temporary rmcp decoder fix for custom extension results carrying protocol-generic +# `resultType` / `_meta`. Remove once the fix ships in an upstream rmcp release. +[patch.crates-io] +rmcp = { git = "https://github.com/dinglebear-ai/rust-sdk", rev = "c9c3e518ce94b8d715519657c88195515978bda4" } + [profile.release] lto = "thin" codegen-units = 1 diff --git a/crates/labby-gateway/src/upstream/pool/skills_tests.rs b/crates/labby-gateway/src/upstream/pool/skills_tests.rs index a41c8d8fd..abcedd8fe 100644 --- a/crates/labby-gateway/src/upstream/pool/skills_tests.rs +++ b/crates/labby-gateway/src/upstream/pool/skills_tests.rs @@ -210,6 +210,31 @@ async fn capability_detection_reads_the_declared_extension() { )); } +#[tokio::test] +async fn skills_list_with_result_type_and_meta_stays_a_custom_result() { + let server = SkillsServer::new(vec![json!({ + "resultType": "complete", + "skills": [entry("up", "alpha")], + "ttlMs": 60000, + "cacheScope": "private", + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "depot-shaped-skills-server", + "version": "1.0.0" + } + } + })]); + let pool = catalog_pool_with_server("up", server).await; + + let skills = pool + .fetch_upstream_skills("up", &peer_for(&pool, "up").await) + .await + .expect("skills/list custom result remains intact"); + + assert_eq!(skills.skills.len(), 1); + assert_eq!(skills.skills[0].entry.frontmatter.name, "alpha"); +} + #[tokio::test] async fn walks_every_page_and_stops_when_the_cursor_clears() { let server = SkillsServer::new(vec![ From ae9d8236b06546e8d308ca4fb285af51f3204bff Mon Sep 17 00:00:00 2001 From: Jake Magar Date: Thu, 20 Aug 2026 00:50:35 -0400 Subject: [PATCH 2/9] test(skills): assert Depot-shaped frontmatter correctly --- crates/labby-gateway/src/upstream/pool/skills_tests.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/labby-gateway/src/upstream/pool/skills_tests.rs b/crates/labby-gateway/src/upstream/pool/skills_tests.rs index abcedd8fe..97c7cd1cc 100644 --- a/crates/labby-gateway/src/upstream/pool/skills_tests.rs +++ b/crates/labby-gateway/src/upstream/pool/skills_tests.rs @@ -232,7 +232,14 @@ async fn skills_list_with_result_type_and_meta_stays_a_custom_result() { .expect("skills/list custom result remains intact"); assert_eq!(skills.skills.len(), 1); - assert_eq!(skills.skills[0].entry.frontmatter.name, "alpha"); + assert_eq!( + skills.skills[0] + .entry + .frontmatter + .get("name") + .and_then(Value::as_str), + Some("alpha") + ); } #[tokio::test] From a4606fe95ca661a7d9c91584ebe895b6a02cf4fb Mon Sep 17 00:00:00 2001 From: Jake Magar Date: Thu, 20 Aug 2026 00:55:27 -0400 Subject: [PATCH 3/9] test(skills): cover generic metadata on get --- .../labby-gateway/src/upstream/pool/skills_tests.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/labby-gateway/src/upstream/pool/skills_tests.rs b/crates/labby-gateway/src/upstream/pool/skills_tests.rs index 97c7cd1cc..5303655fb 100644 --- a/crates/labby-gateway/src/upstream/pool/skills_tests.rs +++ b/crates/labby-gateway/src/upstream/pool/skills_tests.rs @@ -168,7 +168,16 @@ impl ServerHandler for SkillsServer { "skills/get" => { self.get_calls.fetch_add(1, Ordering::SeqCst); match self.get_entry.as_ref() { - Some(entry) => Ok(CustomResult::new(json!({ "skill": entry }))), + Some(entry) => Ok(CustomResult::new(json!({ + "resultType": "complete", + "skill": entry, + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "depot-shaped-skills-server", + "version": "1.0.0" + } + } + }))), None => Err(ErrorData::new( ErrorCode::INVALID_PARAMS, "unknown skill".to_string(), From 1fac0e60d3afc52ca0723f4757b6ffc47274d6bd Mon Sep 17 00:00:00 2001 From: Jake Magar Date: Thu, 20 Aug 2026 02:04:22 -0400 Subject: [PATCH 4/9] fix(skills): distinguish discovered and exposed catalogs --- crates/labby-gateway/src/upstream/pool/skills.rs | 4 ++-- crates/labby-gateway/src/upstream/pool/skills_list.rs | 7 +++++++ crates/labby-gateway/src/upstream/pool/skills_tests.rs | 6 ++++++ crates/labby-gateway/src/upstream/types.rs | 3 ++- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/labby-gateway/src/upstream/pool/skills.rs b/crates/labby-gateway/src/upstream/pool/skills.rs index 280031ce9..82b86a72d 100644 --- a/crates/labby-gateway/src/upstream/pool/skills.rs +++ b/crates/labby-gateway/src/upstream/pool/skills.rs @@ -230,7 +230,7 @@ impl UpstreamPool { match self.fetch_upstream_skills(&config.name, &peer).await { Ok(skills) => { - let count = skills.skills.len(); + let discovered_count = skills.discovered_count; let skill_names = skills .skills .iter() @@ -249,7 +249,7 @@ impl UpstreamPool { let mut catalog = self.catalog.write().await; if let Some(catalog_entry) = catalog.get_mut(&config.name) { catalog_entry.supports_skills = Some(true); - catalog_entry.skill_count = count; + catalog_entry.skill_count = discovered_count; catalog_entry.skill_names = skill_names; } } diff --git a/crates/labby-gateway/src/upstream/pool/skills_list.rs b/crates/labby-gateway/src/upstream/pool/skills_list.rs index 825b033f1..08d93945a 100644 --- a/crates/labby-gateway/src/upstream/pool/skills_list.rs +++ b/crates/labby-gateway/src/upstream/pool/skills_list.rs @@ -57,6 +57,9 @@ use super::logging::{UpstreamRequestLog, log_upstream_request_start}; /// One upstream's validated skills plus what was dropped getting there. #[derive(Debug, Clone, Default)] pub(super) struct UpstreamSkills { + /// Skill candidates observed across the upstream pages fetched for this snapshot, + /// before host validation or exposure policy is applied. + pub(super) discovered_count: usize, /// Skills that passed ingest validation. pub(super) skills: Vec, /// Skills dropped for integrity or budget reasons, by cause. Operators see @@ -132,6 +135,10 @@ fn custom_result_value(result: ServerResult) -> Result, out: &mut UpstreamSkills) -> bool { + // Discovery is what the upstream advertised, not what the host later accepts. + // Count the fetched page before validation so operator status can distinguish + // discovered candidates from the validated/exposed subset. + out.discovered_count = out.discovered_count.saturating_add(entries.len()); for entry in entries { if out.skills.len() >= limits::MAX_SKILLS_PER_UPSTREAM { return true; diff --git a/crates/labby-gateway/src/upstream/pool/skills_tests.rs b/crates/labby-gateway/src/upstream/pool/skills_tests.rs index 5303655fb..d4b74a8ac 100644 --- a/crates/labby-gateway/src/upstream/pool/skills_tests.rs +++ b/crates/labby-gateway/src/upstream/pool/skills_tests.rs @@ -343,6 +343,11 @@ async fn the_skill_cap_stops_the_walk_before_the_next_request() { .expect("walk terminates"); assert!(skills.truncated); + assert_eq!( + skills.discovered_count, + limits::MAX_SKILLS_PER_UPSTREAM + 5, + "every candidate on the fetched page was discovered before the host cap engaged" + ); assert_eq!(skills.skills.len(), limits::MAX_SKILLS_PER_UPSTREAM); assert_eq!( calls.load(Ordering::SeqCst), @@ -375,6 +380,7 @@ async fn one_malformed_skill_does_not_sink_the_upstream() { .await .expect("the upstream survives its own bad skills"); + assert_eq!(skills.discovered_count, 3); assert_eq!(skills.skills.len(), 1); assert_eq!(skills.skills[0].name, "alpha"); assert_eq!(skills.excluded_count(), 2); diff --git a/crates/labby-gateway/src/upstream/types.rs b/crates/labby-gateway/src/upstream/types.rs index f848a10b0..c71acf620 100644 --- a/crates/labby-gateway/src/upstream/types.rs +++ b/crates/labby-gateway/src/upstream/types.rs @@ -344,7 +344,8 @@ pub struct UpstreamEntry { pub prompt_count: usize, /// Last successfully discovered upstream resource count. pub resource_count: usize, - /// Number of validated skills last enumerated from this upstream (SEP-2640). + /// Number of skill candidates observed during the last successful `skills/list` walk, + /// before Labby validation or exposure policy is applied. pub skill_count: usize, /// Cached validated skill names from the last successful skills/list walk. pub skill_names: Vec, From f4ee2762126fe31b27ad77646a4c66a0b7a9265f Mon Sep 17 00:00:00 2001 From: Jake Magar Date: Thu, 20 Aug 2026 00:22:42 -0400 Subject: [PATCH 5/9] fix(skills): preserve strict gateway params --- crates/labby-gateway/src/gateway/shared.rs | 49 ++++++++++++++++++ crates/labby/src/api/services/gateway.rs | 60 ++++++++++++++++++++-- crates/labby/src/mcp/call_tool.rs | 45 ++++++++++++++-- 3 files changed, 146 insertions(+), 8 deletions(-) diff --git a/crates/labby-gateway/src/gateway/shared.rs b/crates/labby-gateway/src/gateway/shared.rs index 4e06b1514..abe2f28b6 100644 --- a/crates/labby-gateway/src/gateway/shared.rs +++ b/crates/labby-gateway/src/gateway/shared.rs @@ -57,3 +57,52 @@ pub fn make_mcp_runtime_owner(subject: Option<&str>) -> UpstreamRuntimeOwner { raw, } } + +/// Whether transport-owned runtime provenance belongs in this action's params. +/// +/// Runtime owner/origin are internal transport metadata, not universal gateway +/// parameters. Only actions that actually consume them should receive them. +/// Keeping this list shared prevents strict read-only parameter schemas (such as +/// `gateway.skills.list`) from rejecting otherwise valid API or MCP requests. +pub fn action_accepts_runtime_owner(action: &str) -> bool { + matches!( + action, + "gateway.add" + | "gateway.update" + | "gateway.remove" + | "gateway.reload" + | "gateway.import_tombstones.restore" + | "gateway.mcp.enable" + | "gateway.mcp.disable" + ) +} + +#[cfg(test)] +mod tests { + use super::action_accepts_runtime_owner; + + #[test] + fn runtime_owner_metadata_is_limited_to_actions_that_consume_it() { + for action in [ + "gateway.add", + "gateway.update", + "gateway.remove", + "gateway.reload", + "gateway.import_tombstones.restore", + "gateway.mcp.enable", + "gateway.mcp.disable", + ] { + assert!(action_accepts_runtime_owner(action), "{action}"); + } + + for action in [ + "gateway.skills.list", + "gateway.list", + "gateway.status", + "gateway.code_mode.get", + "gateway.oauth.resource_lease.create", + ] { + assert!(!action_accepts_runtime_owner(action), "{action}"); + } + } +} diff --git a/crates/labby/src/api/services/gateway.rs b/crates/labby/src/api/services/gateway.rs index c1076778d..67d3046b9 100644 --- a/crates/labby/src/api/services/gateway.rs +++ b/crates/labby/src/api/services/gateway.rs @@ -297,7 +297,7 @@ async fn handle( let subject = subject.clone(); let auth = auth_for_dispatch.clone(); async move { - let params = inject_gateway_owner(params, subject.as_deref(), request_id); + let params = inject_gateway_owner(&action, params, subject.as_deref(), request_id); // Unlike trusted stdio MCP, an unauthenticated HTTP request // must never inherit the shared gateway OAuth credential. let oauth_subject = @@ -318,7 +318,15 @@ async fn handle( .await } -fn inject_gateway_owner(params: Value, subject: Option<&str>, request_id: Option<&str>) -> Value { +fn inject_gateway_owner( + action: &str, + params: Value, + subject: Option<&str>, + request_id: Option<&str>, +) -> Value { + if !crate::dispatch::gateway::shared::action_accepts_runtime_owner(action) { + return params; + } let Some(mut object) = params.as_object().cloned() else { return params; }; @@ -355,7 +363,7 @@ mod tests { use serde_json::json; use tower::ServiceExt; - use super::http_oauth_subject; + use super::{http_oauth_subject, inject_gateway_owner}; use crate::api::oauth::AuthContext; use crate::api::{ @@ -466,6 +474,52 @@ mod tests { } } + #[test] + fn gateway_owner_injection_skips_strict_read_only_actions() { + let params = json!({"upstream": "fixture"}); + let enriched = inject_gateway_owner( + "gateway.skills.list", + params.clone(), + Some("admin-user"), + Some("request-1"), + ); + assert_eq!(enriched, params); + } + + #[test] + fn gateway_owner_injection_preserves_mutation_provenance() { + let enriched = inject_gateway_owner( + "gateway.add", + json!({"spec": {"name": "fixture"}}), + Some("admin-user"), + Some("request-1"), + ); + assert_eq!(enriched["owner"]["surface"], "api"); + assert_eq!(enriched["owner"]["subject"], "admin-user"); + assert_eq!(enriched["owner"]["request_id"], "request-1"); + assert_eq!(enriched["origin"], "api:admin-user:request-1"); + } + + #[cfg(feature = "skills")] + #[tokio::test] + async fn gateway_skills_list_api_keeps_strict_action_params_clean() { + let response = post_gateway_as_admin( + test_manager(), + json!({"action": "gateway.skills.list", "params": {}}), + ) + .await; + + assert_ne!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + if !response.status().is_success() { + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: serde_json::Value = serde_json::from_slice(&body).expect("json"); + assert_ne!(payload["kind"], "invalid_param"); + assert!(!payload.to_string().contains("unknown field `owner`")); + } + } + #[test] fn http_oauth_subject_fails_closed_without_verified_auth_context() { assert!(http_oauth_subject(None, None).is_none()); diff --git a/crates/labby/src/mcp/call_tool.rs b/crates/labby/src/mcp/call_tool.rs index a2f7ada52..394084f39 100644 --- a/crates/labby/src/mcp/call_tool.rs +++ b/crates/labby/src/mcp/call_tool.rs @@ -102,7 +102,11 @@ fn retain_route_visible_gateway_status_rows( } /// Attach the authenticated MCP subject to gateway mutations without replacing caller values. -fn inject_gateway_origin_param(params: Value, subject: Option<&str>) -> Value { +#[cfg(feature = "gateway")] +fn inject_gateway_origin_param(action: &str, params: Value, subject: Option<&str>) -> Value { + if !crate::dispatch::gateway::shared::action_accepts_runtime_owner(action) { + return params; + } let raw = subject .map(|value| format!("mcp:{value}")) .unwrap_or_else(|| "mcp:anonymous".to_string()); @@ -122,6 +126,31 @@ fn inject_gateway_origin_param(params: Value, subject: Option<&str>) -> Value { Value::Object(object) } +#[cfg(all(test, feature = "gateway"))] +mod gateway_origin_tests { + use serde_json::json; + + use super::inject_gateway_origin_param; + + #[test] + fn strict_read_only_gateway_actions_do_not_receive_runtime_owner_params() { + let params = json!({"upstream": "fixture"}); + assert_eq!( + inject_gateway_origin_param("gateway.skills.list", params.clone(), Some("alice")), + params + ); + } + + #[test] + fn gateway_mutations_keep_mcp_runtime_owner_provenance() { + let enriched = + inject_gateway_origin_param("gateway.add", json!({"spec": {}}), Some("alice")); + assert_eq!(enriched["owner"]["surface"], "mcp"); + assert_eq!(enriched["owner"]["subject"], "alice"); + assert_eq!(enriched["origin"], "mcp:alice"); + } +} + impl LabMcpServer { #[cfg(feature = "gateway")] /// Record one structured failure event for a handled Add Server callback. @@ -684,8 +713,11 @@ impl LabMcpServer { ); return Ok(error_result_from_envelope(envelope).into()); } - let params = - inject_gateway_origin_param(params, self.request_subject(&context)); + let params = inject_gateway_origin_param( + gateway_action, + params, + self.request_subject(&context), + ); let enrichment_scope = crate::dispatch::gateway::GatewayEnrichmentScope { route_visible_upstreams: self.route_scope.allowed_upstreams().cloned(), oauth_subject: crate::mcp::context::oauth_upstream_subject_for_request( @@ -1109,8 +1141,11 @@ impl LabMcpServer { ); return Ok(error_result_from_envelope(envelope).into()); }; - let params = - inject_gateway_origin_param(params, self.request_subject(&context)); + let params = inject_gateway_origin_param( + &action, + params, + self.request_subject(&context), + ); let enrichment_scope = crate::dispatch::gateway::GatewayEnrichmentScope { route_visible_upstreams: self.route_scope.allowed_upstreams().cloned(), oauth_subject: crate::mcp::context::oauth_upstream_subject_for_request( From 8749e71dd682bebf2fb0c85e3ac0b33f958e860b Mon Sep 17 00:00:00 2001 From: Jake Magar Date: Thu, 20 Aug 2026 07:08:07 -0400 Subject: [PATCH 6/9] fix(skills): harden relay review findings --- .../src/upstream/pool/skills_list.rs | 42 +++++++++++++------ .../src/upstream/pool/skills_tests.rs | 39 +++++++++++++++++ crates/labby-runtime/src/skills/limits.rs | 8 ++++ crates/labby/src/api/services/gateway.rs | 30 ++++++++++--- crates/labby/src/mcp/call_tool.rs | 30 +++++++++---- 5 files changed, 123 insertions(+), 26 deletions(-) diff --git a/crates/labby-gateway/src/upstream/pool/skills_list.rs b/crates/labby-gateway/src/upstream/pool/skills_list.rs index 08d93945a..6318d906f 100644 --- a/crates/labby-gateway/src/upstream/pool/skills_list.rs +++ b/crates/labby-gateway/src/upstream/pool/skills_list.rs @@ -130,18 +130,28 @@ fn custom_result_value(result: ServerResult) -> Result, out: &mut UpstreamSkills) -> bool { +/// Returns the cap that stopped accumulation, so the caller can stop walking +/// rather than fetching pages whose contents would be discarded. +fn ingest_page(entries: Vec, out: &mut UpstreamSkills) -> Option { // Discovery is what the upstream advertised, not what the host later accepts. // Count the fetched page before validation so operator status can distinguish // discovered candidates from the validated/exposed subset. out.discovered_count = out.discovered_count.saturating_add(entries.len()); for entry in entries { + let processed_candidates = out.skills.len().saturating_add(out.excluded.len()); + if processed_candidates >= limits::MAX_SKILL_CANDIDATES_PER_UPSTREAM { + return Some(SkillIngestCap::Candidates); + } if out.skills.len() >= limits::MAX_SKILLS_PER_UPSTREAM { - return true; + return Some(SkillIngestCap::ValidatedSkills); } let uri = entry.uri.clone(); match validate_skill_entry(&entry) { @@ -164,7 +174,7 @@ fn ingest_page(entries: Vec, out: &mut UpstreamSkills) -> bool { Err(reason) => out.excluded.push((reason, uri)), } } - false + None } impl UpstreamPool { @@ -225,14 +235,20 @@ impl UpstreamPool { (current, next) => current.or(next), }; - let capped = ingest_page(result.skills, &mut out); - if capped { + if let Some(cap) = ingest_page(result.skills, &mut out) { out.truncated = true; - tracing::warn!( - upstream = %upstream_name, - cap = limits::MAX_SKILLS_PER_UPSTREAM, - "upstream published more skills than the per-upstream cap — snapshot truncated" - ); + match cap { + SkillIngestCap::ValidatedSkills => tracing::warn!( + upstream = %upstream_name, + cap = limits::MAX_SKILLS_PER_UPSTREAM, + "upstream published more validated skills than the per-upstream cap — snapshot truncated" + ), + SkillIngestCap::Candidates => tracing::warn!( + upstream = %upstream_name, + cap = limits::MAX_SKILL_CANDIDATES_PER_UPSTREAM, + "upstream published more skill candidates than the validation cap — snapshot truncated" + ), + } break; } diff --git a/crates/labby-gateway/src/upstream/pool/skills_tests.rs b/crates/labby-gateway/src/upstream/pool/skills_tests.rs index d4b74a8ac..f23a365b9 100644 --- a/crates/labby-gateway/src/upstream/pool/skills_tests.rs +++ b/crates/labby-gateway/src/upstream/pool/skills_tests.rs @@ -356,6 +356,45 @@ async fn the_skill_cap_stops_the_walk_before_the_next_request() { ); } +#[tokio::test] +async fn the_candidate_cap_bounds_invalid_skill_floods() { + let big: Vec = (0..limits::MAX_SKILL_CANDIDATES_PER_UPSTREAM + 5) + .map(|i| { + let name = format!("invalid-{i}"); + json!({ + "uri": format!("skill://up/{name}/SKILL.md"), + "frontmatter": { "name": name, "description": "d" } + }) + }) + .collect(); + let server = SkillsServer::new(vec![json!({ "skills": big, "nextCursor": "more" })]); + let calls = Arc::clone(&server.list_calls); + let pool = catalog_pool_with_server("up", server).await; + + let skills = pool + .fetch_upstream_skills("up", &peer_for(&pool, "up").await) + .await + .expect("walk terminates"); + + assert!(skills.truncated); + assert_eq!( + skills.discovered_count, + limits::MAX_SKILL_CANDIDATES_PER_UPSTREAM + 5, + "the fetched page remains visible to operator discovery accounting" + ); + assert!(skills.skills.is_empty()); + assert_eq!( + skills.excluded_count(), + limits::MAX_SKILL_CANDIDATES_PER_UPSTREAM, + "invalid entries stop consuming rejection memory at the candidate cap" + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "the next page is never fetched" + ); +} + #[tokio::test] async fn one_malformed_skill_does_not_sink_the_upstream() { let good = entry("up", "alpha"); diff --git a/crates/labby-runtime/src/skills/limits.rs b/crates/labby-runtime/src/skills/limits.rs index fe31b4c1f..376c18c6b 100644 --- a/crates/labby-runtime/src/skills/limits.rs +++ b/crates/labby-runtime/src/skills/limits.rs @@ -18,6 +18,14 @@ use std::time::Duration; /// Maximum skills retained from one upstream's `skills/list`, across all pages. pub const MAX_SKILLS_PER_UPSTREAM: usize = 256; +/// Maximum skill candidates validated from one upstream's `skills/list`, across all pages. +/// +/// This is deliberately higher than [`MAX_SKILLS_PER_UPSTREAM`]: malformed or +/// policy-rejected entries still consume validation work and rejection memory, +/// so an invalid-skill flood needs its own bound instead of relying on the +/// retained-valid-skill ceiling. +pub const MAX_SKILL_CANDIDATES_PER_UPSTREAM: usize = 1024; + /// Maximum entries in a single skill's `resources` manifest. pub const MAX_RESOURCES_PER_SKILL: usize = 64; diff --git a/crates/labby/src/api/services/gateway.rs b/crates/labby/src/api/services/gateway.rs index 67d3046b9..43a9eedde 100644 --- a/crates/labby/src/api/services/gateway.rs +++ b/crates/labby/src/api/services/gateway.rs @@ -334,18 +334,17 @@ fn inject_gateway_owner( let origin = owner.raw.clone(); // Serialize the owner struct into its JSON shape for the params object. // The fields match the GatewayRuntimeOwnerParams shape consumed by dispatch. - object.entry("owner".to_string()).or_insert_with(|| { + object.insert( + "owner".to_string(), serde_json::json!({ "surface": owner.surface, "subject": owner.subject, "request_id": owner.request_id, "raw": owner.raw, - }) - }); + }), + ); if let Some(origin) = origin { - object - .entry("origin".to_string()) - .or_insert_with(|| Value::String(origin)); + object.insert("origin".to_string(), Value::String(origin)); } Value::Object(object) } @@ -500,6 +499,25 @@ mod tests { assert_eq!(enriched["origin"], "api:admin-user:request-1"); } + #[test] + fn gateway_owner_injection_overwrites_forged_transport_provenance() { + let enriched = inject_gateway_owner( + "gateway.update", + json!({ + "name": "fixture", + "patch": {}, + "owner": {"surface": "forged", "subject": "mallory"}, + "origin": "forged-origin" + }), + Some("admin-user"), + Some("request-2"), + ); + assert_eq!(enriched["owner"]["surface"], "api"); + assert_eq!(enriched["owner"]["subject"], "admin-user"); + assert_eq!(enriched["owner"]["request_id"], "request-2"); + assert_eq!(enriched["origin"], "api:admin-user:request-2"); + } + #[cfg(feature = "skills")] #[tokio::test] async fn gateway_skills_list_api_keeps_strict_action_params_clean() { diff --git a/crates/labby/src/mcp/call_tool.rs b/crates/labby/src/mcp/call_tool.rs index 394084f39..eac9d43c1 100644 --- a/crates/labby/src/mcp/call_tool.rs +++ b/crates/labby/src/mcp/call_tool.rs @@ -101,7 +101,7 @@ fn retain_route_visible_gateway_status_rows( }); } -/// Attach the authenticated MCP subject to gateway mutations without replacing caller values. +/// Attach authoritative authenticated MCP provenance to gateway mutations. #[cfg(feature = "gateway")] fn inject_gateway_origin_param(action: &str, params: Value, subject: Option<&str>) -> Value { if !crate::dispatch::gateway::shared::action_accepts_runtime_owner(action) { @@ -113,16 +113,15 @@ fn inject_gateway_origin_param(action: &str, params: Value, subject: Option<&str let Some(mut object) = params.as_object().cloned() else { return params; }; - object.entry("owner".to_string()).or_insert_with(|| { + object.insert( + "owner".to_string(), serde_json::json!({ "surface": "mcp", "subject": subject, "raw": raw, - }) - }); - object - .entry("origin".to_string()) - .or_insert_with(|| Value::String(raw)); + }), + ); + object.insert("origin".to_string(), Value::String(raw)); Value::Object(object) } @@ -149,6 +148,23 @@ mod gateway_origin_tests { assert_eq!(enriched["owner"]["subject"], "alice"); assert_eq!(enriched["origin"], "mcp:alice"); } + + #[test] + fn gateway_mutations_overwrite_forged_mcp_runtime_provenance() { + let enriched = inject_gateway_origin_param( + "gateway.update", + json!({ + "name": "fixture", + "patch": {}, + "owner": {"surface": "forged", "subject": "mallory"}, + "origin": "forged-origin" + }), + Some("alice"), + ); + assert_eq!(enriched["owner"]["surface"], "mcp"); + assert_eq!(enriched["owner"]["subject"], "alice"); + assert_eq!(enriched["origin"], "mcp:alice"); + } } impl LabMcpServer { From ac1e5994663a67c8cd99f56943462b71d527762e Mon Sep 17 00:00:00 2001 From: Jake Magar Date: Thu, 20 Aug 2026 07:42:36 -0400 Subject: [PATCH 7/9] test(codemode): isolate process enablement state --- crates/labby/src/cli/serve.rs | 4 +--- crates/labby/src/config.rs | 32 +++++++++++++++++++++---- crates/labby/src/mcp/in_process_peer.rs | 5 ++-- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/crates/labby/src/cli/serve.rs b/crates/labby/src/cli/serve.rs index 6281dcc58..0a3fe7f24 100644 --- a/crates/labby/src/cli/serve.rs +++ b/crates/labby/src/cli/serve.rs @@ -2592,7 +2592,7 @@ mod tests { .collect() } - let previous_code_mode = crate::config::process_code_mode_enabled(); + let _guard = crate::config::process_code_mode_test_guard(); crate::config::set_process_code_mode_enabled(true); let notifier = PeerNotifier::default(); @@ -2617,8 +2617,6 @@ mod tests { notifier.code_mode_app_state.set_enabled(true); let enabled = listed_tool_names(app).await; assert!(enabled.iter().any(|name| name == "codemode_ui")); - - crate::config::set_process_code_mode_enabled(previous_code_mode); } #[tokio::test] diff --git a/crates/labby/src/config.rs b/crates/labby/src/config.rs index 19094fa01..1bbbb9b0c 100644 --- a/crates/labby/src/config.rs +++ b/crates/labby/src/config.rs @@ -47,6 +47,33 @@ use std::{ // server is operating in Code Mode. static PROCESS_CODE_MODE_ENABLED: AtomicBool = AtomicBool::new(false); +#[cfg(test)] +static PROCESS_CODE_MODE_TEST_LOCK: Mutex<()> = Mutex::new(()); + +#[cfg(test)] +pub(crate) struct ProcessCodeModeTestGuard { + _lock: std::sync::MutexGuard<'static, ()>, + previous: bool, +} + +#[cfg(test)] +impl Drop for ProcessCodeModeTestGuard { + fn drop(&mut self) { + set_process_code_mode_enabled(self.previous); + } +} + +#[cfg(test)] +pub(crate) fn process_code_mode_test_guard() -> ProcessCodeModeTestGuard { + let lock = PROCESS_CODE_MODE_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + ProcessCodeModeTestGuard { + _lock: lock, + previous: process_code_mode_enabled(), + } +} + pub(crate) fn set_process_code_mode_enabled(enabled: bool) { let previous = PROCESS_CODE_MODE_ENABLED.swap(enabled, Ordering::AcqRel); if previous != enabled { @@ -3589,7 +3616,7 @@ services = ["removed-service"] #[test] fn process_code_mode_flag_round_trips() { - let prev_ts = process_code_mode_enabled(); + let _guard = process_code_mode_test_guard(); set_process_code_mode_enabled(true); assert!( @@ -3602,9 +3629,6 @@ services = ["removed-service"] !process_code_mode_enabled(), "code_mode must be false after set_process_code_mode_enabled(false)" ); - - // Restore - set_process_code_mode_enabled(prev_ts); } // ── T3: secret file permission tests (S2) ──────────────────────────────── diff --git a/crates/labby/src/mcp/in_process_peer.rs b/crates/labby/src/mcp/in_process_peer.rs index 5c6e2b467..02738f7ca 100644 --- a/crates/labby/src/mcp/in_process_peer.rs +++ b/crates/labby/src/mcp/in_process_peer.rs @@ -214,10 +214,11 @@ mod tests { /// `InProcessPeer` visibility from the global flag, suppressed its own /// service, and registered zero tools. /// - /// Safe to toggle the process-global flag here: nextest runs each test in - /// its own process (see the same reasoning at pool helpers tests). + /// Process-global Code Mode state is serialized by the test guard so this + /// remains hermetic under both nextest and plain parallel `cargo test`. #[tokio::test] async fn in_process_peer_lists_its_service_under_process_code_mode() { + let _guard = crate::config::process_code_mode_test_guard(); crate::config::set_process_code_mode_enabled(true); let service = RegisteredService { From be38a6e875177788e092c7f3cef6730c3dcfdbbb Mon Sep 17 00:00:00 2001 From: Jake Magar Date: Thu, 20 Aug 2026 07:42:36 -0400 Subject: [PATCH 8/9] build(rmcp): vendor custom-result decoder patch --- Cargo.lock | 1 - Cargo.toml | 5 +- vendor/README.md | 17 + vendor/rmcp/CHANGELOG.md | 883 ++ vendor/rmcp/Cargo.toml | 904 ++ vendor/rmcp/Cargo.toml.orig | 463 + vendor/rmcp/README.md | 66 + vendor/rmcp/build.rs | 23 + vendor/rmcp/src/error.rs | 72 + vendor/rmcp/src/handler.rs | 4 + vendor/rmcp/src/handler/client.rs | 433 + vendor/rmcp/src/handler/client/progress.rs | 100 + vendor/rmcp/src/handler/server.rs | 817 ++ vendor/rmcp/src/handler/server/common.rs | 404 + vendor/rmcp/src/handler/server/prompt.rs | 383 + vendor/rmcp/src/handler/server/resource.rs | 1 + vendor/rmcp/src/handler/server/router.rs | 235 + .../rmcp/src/handler/server/router/prompt.rs | 217 + vendor/rmcp/src/handler/server/router/tool.rs | 726 ++ .../handler/server/router/tool/tool_traits.rs | 339 + vendor/rmcp/src/handler/server/tool.rs | 367 + .../handler/server/tool_name_validation.rs | 261 + vendor/rmcp/src/handler/server/wrapper.rs | 4 + .../rmcp/src/handler/server/wrapper/json.rs | 43 + .../src/handler/server/wrapper/parameters.rs | 56 + vendor/rmcp/src/lib.rs | 44 + vendor/rmcp/src/model.rs | 5326 +++++++++++ vendor/rmcp/src/model/annotated.rs | 119 + vendor/rmcp/src/model/capabilities.rs | 680 ++ vendor/rmcp/src/model/content.rs | 464 + vendor/rmcp/src/model/elicitation_schema.rs | 2237 +++++ vendor/rmcp/src/model/extension.rs | 337 + vendor/rmcp/src/model/meta.rs | 859 ++ vendor/rmcp/src/model/mrtr.rs | 558 ++ vendor/rmcp/src/model/prompt.rs | 367 + vendor/rmcp/src/model/request_state.rs | 577 ++ vendor/rmcp/src/model/resource.rs | 310 + vendor/rmcp/src/model/serde_impl.rs | 851 ++ vendor/rmcp/src/model/task.rs | 544 ++ vendor/rmcp/src/model/tool.rs | 259 + vendor/rmcp/src/service.rs | 1889 ++++ vendor/rmcp/src/service/client.rs | 2302 +++++ vendor/rmcp/src/service/client/cache.rs | 401 + vendor/rmcp/src/service/server.rs | 1314 +++ vendor/rmcp/src/service/tower.rs | 54 + vendor/rmcp/src/task_manager.rs | 998 ++ vendor/rmcp/src/transport.rs | 310 + vendor/rmcp/src/transport/async_rw.rs | 779 ++ vendor/rmcp/src/transport/auth.rs | 8097 +++++++++++++++++ vendor/rmcp/src/transport/child_process.rs | 321 + vendor/rmcp/src/transport/common.rs | 22 + vendor/rmcp/src/transport/common/auth.rs | 2 + .../common/auth/streamable_http_client.rs | 210 + .../src/transport/common/client_side_sse.rs | 738 ++ .../rmcp/src/transport/common/http_header.rs | 140 + .../rmcp/src/transport/common/mcp_headers.rs | 658 ++ vendor/rmcp/src/transport/common/reqwest.rs | 2 + .../common/reqwest/streamable_http_client.rs | 593 ++ .../src/transport/common/server_side_http.rs | 378 + .../rmcp/src/transport/common/unix_socket.rs | 614 ++ vendor/rmcp/src/transport/io.rs | 6 + vendor/rmcp/src/transport/sink_stream.rs | 80 + .../src/transport/streamable_http_client.rs | 2186 +++++ .../src/transport/streamable_http_server.rs | 6 + .../streamable_http_server/session.rs | 174 + .../streamable_http_server/session/local.rs | 1374 +++ .../streamable_http_server/session/never.rs | 126 + .../streamable_http_server/session/store.rs | 126 + .../transport/streamable_http_server/tower.rs | 2151 +++++ vendor/rmcp/src/transport/worker.rs | 217 + vendor/rmcp/src/transport/ws.rs | 1 + vendor/rmcp/tests/common/calculator.rs | 59 + vendor/rmcp/tests/common/handlers.rs | 190 + vendor/rmcp/tests/common/mod.rs | 2 + .../tests/test_auth_error_classification.rs | 85 + vendor/rmcp/tests/test_cache_hints.rs | 79 + vendor/rmcp/tests/test_cancelled_response.rs | 212 + vendor/rmcp/tests/test_client_credentials.rs | 258 + .../rmcp/tests/test_client_initialization.rs | 152 + .../rmcp/tests/test_client_lifecycle_modes.rs | 463 + vendor/rmcp/tests/test_close_connection.rs | 128 + vendor/rmcp/tests/test_completion.rs | 208 + vendor/rmcp/tests/test_complex_schema.rs | 105 + vendor/rmcp/tests/test_custom_headers.rs | 1231 +++ vendor/rmcp/tests/test_custom_request.rs | 190 + vendor/rmcp/tests/test_deserialization.rs | 238 + .../tool_list_result.json | 31 + .../test_discover_http_client_startup.rs | 137 + vendor/rmcp/tests/test_elicitation.rs | 2077 +++++ .../rmcp/tests/test_embedded_resource_meta.rs | 114 + vendor/rmcp/tests/test_handler_wrappers.rs | 31 + .../tests/test_inflight_response_drain.rs | 159 + .../rmcp/tests/test_json_schema_detection.rs | 188 + vendor/rmcp/tests/test_list_tools_result.rs | 48 + .../list_tools_result.json | 47 + .../rmcp/tests/test_local_client_handler.rs | 16 + vendor/rmcp/tests/test_logging.rs | 344 + vendor/rmcp/tests/test_message_protocol.rs | 426 + vendor/rmcp/tests/test_message_schema.rs | 139 + .../client_json_rpc_message_schema.json | 2447 +++++ ...lient_json_rpc_message_schema_current.json | 2447 +++++ .../server_json_rpc_message_schema.json | 4113 +++++++++ ...erver_json_rpc_message_schema_current.json | 4113 +++++++++ vendor/rmcp/tests/test_meta_helpers.rs | 98 + vendor/rmcp/tests/test_mrtr_behavior.rs | 595 ++ vendor/rmcp/tests/test_notification.rs | 234 + vendor/rmcp/tests/test_progress_subscriber.rs | 134 + vendor/rmcp/tests/test_prompt_handler.rs | 155 + .../tests/test_prompt_macro_annotations.rs | 274 + vendor/rmcp/tests/test_prompt_macros.rs | 375 + vendor/rmcp/tests/test_prompt_routers.rs | 140 + .../test_protocol_version_negotiation.rs | 171 + .../tests/test_request_timeout_progress.rs | 214 + vendor/rmcp/tests/test_resource_link.rs | 86 + .../tests/test_resource_link_integration.rs | 114 + .../tests/test_resource_not_found_version.rs | 91 + vendor/rmcp/tests/test_result_type_version.rs | 82 + vendor/rmcp/tests/test_result_type_wire.rs | 126 + vendor/rmcp/tests/test_sampling.rs | 744 ++ .../test_sep_2260_request_association.rs | 273 + .../tests/test_sep_2260_stream_enforcement.rs | 309 + .../tests/test_sep_2260_stream_routing.rs | 165 + vendor/rmcp/tests/test_server_discover.rs | 255 + .../rmcp/tests/test_server_discover_client.rs | 77 + .../rmcp/tests/test_server_discover_http.rs | 380 + .../rmcp/tests/test_server_initialization.rs | 396 + .../rmcp/tests/test_sse_concurrent_streams.rs | 775 ++ .../tests/test_stateless_protocol_version.rs | 226 + .../tests/test_stateless_server_requests.rs | 196 + .../tests/test_stdio_response_concurrency.rs | 199 + .../test_streamable_http_4xx_error_body.rs | 121 + .../test_streamable_http_connection_reuse.rs | 122 + .../test_streamable_http_disconnect_cancel.rs | 196 + ..._streamable_http_empty_2xx_notification.rs | 48 + .../tests/test_streamable_http_event_store.rs | 372 + .../test_streamable_http_idle_timeout_log.rs | 252 + .../test_streamable_http_init_timeout.rs | 63 + .../test_streamable_http_json_response.rs | 405 + .../tests/test_streamable_http_priming.rs | 499 + .../test_streamable_http_protocol_version.rs | 853 ++ .../test_streamable_http_session_store.rs | 398 + .../test_streamable_http_stale_session.rs | 464 + .../test_streamable_http_standard_headers.rs | 315 + vendor/rmcp/tests/test_structured_output.rs | 422 + vendor/rmcp/tests/test_subscriptions.rs | 727 ++ vendor/rmcp/tests/test_subscriptions_model.rs | 254 + .../test_subscriptions_streamable_http.rs | 317 + vendor/rmcp/tests/test_task.rs | 442 + .../rmcp/tests/test_tool_builder_methods.rs | 105 + .../tests/test_tool_disable_notification.rs | 165 + vendor/rmcp/tests/test_tool_handler.rs | 1 + .../rmcp/tests/test_tool_macro_annotations.rs | 46 + vendor/rmcp/tests/test_tool_macros.rs | 575 ++ vendor/rmcp/tests/test_tool_result_meta.rs | 41 + vendor/rmcp/tests/test_tool_routers.rs | 372 + vendor/rmcp/tests/test_trace_context.rs | 85 + .../rmcp/tests/test_unix_socket_transport.rs | 298 + vendor/rmcp/tests/test_with_js.rs | 136 + vendor/rmcp/tests/test_with_js/.gitignore | 2 + vendor/rmcp/tests/test_with_js/client.js | 29 + vendor/rmcp/tests/test_with_js/package.json | 17 + vendor/rmcp/tests/test_with_js/server.js | 35 + .../tests/test_with_js/streamable_client.js | 28 + .../tests/test_with_js/streamable_server.js | 105 + vendor/rmcp/tests/test_with_python.rs | 78 + vendor/rmcp/tests/test_with_python/.gitignore | 22 + vendor/rmcp/tests/test_with_python/client.py | 28 + .../tests/test_with_python/pyproject.toml | 14 + vendor/rmcp/tests/test_with_python/server.py | 25 + 169 files changed, 82460 insertions(+), 3 deletions(-) create mode 100644 vendor/README.md create mode 100644 vendor/rmcp/CHANGELOG.md create mode 100644 vendor/rmcp/Cargo.toml create mode 100644 vendor/rmcp/Cargo.toml.orig create mode 100644 vendor/rmcp/README.md create mode 100644 vendor/rmcp/build.rs create mode 100644 vendor/rmcp/src/error.rs create mode 100644 vendor/rmcp/src/handler.rs create mode 100644 vendor/rmcp/src/handler/client.rs create mode 100644 vendor/rmcp/src/handler/client/progress.rs create mode 100644 vendor/rmcp/src/handler/server.rs create mode 100644 vendor/rmcp/src/handler/server/common.rs create mode 100644 vendor/rmcp/src/handler/server/prompt.rs create mode 100644 vendor/rmcp/src/handler/server/resource.rs create mode 100644 vendor/rmcp/src/handler/server/router.rs create mode 100644 vendor/rmcp/src/handler/server/router/prompt.rs create mode 100644 vendor/rmcp/src/handler/server/router/tool.rs create mode 100644 vendor/rmcp/src/handler/server/router/tool/tool_traits.rs create mode 100644 vendor/rmcp/src/handler/server/tool.rs create mode 100644 vendor/rmcp/src/handler/server/tool_name_validation.rs create mode 100644 vendor/rmcp/src/handler/server/wrapper.rs create mode 100644 vendor/rmcp/src/handler/server/wrapper/json.rs create mode 100644 vendor/rmcp/src/handler/server/wrapper/parameters.rs create mode 100644 vendor/rmcp/src/lib.rs create mode 100644 vendor/rmcp/src/model.rs create mode 100644 vendor/rmcp/src/model/annotated.rs create mode 100644 vendor/rmcp/src/model/capabilities.rs create mode 100644 vendor/rmcp/src/model/content.rs create mode 100644 vendor/rmcp/src/model/elicitation_schema.rs create mode 100644 vendor/rmcp/src/model/extension.rs create mode 100644 vendor/rmcp/src/model/meta.rs create mode 100644 vendor/rmcp/src/model/mrtr.rs create mode 100644 vendor/rmcp/src/model/prompt.rs create mode 100644 vendor/rmcp/src/model/request_state.rs create mode 100644 vendor/rmcp/src/model/resource.rs create mode 100644 vendor/rmcp/src/model/serde_impl.rs create mode 100644 vendor/rmcp/src/model/task.rs create mode 100644 vendor/rmcp/src/model/tool.rs create mode 100644 vendor/rmcp/src/service.rs create mode 100644 vendor/rmcp/src/service/client.rs create mode 100644 vendor/rmcp/src/service/client/cache.rs create mode 100644 vendor/rmcp/src/service/server.rs create mode 100644 vendor/rmcp/src/service/tower.rs create mode 100644 vendor/rmcp/src/task_manager.rs create mode 100644 vendor/rmcp/src/transport.rs create mode 100644 vendor/rmcp/src/transport/async_rw.rs create mode 100644 vendor/rmcp/src/transport/auth.rs create mode 100644 vendor/rmcp/src/transport/child_process.rs create mode 100644 vendor/rmcp/src/transport/common.rs create mode 100644 vendor/rmcp/src/transport/common/auth.rs create mode 100644 vendor/rmcp/src/transport/common/auth/streamable_http_client.rs create mode 100644 vendor/rmcp/src/transport/common/client_side_sse.rs create mode 100644 vendor/rmcp/src/transport/common/http_header.rs create mode 100644 vendor/rmcp/src/transport/common/mcp_headers.rs create mode 100644 vendor/rmcp/src/transport/common/reqwest.rs create mode 100644 vendor/rmcp/src/transport/common/reqwest/streamable_http_client.rs create mode 100644 vendor/rmcp/src/transport/common/server_side_http.rs create mode 100644 vendor/rmcp/src/transport/common/unix_socket.rs create mode 100644 vendor/rmcp/src/transport/io.rs create mode 100644 vendor/rmcp/src/transport/sink_stream.rs create mode 100644 vendor/rmcp/src/transport/streamable_http_client.rs create mode 100644 vendor/rmcp/src/transport/streamable_http_server.rs create mode 100644 vendor/rmcp/src/transport/streamable_http_server/session.rs create mode 100644 vendor/rmcp/src/transport/streamable_http_server/session/local.rs create mode 100644 vendor/rmcp/src/transport/streamable_http_server/session/never.rs create mode 100644 vendor/rmcp/src/transport/streamable_http_server/session/store.rs create mode 100644 vendor/rmcp/src/transport/streamable_http_server/tower.rs create mode 100644 vendor/rmcp/src/transport/worker.rs create mode 100644 vendor/rmcp/src/transport/ws.rs create mode 100644 vendor/rmcp/tests/common/calculator.rs create mode 100644 vendor/rmcp/tests/common/handlers.rs create mode 100644 vendor/rmcp/tests/common/mod.rs create mode 100644 vendor/rmcp/tests/test_auth_error_classification.rs create mode 100644 vendor/rmcp/tests/test_cache_hints.rs create mode 100644 vendor/rmcp/tests/test_cancelled_response.rs create mode 100644 vendor/rmcp/tests/test_client_credentials.rs create mode 100644 vendor/rmcp/tests/test_client_initialization.rs create mode 100644 vendor/rmcp/tests/test_client_lifecycle_modes.rs create mode 100644 vendor/rmcp/tests/test_close_connection.rs create mode 100644 vendor/rmcp/tests/test_completion.rs create mode 100644 vendor/rmcp/tests/test_complex_schema.rs create mode 100644 vendor/rmcp/tests/test_custom_headers.rs create mode 100644 vendor/rmcp/tests/test_custom_request.rs create mode 100644 vendor/rmcp/tests/test_deserialization.rs create mode 100644 vendor/rmcp/tests/test_deserialization/tool_list_result.json create mode 100644 vendor/rmcp/tests/test_discover_http_client_startup.rs create mode 100644 vendor/rmcp/tests/test_elicitation.rs create mode 100644 vendor/rmcp/tests/test_embedded_resource_meta.rs create mode 100644 vendor/rmcp/tests/test_handler_wrappers.rs create mode 100644 vendor/rmcp/tests/test_inflight_response_drain.rs create mode 100644 vendor/rmcp/tests/test_json_schema_detection.rs create mode 100644 vendor/rmcp/tests/test_list_tools_result.rs create mode 100644 vendor/rmcp/tests/test_list_tools_result/list_tools_result.json create mode 100644 vendor/rmcp/tests/test_local_client_handler.rs create mode 100644 vendor/rmcp/tests/test_logging.rs create mode 100644 vendor/rmcp/tests/test_message_protocol.rs create mode 100644 vendor/rmcp/tests/test_message_schema.rs create mode 100644 vendor/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json create mode 100644 vendor/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json create mode 100644 vendor/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json create mode 100644 vendor/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json create mode 100644 vendor/rmcp/tests/test_meta_helpers.rs create mode 100644 vendor/rmcp/tests/test_mrtr_behavior.rs create mode 100644 vendor/rmcp/tests/test_notification.rs create mode 100644 vendor/rmcp/tests/test_progress_subscriber.rs create mode 100644 vendor/rmcp/tests/test_prompt_handler.rs create mode 100644 vendor/rmcp/tests/test_prompt_macro_annotations.rs create mode 100644 vendor/rmcp/tests/test_prompt_macros.rs create mode 100644 vendor/rmcp/tests/test_prompt_routers.rs create mode 100644 vendor/rmcp/tests/test_protocol_version_negotiation.rs create mode 100644 vendor/rmcp/tests/test_request_timeout_progress.rs create mode 100644 vendor/rmcp/tests/test_resource_link.rs create mode 100644 vendor/rmcp/tests/test_resource_link_integration.rs create mode 100644 vendor/rmcp/tests/test_resource_not_found_version.rs create mode 100644 vendor/rmcp/tests/test_result_type_version.rs create mode 100644 vendor/rmcp/tests/test_result_type_wire.rs create mode 100644 vendor/rmcp/tests/test_sampling.rs create mode 100644 vendor/rmcp/tests/test_sep_2260_request_association.rs create mode 100644 vendor/rmcp/tests/test_sep_2260_stream_enforcement.rs create mode 100644 vendor/rmcp/tests/test_sep_2260_stream_routing.rs create mode 100644 vendor/rmcp/tests/test_server_discover.rs create mode 100644 vendor/rmcp/tests/test_server_discover_client.rs create mode 100644 vendor/rmcp/tests/test_server_discover_http.rs create mode 100644 vendor/rmcp/tests/test_server_initialization.rs create mode 100644 vendor/rmcp/tests/test_sse_concurrent_streams.rs create mode 100644 vendor/rmcp/tests/test_stateless_protocol_version.rs create mode 100644 vendor/rmcp/tests/test_stateless_server_requests.rs create mode 100644 vendor/rmcp/tests/test_stdio_response_concurrency.rs create mode 100644 vendor/rmcp/tests/test_streamable_http_4xx_error_body.rs create mode 100644 vendor/rmcp/tests/test_streamable_http_connection_reuse.rs create mode 100644 vendor/rmcp/tests/test_streamable_http_disconnect_cancel.rs create mode 100644 vendor/rmcp/tests/test_streamable_http_empty_2xx_notification.rs create mode 100644 vendor/rmcp/tests/test_streamable_http_event_store.rs create mode 100644 vendor/rmcp/tests/test_streamable_http_idle_timeout_log.rs create mode 100644 vendor/rmcp/tests/test_streamable_http_init_timeout.rs create mode 100644 vendor/rmcp/tests/test_streamable_http_json_response.rs create mode 100644 vendor/rmcp/tests/test_streamable_http_priming.rs create mode 100644 vendor/rmcp/tests/test_streamable_http_protocol_version.rs create mode 100644 vendor/rmcp/tests/test_streamable_http_session_store.rs create mode 100644 vendor/rmcp/tests/test_streamable_http_stale_session.rs create mode 100644 vendor/rmcp/tests/test_streamable_http_standard_headers.rs create mode 100644 vendor/rmcp/tests/test_structured_output.rs create mode 100644 vendor/rmcp/tests/test_subscriptions.rs create mode 100644 vendor/rmcp/tests/test_subscriptions_model.rs create mode 100644 vendor/rmcp/tests/test_subscriptions_streamable_http.rs create mode 100644 vendor/rmcp/tests/test_task.rs create mode 100644 vendor/rmcp/tests/test_tool_builder_methods.rs create mode 100644 vendor/rmcp/tests/test_tool_disable_notification.rs create mode 100644 vendor/rmcp/tests/test_tool_handler.rs create mode 100644 vendor/rmcp/tests/test_tool_macro_annotations.rs create mode 100644 vendor/rmcp/tests/test_tool_macros.rs create mode 100644 vendor/rmcp/tests/test_tool_result_meta.rs create mode 100644 vendor/rmcp/tests/test_tool_routers.rs create mode 100644 vendor/rmcp/tests/test_trace_context.rs create mode 100644 vendor/rmcp/tests/test_unix_socket_transport.rs create mode 100644 vendor/rmcp/tests/test_with_js.rs create mode 100644 vendor/rmcp/tests/test_with_js/.gitignore create mode 100644 vendor/rmcp/tests/test_with_js/client.js create mode 100644 vendor/rmcp/tests/test_with_js/package.json create mode 100644 vendor/rmcp/tests/test_with_js/server.js create mode 100644 vendor/rmcp/tests/test_with_js/streamable_client.js create mode 100644 vendor/rmcp/tests/test_with_js/streamable_server.js create mode 100644 vendor/rmcp/tests/test_with_python.rs create mode 100644 vendor/rmcp/tests/test_with_python/.gitignore create mode 100644 vendor/rmcp/tests/test_with_python/client.py create mode 100644 vendor/rmcp/tests/test_with_python/pyproject.toml create mode 100644 vendor/rmcp/tests/test_with_python/server.py diff --git a/Cargo.lock b/Cargo.lock index d5bd8d0e6..3c40723cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2801,7 +2801,6 @@ dependencies = [ [[package]] name = "rmcp" version = "3.1.0" -source = "git+https://github.com/dinglebear-ai/rust-sdk?rev=c9c3e518ce94b8d715519657c88195515978bda4#c9c3e518ce94b8d715519657c88195515978bda4" dependencies = [ "async-trait", "base64 0.23.0", diff --git a/Cargo.toml b/Cargo.toml index 9db413488..2986874ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -272,9 +272,10 @@ redundant_explicit_links = "warn" unescaped_backticks = "warn" # Temporary rmcp decoder fix for custom extension results carrying protocol-generic -# `resultType` / `_meta`. Remove once the fix ships in an upstream rmcp release. +# `resultType` / `_meta`. The vendored crate is the normalized rmcp 3.1.0 +# package plus the decoder fix; remove once the fix ships in an upstream release. [patch.crates-io] -rmcp = { git = "https://github.com/dinglebear-ai/rust-sdk", rev = "c9c3e518ce94b8d715519657c88195515978bda4" } +rmcp = { path = "vendor/rmcp" } [profile.release] lto = "thin" diff --git a/vendor/README.md b/vendor/README.md new file mode 100644 index 000000000..58c4d5743 --- /dev/null +++ b/vendor/README.md @@ -0,0 +1,17 @@ +# Vendored dependency patches + +## rmcp 3.1.0 + +`vendor/rmcp` is the Cargo-normalized source package for the official crates.io `rmcp` 3.1.0 release, with one temporary decoder correction in `src/model.rs`. + +Provenance: + +- Upstream package: `rmcp` 3.1.0, Apache-2.0 +- Upstream repository: `https://github.com/modelcontextprotocol/rust-sdk` +- Patch development fork: `dinglebear-ai/rust-sdk` +- Patch commit: `c9c3e518ce94b8d715519657c88195515978bda4` +- Package construction: `cargo package -p rmcp --allow-dirty --no-verify`, then extracted locally + +The patch changes `CallToolResult` deserialization so protocol-generic `resultType` and `_meta` fields are not sufficient to classify an arbitrary custom extension response as a tool result. At least one tool-specific field (`content`, `structuredContent`, or `isError`) must be present. Two regression tests preserve both sides of the boundary: a Depot-shaped Skills result remains `CustomResult`, while a real tool result containing `_meta` remains `CallToolResult`. + +The vendored tree is intentionally used instead of allowing Git dependencies in `deny.toml`; Labby keeps its `unknown-git = "deny"` / empty `allow-git` supply-chain policy. Remove this vendor override as soon as an upstream rmcp release contains the equivalent decoder fix, then restore the normal crates.io dependency and regenerate `Cargo.lock`. diff --git a/vendor/rmcp/CHANGELOG.md b/vendor/rmcp/CHANGELOG.md new file mode 100644 index 000000000..b4e8cf612 --- /dev/null +++ b/vendor/rmcp/CHANGELOG.md @@ -0,0 +1,883 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [3.1.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.1...rmcp-v3.1.0) - 2026-07-31 + +### Added + +- classify authorization-required errors ([#1056](https://github.com/modelcontextprotocol/rust-sdk/pull/1056)) +- add strict stateless protocol metadata validation ([#1091](https://github.com/modelcontextprotocol/rust-sdk/pull/1091)) +- SEP-2260 stream-based enforcement of client receive-side request association ([#1055](https://github.com/modelcontextprotocol/rust-sdk/pull/1055)) + +### Fixed + +- *(model)* decode metadata-bearing input-required results affecting mrtr ([#1097](https://github.com/modelcontextprotocol/rust-sdk/pull/1097)) +- require metadata for modern HTTP requests ([#1089](https://github.com/modelcontextprotocol/rust-sdk/pull/1089)) +- honor supported_protocol_versions when negotiating initialize ([#1093](https://github.com/modelcontextprotocol/rust-sdk/pull/1093)) + +### Other + +- document the ping utility with examples ([#1106](https://github.com/modelcontextprotocol/rust-sdk/pull/1106)) +- complete Tier 1 feature docs and finalize roadmap ([#1101](https://github.com/modelcontextprotocol/rust-sdk/pull/1101)) +- *(conformance)* meeting requirements for tier 1 ([#1087](https://github.com/modelcontextprotocol/rust-sdk/pull/1087)) + +## [3.0.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0...rmcp-v3.0.1) - 2026-07-29 + +### Fixed + +- *(auth)* use discovered resource for token refresh ([#1084](https://github.com/modelcontextprotocol/rust-sdk/pull/1084)) +- return header mismatch for missing protocol header ([#1083](https://github.com/modelcontextprotocol/rust-sdk/pull/1083)) +- negotiate stateless initialize versions ([#1080](https://github.com/modelcontextprotocol/rust-sdk/pull/1080)) +- stamp server info on graceful subscription results ([#1078](https://github.com/modelcontextprotocol/rust-sdk/pull/1078)) + +## [3.0.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0-beta.5...rmcp-v3.0.0) - 2026-07-28 + +### Fixed + +- recognize 2026 MCP methods ([#1076](https://github.com/modelcontextprotocol/rust-sdk/pull/1076)) + +## [3.0.0-beta.5](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0-beta.4...rmcp-v3.0.0-beta.5) - 2026-07-28 + +### Fixed + +- [**breaking**] remove server_info from DiscoverResult ([#1065](https://github.com/modelcontextprotocol/rust-sdk/pull/1065)) +- preserve transient OAuth discovery HTTP errors ([#1071](https://github.com/modelcontextprotocol/rust-sdk/pull/1071)) +- [**breaking**] preserve OAuth discovery transport errors ([#1069](https://github.com/modelcontextprotocol/rust-sdk/pull/1069)) +- gate client handler bounds for local ([#1068](https://github.com/modelcontextprotocol/rust-sdk/pull/1068)) + +### Other + +- prepare for stable 3.0 release ([#1073](https://github.com/modelcontextprotocol/rust-sdk/pull/1073)) +- RFC 9728 resource is used instead of base url when possible ([#962](https://github.com/modelcontextprotocol/rust-sdk/pull/962)) +- [**breaking**] remove deprecated v3 APIs ([#1066](https://github.com/modelcontextprotocol/rust-sdk/pull/1066)) + +## [3.0.0-beta.4](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0-beta.3...rmcp-v3.0.0-beta.4) - 2026-07-28 + +### Fixed + +- accept namespaced discovery server information ([#1044](https://github.com/modelcontextprotocol/rust-sdk/pull/1044)) + +### Other + +- *(deps)* update base64 requirement from 0.22 to 0.23 ([#1059](https://github.com/modelcontextprotocol/rust-sdk/pull/1059)) +- *(deps)* update jsonwebtoken requirement from 10 to 11 ([#1058](https://github.com/modelcontextprotocol/rust-sdk/pull/1058)) + +## [3.0.0-beta.3](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0-beta.2...rmcp-v3.0.0-beta.3) - 2026-07-27 + +### Fixed + +- [**breaking**] reject missing `issuer` in authorization server metadata by default ([#1054](https://github.com/modelcontextprotocol/rust-sdk/pull/1054)) +- default to allowing missing `issuer` ([#1051](https://github.com/modelcontextprotocol/rust-sdk/pull/1051)) + +### Other + +- make oauth discovery reactive ([#1052](https://github.com/modelcontextprotocol/rust-sdk/pull/1052)) + +## [3.0.0-beta.2](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0-beta.1...rmcp-v3.0.0-beta.2) - 2026-07-24 + +### Added + +- expose oauth discovery metadata source ([#1041](https://github.com/modelcontextprotocol/rust-sdk/pull/1041)) + +### Fixed + +- [**breaking**] omit resultType for legacy protocol sessions ([#1038](https://github.com/modelcontextprotocol/rust-sdk/pull/1038)) + +### Other + +- declare and check MSRV ([#1034](https://github.com/modelcontextprotocol/rust-sdk/pull/1034)) + +## [3.0.0-beta.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v2.2.0...rmcp-v3.0.0-beta.1) - 2026-07-23 + +### Added + +- route SEP-2260 associated server requests to the originating SSE stream ([#1029](https://github.com/modelcontextprotocol/rust-sdk/pull/1029)) +- [**breaking**] add distributed SSE event store ([#1024](https://github.com/modelcontextprotocol/rust-sdk/pull/1024)) +- add client-side TTL-honoring response cache (SEP-2549) ([#1025](https://github.com/modelcontextprotocol/rust-sdk/pull/1025)) +- [**breaking**] Implement SEP-2663 Tasks Extension ([#1020](https://github.com/modelcontextprotocol/rust-sdk/pull/1020)) +- add subscription listen streams (SEP-2575) ([#1000](https://github.com/modelcontextprotocol/rust-sdk/pull/1000)) +- *(auth)* bind DCR client credentials to issuing authorization server (SEP-2352) ([#998](https://github.com/modelcontextprotocol/rust-sdk/pull/998)) +- add modern client lifecycle modes (SEP-2575) ([#995](https://github.com/modelcontextprotocol/rust-sdk/pull/995)) +- *(auth)* accumulate client-side scopes during step-up authorization ([#888](https://github.com/modelcontextprotocol/rust-sdk/pull/888)) +- [**breaking**] add server discovery and negotiation (SEP-2575) ([#973](https://github.com/modelcontextprotocol/rust-sdk/pull/973)) +- *(conformance)* add SEP-2243 header validation tool ([#997](https://github.com/modelcontextprotocol/rust-sdk/pull/997)) +- [**breaking**] align metadata models with draft schema ([#993](https://github.com/modelcontextprotocol/rust-sdk/pull/993)) +- [**breaking**] implement SEP-2549 cache hints ([#889](https://github.com/modelcontextprotocol/rust-sdk/pull/889)) +- [**breaking**] add MRTR behavior support (SEP-2322) ([#929](https://github.com/modelcontextprotocol/rust-sdk/pull/929)) +- [**breaking**] type Annotations.lastModified as a string ([#956](https://github.com/modelcontextprotocol/rust-sdk/pull/956)) +- [**breaking**] add SEP-2243 HTTP standard headers ([#907](https://github.com/modelcontextprotocol/rust-sdk/pull/907)) +- relax outputSchema to accept non-object JSON Schema types (SEP-2106) ([#895](https://github.com/modelcontextprotocol/rust-sdk/pull/895)) +- [**breaking**] relax tool result structuredContent type (SEP-2106) ([#933](https://github.com/modelcontextprotocol/rust-sdk/pull/933)) +- [**breaking**] add MRTR model types (SEP-2322) ([#915](https://github.com/modelcontextprotocol/rust-sdk/pull/915)) + +### Fixed + +- reap completed response send tasks ([#1026](https://github.com/modelcontextprotocol/rust-sdk/pull/1026)) +- accept stringified numeric response IDs ([#1021](https://github.com/modelcontextprotocol/rust-sdk/pull/1021)) +- re-register after auth server change ([#1011](https://github.com/modelcontextprotocol/rust-sdk/pull/1011)) +- .with_stateful_mode -> .with_legacy_session_mode ([#1015](https://github.com/modelcontextprotocol/rust-sdk/pull/1015)) +- preserve negotiated progress responses ([#1005](https://github.com/modelcontextprotocol/rust-sdk/pull/1005)) +- *(server)* serve draft-version requests statelessly per SEP-2567 ([#999](https://github.com/modelcontextprotocol/rust-sdk/pull/999)) +- pass client header conformance ([#1012](https://github.com/modelcontextprotocol/rust-sdk/pull/1012)) +- *(auth)* add an SDK path for pre-registered OAuth clients ([#994](https://github.com/modelcontextprotocol/rust-sdk/pull/994)) +- *(transport)* cancel in-flight request on stateless streamable-HTTP client disconnect ([#857](https://github.com/modelcontextprotocol/rust-sdk/pull/857)) ([#967](https://github.com/modelcontextprotocol/rust-sdk/pull/967)) +- *(auth)* distinguish rejected refresh tokens from transient failures ([#963](https://github.com/modelcontextprotocol/rust-sdk/pull/963)) +- *(auth)* validate discovered metadata issuer ([#996](https://github.com/modelcontextprotocol/rust-sdk/pull/996)) +- bound streamable HTTP memory usage ([#970](https://github.com/modelcontextprotocol/rust-sdk/pull/970)) +- *(streamable-http)* preserve progress in JSON mode ([#990](https://github.com/modelcontextprotocol/rust-sdk/pull/990)) +- specify compatible sse-stream version ([#968](https://github.com/modelcontextprotocol/rust-sdk/pull/968)) +- flag schema derive on schemars feature ([#966](https://github.com/modelcontextprotocol/rust-sdk/pull/966)) + +### Other + +- refactor OAuth client authorization api ([#1009](https://github.com/modelcontextprotocol/rust-sdk/pull/1009)) +- update for 2026-07-28 version ([#1032](https://github.com/modelcontextprotocol/rust-sdk/pull/1032)) +- *(deps)* update hmac requirement from 0.12 to 0.13 ([#988](https://github.com/modelcontextprotocol/rust-sdk/pull/988)) +- serialize JavaScript dependency install ([#972](https://github.com/modelcontextprotocol/rust-sdk/pull/972)) + +### Changed + +- **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 + +- reject auth servers lacking S256 PKCE support ([#955](https://github.com/modelcontextprotocol/rust-sdk/pull/955)) + +### Fixed + +- pass client conformance suite ([#960](https://github.com/modelcontextprotocol/rust-sdk/pull/960)) +- don't respond to cancelled requests ([#957](https://github.com/modelcontextprotocol/rust-sdk/pull/957)) +- fail orphaned streamable HTTP responses on reinit ([#914](https://github.com/modelcontextprotocol/rust-sdk/pull/914)) +- address 2025-11-25 conformance audit findings ([#951](https://github.com/modelcontextprotocol/rust-sdk/pull/951)) + +## [2.1.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v2.0.0...rmcp-v2.1.0) - 2026-07-02 + +### Added + +- add SEP-414 trace context meta accessors ([#910](https://github.com/modelcontextprotocol/rust-sdk/pull/910)) +- add SEP-2575 meta helpers ([#942](https://github.com/modelcontextprotocol/rust-sdk/pull/942)) + +### Fixed + +- *(transport)* make AsyncRwTransport::receive cancel-safe ([#941](https://github.com/modelcontextprotocol/rust-sdk/pull/941)) ([#947](https://github.com/modelcontextprotocol/rust-sdk/pull/947)) +- *(auth)* preserve refresh_token when refresh response omits it ([#949](https://github.com/modelcontextprotocol/rust-sdk/pull/949)) +- block redirect header leaks ([#936](https://github.com/modelcontextprotocol/rust-sdk/pull/936)) +- don't respond to unparsable messages ([#940](https://github.com/modelcontextprotocol/rust-sdk/pull/940)) +- negotiate protocol version in handler ([#930](https://github.com/modelcontextprotocol/rust-sdk/pull/930)) + +## [2.0.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.8.0...rmcp-v2.0.0) - 2026-06-27 + +### Added + +- [**breaking**] relax tool result structuredContent type ([#919](https://github.com/modelcontextprotocol/rust-sdk/pull/919)) +- deprecate roots/sampling/logging types ([#923](https://github.com/modelcontextprotocol/rust-sdk/pull/923)) +- [**breaking**] align model types with MCP 2025-11-25 spec ([#927](https://github.com/modelcontextprotocol/rust-sdk/pull/927)) + +### Fixed + +- prevent OAuth resource spoofing ([#937](https://github.com/modelcontextprotocol/rust-sdk/pull/937)) +- block oauth metadata ssrf ([#935](https://github.com/modelcontextprotocol/rust-sdk/pull/935)) +- prevent streamable HTTP session leak ([#934](https://github.com/modelcontextprotocol/rust-sdk/pull/934)) +- fill missing fully qualified syntax in prompt_handler macros ([#866](https://github.com/modelcontextprotocol/rust-sdk/pull/866)) +- *(rmcp)* add Audio variant to PromptMessageContent ([#865](https://github.com/modelcontextprotocol/rust-sdk/pull/865)) + +### Other + +- consolidate repeated rmcp tests ([#931](https://github.com/modelcontextprotocol/rust-sdk/pull/931)) +- Revert "feat!: relax tool result structuredContent type ([#919](https://github.com/modelcontextprotocol/rust-sdk/pull/919))" ([#932](https://github.com/modelcontextprotocol/rust-sdk/pull/932)) +- align README examples with v2 model API ([#928](https://github.com/modelcontextprotocol/rust-sdk/pull/928)) + +## [1.8.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.7.0...rmcp-v1.8.0) - 2026-06-22 + +### Added + +- standardize resource-not-found error code (SEP-2164) ([#899](https://github.com/modelcontextprotocol/rust-sdk/pull/899)) +- validate OAuth authorization response issuer ([#896](https://github.com/modelcontextprotocol/rust-sdk/pull/896)) +- specify OIDC application_type during dynamic client registration (SEP-837) ([#883](https://github.com/modelcontextprotocol/rust-sdk/pull/883)) +- deprecate roots, sampling, and logging (SEP-2577) ([#884](https://github.com/modelcontextprotocol/rust-sdk/pull/884)) + +### Fixed + +- *(auth)* preserve configured reqwest client ([#917](https://github.com/modelcontextprotocol/rust-sdk/pull/917)) +- *(auth)* align OAuth metadata discovery ordering ([#887](https://github.com/modelcontextprotocol/rust-sdk/pull/887)) +- align progress timeout token ([#909](https://github.com/modelcontextprotocol/rust-sdk/pull/909)) +- *(elicitation)* preserve enumNames through ElicitationSchema serde round-trip ([#905](https://github.com/modelcontextprotocol/rust-sdk/pull/905)) +- return tool errors for invalid arguments ([#894](https://github.com/modelcontextprotocol/rust-sdk/pull/894)) +- *(auth)* apply offline_access to reauth paths ([#897](https://github.com/modelcontextprotocol/rust-sdk/pull/897)) +- update peer info on duplicate initialize ([#862](https://github.com/modelcontextprotocol/rust-sdk/pull/862)) +- strip and validate tool outputSchema and inputSchema ([#860](https://github.com/modelcontextprotocol/rust-sdk/pull/860)) +- remove unnecessary fields from tools' inputSchema ([#856](https://github.com/modelcontextprotocol/rust-sdk/pull/856)) +- reject init header/body version mismatch ([#853](https://github.com/modelcontextprotocol/rust-sdk/pull/853)) +- align protocol version negotiation ([#855](https://github.com/modelcontextprotocol/rust-sdk/pull/855)) +- accept 200 with empty body in response to notifications in addition to 202 ([#849](https://github.com/modelcontextprotocol/rust-sdk/pull/849)) + +### Other + +- Allow custom HTTP clients for OAuth ([#908](https://github.com/modelcontextprotocol/rust-sdk/pull/908)) +- Add progress-aware request timeout reset ([#858](https://github.com/modelcontextprotocol/rust-sdk/pull/858)) +- *(server)* document Err vs Ok(CallToolResult::error) visibility contract on ServerHandler::call_tool ([#854](https://github.com/modelcontextprotocol/rust-sdk/pull/854)) +- refine mcpmate listing copy ([#885](https://github.com/modelcontextprotocol/rust-sdk/pull/885)) +- added jilebi-mcp to the list of built with rmcp ([#861](https://github.com/modelcontextprotocol/rust-sdk/pull/861)) + +## [1.7.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.6.0...rmcp-v1.7.0) - 2026-05-13 + +### Added + +- add task-based stdio examples ([#839](https://github.com/modelcontextprotocol/rust-sdk/pull/839)) + +### Fixed + +- *(rmcp)* flatten Resource variant of PromptMessageContent ([#843](https://github.com/modelcontextprotocol/rust-sdk/pull/843)) +- reply -32700 on stdio parse errors instead of closing ([#833](https://github.com/modelcontextprotocol/rust-sdk/pull/833)) + +### Other + +- *(rmcp)* remove dependency on chrono default features ([#829](https://github.com/modelcontextprotocol/rust-sdk/pull/829)) +- Fix/issue 817 idle timeout log level ([#824](https://github.com/modelcontextprotocol/rust-sdk/pull/824)) + +## [1.6.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.5.0...rmcp-v1.6.0) - 2026-05-01 + +### Added + +- *(http)* log Host/Origin rejections ([#826](https://github.com/modelcontextprotocol/rust-sdk/pull/826)) +- *(http)* add Origin header validation ([#823](https://github.com/modelcontextprotocol/rust-sdk/pull/823)) +- *(router)* support runtime disabling of tools ([#809](https://github.com/modelcontextprotocol/rust-sdk/pull/809)) +- optional session store (resumabillity support) ([#775](https://github.com/modelcontextprotocol/rust-sdk/pull/775)) + +### Fixed + +- add init_timeout for streamable-http sessions ([#811](https://github.com/modelcontextprotocol/rust-sdk/pull/811)) +- *(http)* fall back to :authority for HTTP/2 ([#827](https://github.com/modelcontextprotocol/rust-sdk/pull/827)) +- *(docs)* use correct Parameters syntax in tool examples ([#814](https://github.com/modelcontextprotocol/rust-sdk/pull/814)) + +### Other + +- add systemprompt-template to Built with rmcp ([#820](https://github.com/modelcontextprotocol/rust-sdk/pull/820)) + +## [1.5.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.4.0...rmcp-v1.5.0) - 2026-04-16 + +### Added + +- *(transport)* add constructors for non_exhaustive error types ([#806](https://github.com/modelcontextprotocol/rust-sdk/pull/806)) +- add 2025-11-25 protocol version support ([#802](https://github.com/modelcontextprotocol/rust-sdk/pull/802)) + +### Fixed + +- treat resource metadata JSON parse failure as soft error ([#810](https://github.com/modelcontextprotocol/rust-sdk/pull/810)) +- include http_request_id in request-wise priming event IDs ([#799](https://github.com/modelcontextprotocol/rust-sdk/pull/799)) +- *(http)* drain SSE stream for connection reuse ([#790](https://github.com/modelcontextprotocol/rust-sdk/pull/790)) + +### Other + +- *(deps)* update which requirement from 7 to 8 ([#807](https://github.com/modelcontextprotocol/rust-sdk/pull/807)) + +## [1.4.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.3.0...rmcp-v1.4.0) - 2026-04-09 + +### Added + +- add Default and constructors to ServerSseMessage ([#794](https://github.com/modelcontextprotocol/rust-sdk/pull/794)) +- add meta to elicitation results ([#792](https://github.com/modelcontextprotocol/rust-sdk/pull/792)) +- *(macros)* auto-generate get_info and default router ([#785](https://github.com/modelcontextprotocol/rust-sdk/pull/785)) +- *(transport)* add which_command for cross-platform executable resolution ([#774](https://github.com/modelcontextprotocol/rust-sdk/pull/774)) +- *(auth)* add StoredCredentials::new() constructor ([#778](https://github.com/modelcontextprotocol/rust-sdk/pull/778)) + +### Fixed + +- *(server)* remove initialized notification gate to support Streamable HTTP ([#788](https://github.com/modelcontextprotocol/rust-sdk/pull/788)) +- default session keep_alive to 5 minutes ([#780](https://github.com/modelcontextprotocol/rust-sdk/pull/780)) +- *(http)* add host check ([#764](https://github.com/modelcontextprotocol/rust-sdk/pull/764)) +- exclude local feature from docs.rs build ([#782](https://github.com/modelcontextprotocol/rust-sdk/pull/782)) + +### Other + +- update Rust toolchain to 1.92 ([#797](https://github.com/modelcontextprotocol/rust-sdk/pull/797)) +- unify IntoCallToolResult Result impls ([#787](https://github.com/modelcontextprotocol/rust-sdk/pull/787)) + +## [1.3.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.2.0...rmcp-v1.3.0) - 2026-03-24 + +### Added + +- *(transport)* add Unix domain socket client for streamable HTTP ([#749](https://github.com/modelcontextprotocol/rust-sdk/pull/749)) +- *(auth)* implement SEP-2207 OIDC-flavored refresh token guidance ([#676](https://github.com/modelcontextprotocol/rust-sdk/pull/676)) +- add configuration for transparent session re-init ([#760](https://github.com/modelcontextprotocol/rust-sdk/pull/760)) +- add local feature for !Send tool handler support ([#740](https://github.com/modelcontextprotocol/rust-sdk/pull/740)) + +### Fixed + +- prevent CallToolResult and GetTaskPayloadResult from shadowing CustomResult in untagged enums ([#771](https://github.com/modelcontextprotocol/rust-sdk/pull/771)) +- drain in-flight responses on stdin EOF ([#759](https://github.com/modelcontextprotocol/rust-sdk/pull/759)) +- remove default type param from StreamableHttpService ([#758](https://github.com/modelcontextprotocol/rust-sdk/pull/758)) +- use cfg-gated Send+Sync supertraits to avoid semver break ([#757](https://github.com/modelcontextprotocol/rust-sdk/pull/757)) +- *(rmcp)* surface JSON-RPC error bodies on HTTP 4xx responses ([#748](https://github.com/modelcontextprotocol/rust-sdk/pull/748)) +- default CallToolResult content to empty vec on missing field ([#752](https://github.com/modelcontextprotocol/rust-sdk/pull/752)) +- *(auth)* redact secrets in Debug output for StoredCredentials and StoredAuthorizationState ([#744](https://github.com/modelcontextprotocol/rust-sdk/pull/744)) + +### Other + +- fix all clippy warnings across workspace ([#746](https://github.com/modelcontextprotocol/rust-sdk/pull/746)) + +## [1.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.1.1...rmcp-v1.2.0) - 2026-03-11 + +### Added + +- add missing constructors for non-exhaustive model types ([#739](https://github.com/modelcontextprotocol/rust-sdk/pull/739)) +- include granted scopes in OAuth refresh token request ([#731](https://github.com/modelcontextprotocol/rust-sdk/pull/731)) + +### Fixed + +- handle ping requests sent before initialize handshake ([#745](https://github.com/modelcontextprotocol/rust-sdk/pull/745)) +- allow deserializing notifications without params field ([#729](https://github.com/modelcontextprotocol/rust-sdk/pull/729)) + +### Other + +- *(deps)* update jsonwebtoken requirement from 9 to 10 ([#737](https://github.com/modelcontextprotocol/rust-sdk/pull/737)) + +## [1.1.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.1.0...rmcp-v1.1.1) - 2026-03-09 + +### Fixed + +- accept logging/setLevel and ping before initialized notification ([#730](https://github.com/modelcontextprotocol/rust-sdk/pull/730)) + +## [1.1.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.0.0...rmcp-v1.1.0) - 2026-03-04 + +### Added + +- implement OAuth 2.0 Client Credentials flow ([#707](https://github.com/modelcontextprotocol/rust-sdk/pull/707)) + +### Other + +- add McpMux to Built with rmcp section ([#717](https://github.com/modelcontextprotocol/rust-sdk/pull/717)) + +## [1.0.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.0.0-alpha...rmcp-v1.0.0) - 2026-03-03 + +### Fixed + +- *(auth)* pass WWW-Authenticate scopes to DCR registration request ([#705](https://github.com/modelcontextprotocol/rust-sdk/pull/705)) +- api ergonomics follow-up ([#720](https://github.com/modelcontextprotocol/rust-sdk/pull/720)) +- *(streamable-http)* map stale session 401 to status-aware error ([#709](https://github.com/modelcontextprotocol/rust-sdk/pull/709)) + +## [1.0.0-alpha](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.17.0...rmcp-v1.0.0-alpha) - 2026-03-03 + +### Added + +- docs update ([#718](https://github.com/modelcontextprotocol/rust-sdk/pull/718)) +- *(auth)* [**breaking**] support returning extra fields from token exchange ([#700](https://github.com/modelcontextprotocol/rust-sdk/pull/700)) + +### Fixed + +- downgrade logging of message to `TRACE` to avoid spamming logs ([#699](https://github.com/modelcontextprotocol/rust-sdk/pull/699)) + +### Other + +- add #[non_exhaustive] and mutation methods to improve compatibility ([#715](https://github.com/modelcontextprotocol/rust-sdk/pull/715)) + +## [0.17.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.16.0...rmcp-v0.17.0) - 2026-02-27 + +### Added + +- *(streamable-http)* add json_response option for stateless server mode ([#683](https://github.com/modelcontextprotocol/rust-sdk/pull/683)) +- mcp sdk conformance ([#687](https://github.com/modelcontextprotocol/rust-sdk/pull/687)) +- add default value support to string, number, and integer schemas ([#686](https://github.com/modelcontextprotocol/rust-sdk/pull/686)) +- add trait-based tool declaration ([#677](https://github.com/modelcontextprotocol/rust-sdk/pull/677)) +- send and validate MCP-Protocol-Version header ([#675](https://github.com/modelcontextprotocol/rust-sdk/pull/675)) + +### Fixed + +- improve error logging and remove token secret from logs ([#685](https://github.com/modelcontextprotocol/rust-sdk/pull/685)) +- refresh token expiry ([#680](https://github.com/modelcontextprotocol/rust-sdk/pull/680)) +- gate optional dependencies behind feature flags ([#672](https://github.com/modelcontextprotocol/rust-sdk/pull/672)) +- allow empty content in CallToolResult ([#681](https://github.com/modelcontextprotocol/rust-sdk/pull/681)) +- *(schema)* remove AddNullable from draft2020_12 settings ([#664](https://github.com/modelcontextprotocol/rust-sdk/pull/664)) + +### Other + +- add prose documentation for core features to meet conformance ([#702](https://github.com/modelcontextprotocol/rust-sdk/pull/702)) +- Fix/sse channel replacement conflict ([#682](https://github.com/modelcontextprotocol/rust-sdk/pull/682)) +- document session management for streamable HTTP transport ([#674](https://github.com/modelcontextprotocol/rust-sdk/pull/674)) + +## [0.16.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.15.0...rmcp-v0.16.0) - 2026-02-17 + +### Added + +- add support for custom HTTP headers in StreamableHttpClient ([#655](https://github.com/modelcontextprotocol/rust-sdk/pull/655)) +- *(auth)* add token_endpoint_auth_method to OAuthClientConfig ([#648](https://github.com/modelcontextprotocol/rust-sdk/pull/648)) + +### Fixed + +- remove unnecessary doc-cfg ([#661](https://github.com/modelcontextprotocol/rust-sdk/pull/661)) +- duplicate meta serialization ([#662](https://github.com/modelcontextprotocol/rust-sdk/pull/662)) +- sort list_all() output in ToolRouter and PromptRouter for deterministic ordering ([#665](https://github.com/modelcontextprotocol/rust-sdk/pull/665)) +- align task response types with MCP spec ([#658](https://github.com/modelcontextprotocol/rust-sdk/pull/658)) + +### Other + +- upgrade reqwest to 0.13.2 ([#669](https://github.com/modelcontextprotocol/rust-sdk/pull/669)) +- include LICENSE in final crate tarball ([#657](https://github.com/modelcontextprotocol/rust-sdk/pull/657)) +- *(deps)* update rand requirement from 0.9 to 0.10 ([#650](https://github.com/modelcontextprotocol/rust-sdk/pull/650)) +- remove unused axum dependency from server-side-http feature ([#642](https://github.com/modelcontextprotocol/rust-sdk/pull/642)) +- 11-25-2025 compliant Auth ([#651](https://github.com/modelcontextprotocol/rust-sdk/pull/651)) +- add rudof-mcp to MCP servers list ([#645](https://github.com/modelcontextprotocol/rust-sdk/pull/645)) + +## [0.15.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.14.0...rmcp-v0.15.0) - 2026-02-10 + +### Added + +- *(elicitation)* add support URL elicitation. SEP-1036 ([#605](https://github.com/modelcontextprotocol/rust-sdk/pull/605)) +- enforce SEP-1577 MUST requirements for sampling with tools ([#646](https://github.com/modelcontextprotocol/rust-sdk/pull/646)) +- add native-tls as an optional TLS backend ([#631](https://github.com/modelcontextprotocol/rust-sdk/pull/631)) +- *(capabilities)* add extensions field for SEP-1724 ([#643](https://github.com/modelcontextprotocol/rust-sdk/pull/643)) + +### Fixed + +- *(tasks)* avoid dropping completed task results during collection ([#639](https://github.com/modelcontextprotocol/rust-sdk/pull/639)) +- *(auth)* oauth metadata discovery ([#641](https://github.com/modelcontextprotocol/rust-sdk/pull/641)) +- compilation with --no-default-features ([#593](https://github.com/modelcontextprotocol/rust-sdk/pull/593)) +- *(tasks)* expose `execution.taskSupport` on tools ([#635](https://github.com/modelcontextprotocol/rust-sdk/pull/635)) +- *(tasks)* correct enum variant ordering for deserialization ([#634](https://github.com/modelcontextprotocol/rust-sdk/pull/634)) + +### Other + +- Add optional description field to Implementation struct ([#649](https://github.com/modelcontextprotocol/rust-sdk/pull/649)) +- Implement SEP-1577: Sampling With Tools ([#628](https://github.com/modelcontextprotocol/rust-sdk/pull/628)) + +## [0.14.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.13.0...rmcp-v0.14.0) - 2026-01-23 + +### Fixed + +- *(tasks)* #626 model task capabilities correctly ([#627](https://github.com/modelcontextprotocol/rust-sdk/pull/627)) +- don't treat non-success HTTP codes as transport errors ([#618](https://github.com/modelcontextprotocol/rust-sdk/pull/618)) + +### Other + +- show README content on docs.rs ([#583](https://github.com/modelcontextprotocol/rust-sdk/pull/583)) +- added hyper-mcp to the list of built with rmcp ([#621](https://github.com/modelcontextprotocol/rust-sdk/pull/621)) +- Implement SEP-1319: Decouple Request Payload from RPC Methods ([#617](https://github.com/modelcontextprotocol/rust-sdk/pull/617)) + +## [0.13.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.12.0...rmcp-v0.13.0) - 2026-01-15 + +### Added + +- provide blanket implementations for ClientHandler and ServerHandler traits ([#609](https://github.com/modelcontextprotocol/rust-sdk/pull/609)) +- *(service)* add close() method for graceful connection shutdown ([#588](https://github.com/modelcontextprotocol/rust-sdk/pull/588)) +- *(auth)* add StateStore trait for pluggable OAuth state storage ([#614](https://github.com/modelcontextprotocol/rust-sdk/pull/614)) +- *(elicitation)* implement SEP-1330 Elicitation Enum Schema Improvements ([#539](https://github.com/modelcontextprotocol/rust-sdk/pull/539)) +- *(task)* add task support (SEP-1686) ([#536](https://github.com/modelcontextprotocol/rust-sdk/pull/536)) + +### Fixed + +- use the json rpc error from the initialize response and bubble it up to the client ([#569](https://github.com/modelcontextprotocol/rust-sdk/pull/569)) +- *(build)* fix build of the project when no features are selected ([#606](https://github.com/modelcontextprotocol/rust-sdk/pull/606)) +- use Semaphore instead of Notify in OneshotTransport to prevent race condition ([#611](https://github.com/modelcontextprotocol/rust-sdk/pull/611)) +- add OpenID Connect discovery support per spec-2025-11-25 4.3 ([#598](https://github.com/modelcontextprotocol/rust-sdk/pull/598)) +- only try to refresh access tokens if we have a refresh token or an expiry time ([#594](https://github.com/modelcontextprotocol/rust-sdk/pull/594)) +- *(docs)* add spreadsheet-mcp to Built with rmcp ([#582](https://github.com/modelcontextprotocol/rust-sdk/pull/582)) + +### Other + +- *(elicitation)* improve enum schema builder, small changes of elicitation builder ([#608](https://github.com/modelcontextprotocol/rust-sdk/pull/608)) +- add pre-commit hook for conventional commit verification ([#619](https://github.com/modelcontextprotocol/rust-sdk/pull/619)) +- clean up optional dependencies ([#546](https://github.com/modelcontextprotocol/rust-sdk/pull/546)) +- re-export ServerSseMessage from session module ([#612](https://github.com/modelcontextprotocol/rust-sdk/pull/612)) +- Implement SEP-1699: Support SSE Polling via Server-Side Disconnect ([#604](https://github.com/modelcontextprotocol/rust-sdk/pull/604)) +- update README external links ([#603](https://github.com/modelcontextprotocol/rust-sdk/pull/603)) +- clarity and formatting ([#602](https://github.com/modelcontextprotocol/rust-sdk/pull/602)) +- Add optional icons field to RawResourceTemplate ([#589](https://github.com/modelcontextprotocol/rust-sdk/pull/589)) + +## [0.12.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.11.0...rmcp-v0.12.0) - 2025-12-18 + +### Added + +- add support for custom requests ([#590](https://github.com/modelcontextprotocol/rust-sdk/pull/590)) +- add support for custom server notifications ([#580](https://github.com/modelcontextprotocol/rust-sdk/pull/580)) + +### Fixed + +- update process-wrap to v9.0 ([#586](https://github.com/modelcontextprotocol/rust-sdk/pull/586)) +- *(oauth)* rfc8414 should judement the response_types ([#485](https://github.com/modelcontextprotocol/rust-sdk/pull/485)) + +### Other + +- Add SEP-991 (CIMD) support for URL-based client IDs ([#570](https://github.com/modelcontextprotocol/rust-sdk/pull/570)) +- merge cached_schema_for_type into schema_for_type ([#581](https://github.com/modelcontextprotocol/rust-sdk/pull/581)) +- Add NexusCore MCP to project list ([#573](https://github.com/modelcontextprotocol/rust-sdk/pull/573)) + +## [0.11.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.10.0...rmcp-v0.11.0) - 2025-12-08 + +### Added + +- *(meta)* add _meta field to prompts, resources and paginated result ([#558](https://github.com/modelcontextprotocol/rust-sdk/pull/558)) +- [**breaking**] remove SSE transport support ([#562](https://github.com/modelcontextprotocol/rust-sdk/pull/562)) + +### Fixed + +- *(streamable-http)* gracefully shutdown while client connected ([#494](https://github.com/modelcontextprotocol/rust-sdk/pull/494)) + +### Other + +- Implements outputSchema validation ([#566](https://github.com/modelcontextprotocol/rust-sdk/pull/566)) +- add video-transcriber-mcp-rs to projects built with rmcp ([#565](https://github.com/modelcontextprotocol/rust-sdk/pull/565)) + +## [0.10.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.9.1...rmcp-v0.10.0) - 2025-12-01 + +### Added + +- add support for custom client notifications ([#556](https://github.com/modelcontextprotocol/rust-sdk/pull/556)) + +### Other + +- replace paste with pastey for macros feature ([#564](https://github.com/modelcontextprotocol/rust-sdk/pull/564)) + +## [0.9.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.9.0...rmcp-v0.9.1) - 2025-11-24 + +### Added + +- *(streamable-http)* support both SSE and JSON response formats ([#540](https://github.com/modelcontextprotocol/rust-sdk/pull/540)) + +### Fixed + +- don't block on creating the SSE stream ([#553](https://github.com/modelcontextprotocol/rust-sdk/pull/553)) +- *(shemars)* use JSON Schema 2020-12 as Default Dialect ([#549](https://github.com/modelcontextprotocol/rust-sdk/pull/549)) +- *(oauth)* let OAuth discovery skip to next well-known URL candidate on JSON parse error. ([#545](https://github.com/modelcontextprotocol/rust-sdk/pull/545)) + +### Other + +- Implementation of SEP-986: Specify Format for Tool Names ([#551](https://github.com/modelcontextprotocol/rust-sdk/pull/551)) + +## [0.9.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.8.5...rmcp-v0.9.0) - 2025-11-17 + +### Added + +- *(auth)* implement CredentialStore trait ([#542](https://github.com/modelcontextprotocol/rust-sdk/pull/542)) +- *(tool)* add _meta to tool definitions ([#534](https://github.com/modelcontextprotocol/rust-sdk/pull/534)) + +## [0.8.5](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.8.4...rmcp-v0.8.5) - 2025-11-05 + +### Fixed + +- *(oauth)* respect oauth-protected-resource discovery ([#511](https://github.com/modelcontextprotocol/rust-sdk/pull/511)) + +## [0.8.4](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.8.3...rmcp-v0.8.4) - 2025-11-04 + +### Fixed + +- *(oauth)* fix oauth credential refresh ([#509](https://github.com/modelcontextprotocol/rust-sdk/pull/509)) +- do not manually construct fallback authorization metadata ([#507](https://github.com/modelcontextprotocol/rust-sdk/pull/507)) +- *(doc)* add stakpak-agent to Built with rmcp section ([#500](https://github.com/modelcontextprotocol/rust-sdk/pull/500)) + +## [0.8.3](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.8.2...rmcp-v0.8.3) - 2025-10-22 + +### Fixed + +- accept 204 in addition to 202 on initialize ([#497](https://github.com/modelcontextprotocol/rust-sdk/pull/497)) + +## [0.8.2](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.8.1...rmcp-v0.8.2) - 2025-10-21 + +### Added + +- add type-safe elicitation schema support ([#465](https://github.com/modelcontextprotocol/rust-sdk/pull/465)) ([#466](https://github.com/modelcontextprotocol/rust-sdk/pull/466)) +- *(SEP-973)* following change Icon.sizes from string to string array ([#479](https://github.com/modelcontextprotocol/rust-sdk/pull/479)) + +### Fixed + +- *(oauth)* three oauth discovery and registration issues ([#489](https://github.com/modelcontextprotocol/rust-sdk/pull/489)) +- *(oauth)* dynamic client registration should be optional ([#463](https://github.com/modelcontextprotocol/rust-sdk/pull/463)) +- *(sse-client)* consume control frames; refresh message endpoint ([#448](https://github.com/modelcontextprotocol/rust-sdk/pull/448)) + +### Other + +- Streamable HTTP: drain SSE frames until the initialize response, ignoring early notifications to prevent handshake timeouts ([#467](https://github.com/modelcontextprotocol/rust-sdk/pull/467)) +- bump crate version in README.md ([#471](https://github.com/modelcontextprotocol/rust-sdk/pull/471)) + +## [0.8.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.8.0...rmcp-v0.8.1) - 2025-10-07 + +### Fixed + +- *(oauth)* pass bearer token to all streamable http requests ([#476](https://github.com/modelcontextprotocol/rust-sdk/pull/476)) +- fix spellcheck on intentional typo in CHANGELOG ([#470](https://github.com/modelcontextprotocol/rust-sdk/pull/470)) + +## [0.8.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.7.0...rmcp-v0.8.0) - 2025-10-04 + +### Added + +- allow clients to override client_name ([#469](https://github.com/modelcontextprotocol/rust-sdk/pull/469)) + +### Fixed + +- *(oauth)* support suffixed and prefixed well-known paths ([#459](https://github.com/modelcontextprotocol/rust-sdk/pull/459)) +- generate default schema for tools with no params ([#446](https://github.com/modelcontextprotocol/rust-sdk/pull/446)) + +### Other + +- bump to rust 1.90.0 ([#453](https://github.com/modelcontextprotocol/rust-sdk/pull/453)) + +## [0.7.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.6.4...rmcp-v0.7.0) - 2025-09-24 + +### Fixed + +- return auth errors ([#451](https://github.com/modelcontextprotocol/rust-sdk/pull/451)) +- *(oauth)* do not treat empty secret as valid for public clients ([#443](https://github.com/modelcontextprotocol/rust-sdk/pull/443)) +- *(clippy)* add doc comment for generated tool attr fn ([#439](https://github.com/modelcontextprotocol/rust-sdk/pull/439)) +- *(oauth)* require CSRF token as part of the OAuth authorization flow. ([#435](https://github.com/modelcontextprotocol/rust-sdk/pull/435)) + +### Other + +- *(root)* Add Terminator to Built with rmcp section ([#437](https://github.com/modelcontextprotocol/rust-sdk/pull/437)) +- Non-empty paths in OAuth2 Authorization Server Metadata URLs ([#441](https://github.com/modelcontextprotocol/rust-sdk/pull/441)) + +## [0.6.4](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.6.3...rmcp-v0.6.4) - 2025-09-11 + +### Added + +- *(SEP-973)* add support for icons and websiteUrl across relevant types ([#432](https://github.com/modelcontextprotocol/rust-sdk/pull/432)) +- implement context-aware completion (MCP 2025-06-18) ([#396](https://github.com/modelcontextprotocol/rust-sdk/pull/396)) +- add `title` field for data types ([#410](https://github.com/modelcontextprotocol/rust-sdk/pull/410)) + +### Fixed + +- crates/rmcp/src/handler/client/progress.rs XXXXXX -> dispatcher ([#429](https://github.com/modelcontextprotocol/rust-sdk/pull/429)) +- build issue due to missing struct field ([#427](https://github.com/modelcontextprotocol/rust-sdk/pull/427)) +- generate simple {} schema for tools with no parameters ([#425](https://github.com/modelcontextprotocol/rust-sdk/pull/425)) + +### Other + +- Skip notification in initialization handshake ([#421](https://github.com/modelcontextprotocol/rust-sdk/pull/421)) +- add nvim-mcp project built by rmcp ([#422](https://github.com/modelcontextprotocol/rust-sdk/pull/422)) + +## [0.6.3](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.6.2...rmcp-v0.6.3) - 2025-09-04 + +### Fixed + +- change JSON-RPC request ID type from u32 to i64 ([#416](https://github.com/modelcontextprotocol/rust-sdk/pull/416)) + +## [0.6.2](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.6.1...rmcp-v0.6.2) - 2025-09-04 + +### Added + +- *(rmcp)* add optional _meta to CallToolResult, EmbeddedResource, and ResourceContents ([#386](https://github.com/modelcontextprotocol/rust-sdk/pull/386)) + +### Fixed + +- resolve compatibility issues with servers sending LSP notifications ([#413](https://github.com/modelcontextprotocol/rust-sdk/pull/413)) +- remove batched json rpc support ([#408](https://github.com/modelcontextprotocol/rust-sdk/pull/408)) +- transport-streamable-http-server depends on transport-worker ([#405](https://github.com/modelcontextprotocol/rust-sdk/pull/405)) +- *(typo)* correct typo in error message for transport cancellation and field. ([#404](https://github.com/modelcontextprotocol/rust-sdk/pull/404)) + +### Other + +- Spec conformance: meta support and spec updates ([#415](https://github.com/modelcontextprotocol/rust-sdk/pull/415)) +- add the rmcp-openapi and rmcp-actix-web related projects ([#406](https://github.com/modelcontextprotocol/rust-sdk/pull/406)) + +## [0.6.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.6.0...rmcp-v0.6.1) - 2025-08-29 + +### Added + +- *(rmcp)* add authorization header support for the streamable http client ([#390](https://github.com/modelcontextprotocol/rust-sdk/pull/390)) +- *(model)* add helpers to build enum from concrete values ([#393](https://github.com/modelcontextprotocol/rust-sdk/pull/393)) +- *(model)* expose client method name ([#391](https://github.com/modelcontextprotocol/rust-sdk/pull/391)) +- add resource_link support to tools and prompts ([#381](https://github.com/modelcontextprotocol/rust-sdk/pull/381)) +- Add prompt support ([#351](https://github.com/modelcontextprotocol/rust-sdk/pull/351)) +- include reqwest in transport-streamble-http-client feature ([#376](https://github.com/modelcontextprotocol/rust-sdk/pull/376)) + +### Fixed + +- *(auth)* url parse is not correct ([#402](https://github.com/modelcontextprotocol/rust-sdk/pull/402)) +- *(readme)* missing use declarations, more accurate server instructions ([#399](https://github.com/modelcontextprotocol/rust-sdk/pull/399)) +- enhance transport graceful shutdown with proper writer closure ([#392](https://github.com/modelcontextprotocol/rust-sdk/pull/392)) + +### Other + +- simplify remove_route method signature ([#401](https://github.com/modelcontextprotocol/rust-sdk/pull/401)) + +## [0.6.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.5.0...rmcp-v0.6.0) - 2025-08-19 + +### Added + +- Add MCP Elicitation support ([#332](https://github.com/modelcontextprotocol/rust-sdk/pull/332)) +- keep internal error in worker's quit reason ([#372](https://github.com/modelcontextprotocol/rust-sdk/pull/372)) + +### Fixed + +- match shape of the calltoolresult schema ([#377](https://github.com/modelcontextprotocol/rust-sdk/pull/377)) +- make stdio shutdown more graceful ([#364](https://github.com/modelcontextprotocol/rust-sdk/pull/364)) +- *(tool)* remove unnecessary schema validation ([#375](https://github.com/modelcontextprotocol/rust-sdk/pull/375)) +- *(rmcp)* return serialized json with structured content ([#368](https://github.com/modelcontextprotocol/rust-sdk/pull/368)) + +### Other + +- add related project rustfs-mcp ([#378](https://github.com/modelcontextprotocol/rust-sdk/pull/378)) +- *(streamable)* add document for extracting http info ([#373](https://github.com/modelcontextprotocol/rust-sdk/pull/373)) + +## [0.5.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.4.1...rmcp-v0.5.0) - 2025-08-07 + +### Fixed + +- correct numeric types in progress notifications ([#361](https://github.com/modelcontextprotocol/rust-sdk/pull/361)) + +## [0.4.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.4.0...rmcp-v0.4.1) - 2025-08-07 + +### Fixed + +- *(rmcp)* allow both content and structured content ([#359](https://github.com/modelcontextprotocol/rust-sdk/pull/359)) + +## [0.4.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.3.2...rmcp-v0.4.0) - 2025-08-05 + +### Added + +- [**breaking**] Add support for `Tool.outputSchema` and `CallToolResult.structuredContent` ([#316](https://github.com/modelcontextprotocol/rust-sdk/pull/316)) + +### Fixed + +- don't wrap errors in streamable http auth client ([#353](https://github.com/modelcontextprotocol/rust-sdk/pull/353)) +- *(prompt)* remove unused code ([#343](https://github.com/modelcontextprotocol/rust-sdk/pull/343)) + +## [0.3.2](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.3.1...rmcp-v0.3.2) - 2025-07-30 + +### Fixed + +- *(capabilities)* do not serialize None as null for `list_changed` ([#341](https://github.com/modelcontextprotocol/rust-sdk/pull/341)) +- *(Transport)* close oneshot transport on error ([#340](https://github.com/modelcontextprotocol/rust-sdk/pull/340)) +- *(oauth)* expose OAuthTokenResponse publicly ([#335](https://github.com/modelcontextprotocol/rust-sdk/pull/335)) + +## [0.3.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.3.0...rmcp-v0.3.1) - 2025-07-29 + +### Fixed + +- use mimeType instead of mime_type for MCP specification compliance ([#339](https://github.com/modelcontextprotocol/rust-sdk/pull/339)) +- return a 405 for GET and DELETE if stateful_mode=false ([#331](https://github.com/modelcontextprotocol/rust-sdk/pull/331)) +- propagate tracing spans when spawning new tokio tasks ([#334](https://github.com/modelcontextprotocol/rust-sdk/pull/334)) +- Explicitly added client_id as an extra parameter causes bad token requests ([#322](https://github.com/modelcontextprotocol/rust-sdk/pull/322)) + +### Other + +- Fix formatting in crate descriptions in README.md ([#333](https://github.com/modelcontextprotocol/rust-sdk/pull/333)) + +## [0.3.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.2.1...rmcp-v0.3.0) - 2025-07-15 + +### Added + +- unified error type ([#308](https://github.com/modelcontextprotocol/rust-sdk/pull/308)) +- *(transport)* add builder & expose child stderr ([#305](https://github.com/modelcontextprotocol/rust-sdk/pull/305)) + +### Other + +- *(deps)* update schemars requirement from 0.8 to 1.0 ([#258](https://github.com/modelcontextprotocol/rust-sdk/pull/258)) +- *(rmcp)* bump rmcp-macros version to match ([#311](https://github.com/modelcontextprotocol/rust-sdk/pull/311)) +- fix packages used for server example ([#309](https://github.com/modelcontextprotocol/rust-sdk/pull/309)) + +## [0.2.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.2.0...rmcp-v0.2.1) - 2025-07-03 + +### Other + +- *(docs)* Minor README updates ([#301](https://github.com/modelcontextprotocol/rust-sdk/pull/301)) + +## [0.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.1.5...rmcp-v0.2.0) - 2025-07-02 + +### Added + +- mark boxed http body as sync ([#291](https://github.com/modelcontextprotocol/rust-sdk/pull/291)) +- add progress notification handling and related structures ([#282](https://github.com/modelcontextprotocol/rust-sdk/pull/282)) +- allow failable service creation in streamable HTTP tower service ([#244](https://github.com/modelcontextprotocol/rust-sdk/pull/244)) +- provide more context information ([#236](https://github.com/modelcontextprotocol/rust-sdk/pull/236)) +- stateless mode of streamable http client ([#233](https://github.com/modelcontextprotocol/rust-sdk/pull/233)) +- add cancellation_token method to `RunningService` ([#218](https://github.com/modelcontextprotocol/rust-sdk/pull/218)) +- better http server support ([#199](https://github.com/modelcontextprotocol/rust-sdk/pull/199)) +- throw initialize error detail ([#192](https://github.com/modelcontextprotocol/rust-sdk/pull/192)) +- sse client optionally skip the endpoint event ([#187](https://github.com/modelcontextprotocol/rust-sdk/pull/187)) +- *(server)* add annotation to tool macro ([#184](https://github.com/modelcontextprotocol/rust-sdk/pull/184)) +- *(model)* add json schema generation support for all model types ([#176](https://github.com/modelcontextprotocol/rust-sdk/pull/176)) +- *(openapi)* add OpenAPI v3 compatibility and test for nullable field schema workaround ([#135](https://github.com/modelcontextprotocol/rust-sdk/pull/135)) ([#137](https://github.com/modelcontextprotocol/rust-sdk/pull/137)) +- *(extension)* extract http request part into rmcp extension ([#163](https://github.com/modelcontextprotocol/rust-sdk/pull/163)) +- *(transport)* support streamable http server ([#152](https://github.com/modelcontextprotocol/rust-sdk/pull/152)) +- *(oauth)* fixes + cache client credentials ([#157](https://github.com/modelcontextprotocol/rust-sdk/pull/157)) +- allow use of reqwest without ring provider ([#155](https://github.com/modelcontextprotocol/rust-sdk/pull/155)) +- extensions to context ([#102](https://github.com/modelcontextprotocol/rust-sdk/pull/102)) +- revision-2025-03-26 without streamable http ([#84](https://github.com/modelcontextprotocol/rust-sdk/pull/84)) +- *(tool)* allow tool call return a serializable value in json format ([#75](https://github.com/modelcontextprotocol/rust-sdk/pull/75)) ([#78](https://github.com/modelcontextprotocol/rust-sdk/pull/78)) +- Sse server auto ping ([#74](https://github.com/modelcontextprotocol/rust-sdk/pull/74)) +- *(transport)* Sse client transport trait ([#67](https://github.com/modelcontextprotocol/rust-sdk/pull/67)) + +### Fixed + +- let users decide what to wrap in child process command ([#279](https://github.com/modelcontextprotocol/rust-sdk/pull/279)) +- cancellable initialization process ([#280](https://github.com/modelcontextprotocol/rust-sdk/pull/280)) +- inject part into extension when handing init req ([#275](https://github.com/modelcontextprotocol/rust-sdk/pull/275)) +- streamable http server close request channel on response([#266](https://github.com/modelcontextprotocol/rust-sdk/pull/266)) ([#270](https://github.com/modelcontextprotocol/rust-sdk/pull/270)) +- streamable http client close on response ([#268](https://github.com/modelcontextprotocol/rust-sdk/pull/268)) +- expose TokioChildWrapper::id() in TokioChildProcess and TokioChildProcessOut ([#254](https://github.com/modelcontextprotocol/rust-sdk/pull/254)) +- add compatibility handling for non-standard notifications in async_rw ([#247](https://github.com/modelcontextprotocol/rust-sdk/pull/247)) +- allow SSE server router to be nested ([#240](https://github.com/modelcontextprotocol/rust-sdk/pull/240)) +- error for status in post method of streamable http client ([#238](https://github.com/modelcontextprotocol/rust-sdk/pull/238)) +- disable wasmbind in chrono for wasm32-unknown-unknown ([#234](https://github.com/modelcontextprotocol/rust-sdk/pull/234)) +- *(examples)* add clients in examples's readme ([#225](https://github.com/modelcontextprotocol/rust-sdk/pull/225)) +- generic ServerHandler ([#223](https://github.com/modelcontextprotocol/rust-sdk/pull/223)) +- comment error ([#215](https://github.com/modelcontextprotocol/rust-sdk/pull/215)) +- resolve the server 406 error in API calls ([#203](https://github.com/modelcontextprotocol/rust-sdk/pull/203)) +- sse endpoint build follow js's `new URL(url, base)` ([#197](https://github.com/modelcontextprotocol/rust-sdk/pull/197)) +- more friendly interface to get service error ([#190](https://github.com/modelcontextprotocol/rust-sdk/pull/190)) +- cleanup zombie processes for child process client ([#156](https://github.com/modelcontextprotocol/rust-sdk/pull/156)) +- *(schemar)* use self-defined settings ([#180](https://github.com/modelcontextprotocol/rust-sdk/pull/180)) +- *(transport-sse-server)* cleanup on connection drop ([#165](https://github.com/modelcontextprotocol/rust-sdk/pull/165)) +- *(test)* skip serialize tool's annotation if empty ([#160](https://github.com/modelcontextprotocol/rust-sdk/pull/160)) +- fix resource leak ([#136](https://github.com/modelcontextprotocol/rust-sdk/pull/136)) +- *(handler)* do call handler methods when initialize server ([#118](https://github.com/modelcontextprotocol/rust-sdk/pull/118)) +- *(server)* schemars compilation errors ([#104](https://github.com/modelcontextprotocol/rust-sdk/pull/104)) +- *(test)* fix test introduced by #97 ([#101](https://github.com/modelcontextprotocol/rust-sdk/pull/101)) +- *(macro)* add generics marco types support ([#98](https://github.com/modelcontextprotocol/rust-sdk/pull/98)) +- *(typo)* nit language corrections ([#90](https://github.com/modelcontextprotocol/rust-sdk/pull/90)) +- *(typo)* s/marcos/macros/ ([#85](https://github.com/modelcontextprotocol/rust-sdk/pull/85)) +- *(client)* add error enum while deal client info ([#76](https://github.com/modelcontextprotocol/rust-sdk/pull/76)) +- *(notification)* fix wrongly error report in notification ([#70](https://github.com/modelcontextprotocol/rust-sdk/pull/70)) +- *(test)* fix tool deserialization error ([#68](https://github.com/modelcontextprotocol/rust-sdk/pull/68)) +- *(server)* add error enum while deal server info ([#51](https://github.com/modelcontextprotocol/rust-sdk/pull/51)) + +### Other + +- add simpling example and test ([#289](https://github.com/modelcontextprotocol/rust-sdk/pull/289)) +- add update for test_message_schema ([#286](https://github.com/modelcontextprotocol/rust-sdk/pull/286)) +- add notion clear in model.rs ([#284](https://github.com/modelcontextprotocol/rust-sdk/pull/284)) +- cov settings, and fix several building warnings ([#281](https://github.com/modelcontextprotocol/rust-sdk/pull/281)) +- refactor tool macros and router implementation ([#261](https://github.com/modelcontextprotocol/rust-sdk/pull/261)) +- fix regression in URL joining ([#265](https://github.com/modelcontextprotocol/rust-sdk/pull/265)) +- remove erroneous definitions_path ([#264](https://github.com/modelcontextprotocol/rust-sdk/pull/264)) +- allow using a TokioCommandWrap for TokioChildProcess::new closes #243 ([#245](https://github.com/modelcontextprotocol/rust-sdk/pull/245)) +- Fix typo ([#249](https://github.com/modelcontextprotocol/rust-sdk/pull/249)) +- provide http server as tower service ([#228](https://github.com/modelcontextprotocol/rust-sdk/pull/228)) +- *(deps)* update sse-stream requirement from 0.1.4 to 0.2.0 ([#230](https://github.com/modelcontextprotocol/rust-sdk/pull/230)) +- Server info is only retrieved once during initialization ([#214](https://github.com/modelcontextprotocol/rust-sdk/pull/214)) +- *(deps)* update base64 requirement from 0.21 to 0.22 ([#209](https://github.com/modelcontextprotocol/rust-sdk/pull/209)) +- revert badge ([#202](https://github.com/modelcontextprotocol/rust-sdk/pull/202)) +- use hierarchical readme for publishing ([#198](https://github.com/modelcontextprotocol/rust-sdk/pull/198)) +- Ci/coverage badge ([#191](https://github.com/modelcontextprotocol/rust-sdk/pull/191)) +- fix error introduced by merge, and reorganize feature ([#185](https://github.com/modelcontextprotocol/rust-sdk/pull/185)) +- Transport trait and worker transport, and streamable http client with those new features. ([#167](https://github.com/modelcontextprotocol/rust-sdk/pull/167)) +- add oauth2 support ([#130](https://github.com/modelcontextprotocol/rust-sdk/pull/130)) +- remove un-used tower.rs ([#125](https://github.com/modelcontextprotocol/rust-sdk/pull/125)) +- update calculator example description ([#115](https://github.com/modelcontextprotocol/rust-sdk/pull/115)) +- fix the url ([#120](https://github.com/modelcontextprotocol/rust-sdk/pull/120)) +- add a simple chat client for example ([#119](https://github.com/modelcontextprotocol/rust-sdk/pull/119)) +- add an overview to `rmcp/src/lib.rs` ([#116](https://github.com/modelcontextprotocol/rust-sdk/pull/116)) +- *(context)* test context request handling and refactor for reusable client-server tests ([#97](https://github.com/modelcontextprotocol/rust-sdk/pull/97)) +- *(logging)* Add tests for logging ([#96](https://github.com/modelcontextprotocol/rust-sdk/pull/96)) +- Adopt Devcontainer for Development Environment ([#81](https://github.com/modelcontextprotocol/rust-sdk/pull/81)) +- fix typos ([#79](https://github.com/modelcontextprotocol/rust-sdk/pull/79)) +- format and fix typo ([#72](https://github.com/modelcontextprotocol/rust-sdk/pull/72)) +- add documentation generation job ([#59](https://github.com/modelcontextprotocol/rust-sdk/pull/59)) +- add test with js server ([#65](https://github.com/modelcontextprotocol/rust-sdk/pull/65)) +- fmt the project ([#54](https://github.com/modelcontextprotocol/rust-sdk/pull/54)) +- *(sse_server)* separate router and server startup ([#52](https://github.com/modelcontextprotocol/rust-sdk/pull/52)) +- fix broken link ([#53](https://github.com/modelcontextprotocol/rust-sdk/pull/53)) +- fix the branch name for git dependency ([#46](https://github.com/modelcontextprotocol/rust-sdk/pull/46)) +- Move whole rmcp crate to official rust sdk ([#44](https://github.com/modelcontextprotocol/rust-sdk/pull/44)) +- Initial commit diff --git a/vendor/rmcp/Cargo.toml b/vendor/rmcp/Cargo.toml new file mode 100644 index 000000000..bf25fff65 --- /dev/null +++ b/vendor/rmcp/Cargo.toml @@ -0,0 +1,904 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +rust-version = "1.88" +name = "rmcp" +version = "3.1.0" +build = "build.rs" +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Rust SDK for Model Context Protocol" +homepage = "https://github.com/modelcontextprotocol/rust-sdk" +documentation = "https://docs.rs/rmcp" +readme = "README.md" +license = "Apache-2.0" +repository = "https://github.com/modelcontextprotocol/rust-sdk/" +resolver = "2" + +[package.metadata.docs.rs] +features = [ + "auth", + "auth-client-credentials-jwt", + "base64", + "client", + "client-side-sse", + "elicitation", + "macros", + "request-state", + "reqwest", + "reqwest-native-tls", + "reqwest-tls-no-provider", + "schemars", + "server", + "server-side-http", + "tower", + "transport-async-rw", + "transport-child-process", + "transport-io", + "transport-streamable-http-client", + "transport-streamable-http-client-reqwest", + "transport-streamable-http-client-unix-socket", + "transport-streamable-http-server", + "transport-streamable-http-server-session", + "transport-worker", + "uuid", +] +rustdoc-args = [ + "--cfg", + "docsrs", +] + +[features] +__reqwest = ["dep:reqwest"] +auth = [ + "dep:oauth2", + "__reqwest", + "dep:url", +] +auth-client-credentials-jwt = [ + "auth", + "dep:jsonwebtoken", + "uuid", +] +client = ["dep:tokio-stream"] +client-side-sse = [ + "dep:sse-stream", + "dep:http", + "dep:bytes", + "base64", +] +default = [ + "base64", + "macros", + "server", +] +elicitation = ["dep:url"] +local = ["rmcp-macros?/local"] +macros = [ + "dep:rmcp-macros", + "dep:pastey", +] +request-state = [ + "dep:hmac", + "dep:sha2", + "base64", +] +reqwest = [ + "__reqwest", + "reqwest?/rustls", +] +reqwest-native-tls = [ + "__reqwest", + "reqwest?/native-tls", +] +reqwest-tls-no-provider = [ + "__reqwest", + "reqwest?/rustls-no-provider", +] +schemars = ["dep:schemars"] +server = [ + "transport-async-rw", + "schemars", + "dep:pastey", + "uuid", +] +server-side-http = [ + "uuid", + "dep:rand", + "dep:tokio-stream", + "dep:http", + "dep:http-body", + "dep:http-body-util", + "dep:bytes", + "dep:sse-stream", + "tower", + "base64", +] +tower = ["dep:tower-service"] +transport-async-rw = [ + "tokio/io-util", + "tokio-util/codec", +] +transport-child-process = [ + "transport-async-rw", + "tokio/process", + "dep:process-wrap", +] +transport-io = [ + "transport-async-rw", + "tokio/io-std", +] +transport-streamable-http-client = [ + "client-side-sse", + "transport-worker", +] +transport-streamable-http-client-reqwest = [ + "transport-streamable-http-client", + "__reqwest", +] +transport-streamable-http-client-unix-socket = [ + "transport-streamable-http-client", + "dep:hyper", + "dep:hyper-util", + "dep:http-body-util", + "dep:http", + "dep:bytes", + "tokio/net", +] +transport-streamable-http-server = [ + "transport-streamable-http-server-session", + "server-side-http", + "transport-worker", +] +transport-streamable-http-server-session = [ + "transport-async-rw", + "dep:tokio-stream", +] +transport-worker = ["dep:tokio-stream"] +which-command = [ + "transport-child-process", + "dep:which", +] + +[lib] +name = "rmcp" +path = "src/lib.rs" + +[[test]] +name = "test_auth_error_classification" +path = "tests/test_auth_error_classification.rs" +required-features = [ + "auth", + "client", + "transport-streamable-http-client", +] + +[[test]] +name = "test_cache_hints" +path = "tests/test_cache_hints.rs" + +[[test]] +name = "test_cancelled_response" +path = "tests/test_cancelled_response.rs" + +[[test]] +name = "test_client_credentials" +path = "tests/test_client_credentials.rs" +required-features = ["auth"] + +[[test]] +name = "test_client_initialization" +path = "tests/test_client_initialization.rs" + +[[test]] +name = "test_client_lifecycle_modes" +path = "tests/test_client_lifecycle_modes.rs" +required-features = [ + "client", + "server", +] + +[[test]] +name = "test_close_connection" +path = "tests/test_close_connection.rs" +required-features = [ + "server", + "client", +] + +[[test]] +name = "test_completion" +path = "tests/test_completion.rs" + +[[test]] +name = "test_complex_schema" +path = "tests/test_complex_schema.rs" + +[[test]] +name = "test_custom_headers" +path = "tests/test_custom_headers.rs" +required-features = [ + "client", + "server", + "transport-streamable-http-client-reqwest", + "transport-streamable-http-server", +] + +[[test]] +name = "test_custom_request" +path = "tests/test_custom_request.rs" +required-features = [ + "server", + "client", +] + +[[test]] +name = "test_deserialization" +path = "tests/test_deserialization.rs" + +[[test]] +name = "test_discover_http_client_startup" +path = "tests/test_discover_http_client_startup.rs" +required-features = [ + "client", + "reqwest", + "transport-streamable-http-client-reqwest", + "transport-streamable-http-server", +] + +[[test]] +name = "test_elicitation" +path = "tests/test_elicitation.rs" +required-features = [ + "elicitation", + "client", + "server", +] + +[[test]] +name = "test_embedded_resource_meta" +path = "tests/test_embedded_resource_meta.rs" + +[[test]] +name = "test_handler_wrappers" +path = "tests/test_handler_wrappers.rs" + +[[test]] +name = "test_inflight_response_drain" +path = "tests/test_inflight_response_drain.rs" + +[[test]] +name = "test_json_schema_detection" +path = "tests/test_json_schema_detection.rs" + +[[test]] +name = "test_list_tools_result" +path = "tests/test_list_tools_result.rs" + +[[test]] +name = "test_local_client_handler" +path = "tests/test_local_client_handler.rs" +required-features = [ + "client", + "local", +] + +[[test]] +name = "test_logging" +path = "tests/test_logging.rs" +required-features = [ + "server", + "client", +] + +[[test]] +name = "test_message_protocol" +path = "tests/test_message_protocol.rs" +required-features = ["client"] + +[[test]] +name = "test_message_schema" +path = "tests/test_message_schema.rs" +required-features = [ + "server", + "client", + "schemars", +] + +[[test]] +name = "test_meta_helpers" +path = "tests/test_meta_helpers.rs" + +[[test]] +name = "test_mrtr_behavior" +path = "tests/test_mrtr_behavior.rs" +required-features = [ + "server", + "client", +] + +[[test]] +name = "test_notification" +path = "tests/test_notification.rs" +required-features = [ + "server", + "client", +] + +[[test]] +name = "test_progress_subscriber" +path = "tests/test_progress_subscriber.rs" +required-features = [ + "server", + "client", + "macros", +] + +[[test]] +name = "test_prompt_handler" +path = "tests/test_prompt_handler.rs" + +[[test]] +name = "test_prompt_macro_annotations" +path = "tests/test_prompt_macro_annotations.rs" + +[[test]] +name = "test_prompt_macros" +path = "tests/test_prompt_macros.rs" +required-features = [ + "server", + "client", +] + +[[test]] +name = "test_prompt_routers" +path = "tests/test_prompt_routers.rs" + +[[test]] +name = "test_protocol_version_negotiation" +path = "tests/test_protocol_version_negotiation.rs" +required-features = [ + "server", + "client", +] + +[[test]] +name = "test_request_timeout_progress" +path = "tests/test_request_timeout_progress.rs" +required-features = [ + "server", + "client", + "macros", +] + +[[test]] +name = "test_resource_link" +path = "tests/test_resource_link.rs" + +[[test]] +name = "test_resource_link_integration" +path = "tests/test_resource_link_integration.rs" + +[[test]] +name = "test_resource_not_found_version" +path = "tests/test_resource_not_found_version.rs" + +[[test]] +name = "test_result_type_version" +path = "tests/test_result_type_version.rs" + +[[test]] +name = "test_result_type_wire" +path = "tests/test_result_type_wire.rs" + +[[test]] +name = "test_sampling" +path = "tests/test_sampling.rs" +required-features = [ + "server", + "client", +] + +[[test]] +name = "test_sep_2260_request_association" +path = "tests/test_sep_2260_request_association.rs" + +[[test]] +name = "test_sep_2260_stream_enforcement" +path = "tests/test_sep_2260_stream_enforcement.rs" + +[[test]] +name = "test_sep_2260_stream_routing" +path = "tests/test_sep_2260_stream_routing.rs" +required-features = [ + "server", + "elicitation", + "transport-streamable-http-server", + "reqwest", +] + +[[test]] +name = "test_server_discover" +path = "tests/test_server_discover.rs" + +[[test]] +name = "test_server_discover_client" +path = "tests/test_server_discover_client.rs" + +[[test]] +name = "test_server_discover_http" +path = "tests/test_server_discover_http.rs" + +[[test]] +name = "test_server_initialization" +path = "tests/test_server_initialization.rs" + +[[test]] +name = "test_sse_concurrent_streams" +path = "tests/test_sse_concurrent_streams.rs" +required-features = [ + "server", + "client", + "transport-streamable-http-server", + "transport-streamable-http-client", + "reqwest", +] + +[[test]] +name = "test_stateless_protocol_version" +path = "tests/test_stateless_protocol_version.rs" +required-features = [ + "server", + "transport-streamable-http-server", + "reqwest", +] + +[[test]] +name = "test_stateless_server_requests" +path = "tests/test_stateless_server_requests.rs" +required-features = [ + "client", + "server", +] + +[[test]] +name = "test_stdio_response_concurrency" +path = "tests/test_stdio_response_concurrency.rs" + +[[test]] +name = "test_streamable_http_4xx_error_body" +path = "tests/test_streamable_http_4xx_error_body.rs" +required-features = [ + "transport-streamable-http-client", + "transport-streamable-http-client-reqwest", +] + +[[test]] +name = "test_streamable_http_connection_reuse" +path = "tests/test_streamable_http_connection_reuse.rs" +required-features = [ + "server", + "client", + "macros", + "schemars", + "transport-streamable-http-server", + "transport-streamable-http-client", + "transport-streamable-http-client-reqwest", +] + +[[test]] +name = "test_streamable_http_disconnect_cancel" +path = "tests/test_streamable_http_disconnect_cancel.rs" +required-features = [ + "server", + "transport-streamable-http-server", + "reqwest", +] + +[[test]] +name = "test_streamable_http_empty_2xx_notification" +path = "tests/test_streamable_http_empty_2xx_notification.rs" + +[[test]] +name = "test_streamable_http_event_store" +path = "tests/test_streamable_http_event_store.rs" +required-features = [ + "client", + "server", + "transport-streamable-http-client-reqwest", + "transport-streamable-http-server", +] + +[[test]] +name = "test_streamable_http_idle_timeout_log" +path = "tests/test_streamable_http_idle_timeout_log.rs" + +[[test]] +name = "test_streamable_http_init_timeout" +path = "tests/test_streamable_http_init_timeout.rs" + +[[test]] +name = "test_streamable_http_json_response" +path = "tests/test_streamable_http_json_response.rs" +required-features = [ + "server", + "client", + "transport-streamable-http-server", + "reqwest", +] + +[[test]] +name = "test_streamable_http_priming" +path = "tests/test_streamable_http_priming.rs" +required-features = [ + "server", + "client", + "transport-streamable-http-server", + "reqwest", +] + +[[test]] +name = "test_streamable_http_protocol_version" +path = "tests/test_streamable_http_protocol_version.rs" +required-features = [ + "server", + "client", + "transport-streamable-http-server", + "reqwest", +] + +[[test]] +name = "test_streamable_http_session_store" +path = "tests/test_streamable_http_session_store.rs" +required-features = [ + "client", + "server", + "transport-streamable-http-client-reqwest", + "transport-streamable-http-server", +] + +[[test]] +name = "test_streamable_http_stale_session" +path = "tests/test_streamable_http_stale_session.rs" +required-features = [ + "server", + "client", + "transport-streamable-http-server", + "transport-streamable-http-client", + "transport-streamable-http-client-reqwest", +] + +[[test]] +name = "test_streamable_http_standard_headers" +path = "tests/test_streamable_http_standard_headers.rs" +required-features = [ + "server", + "client", + "transport-streamable-http-server", + "reqwest", +] + +[[test]] +name = "test_structured_output" +path = "tests/test_structured_output.rs" + +[[test]] +name = "test_subscriptions" +path = "tests/test_subscriptions.rs" + +[[test]] +name = "test_subscriptions_model" +path = "tests/test_subscriptions_model.rs" + +[[test]] +name = "test_subscriptions_streamable_http" +path = "tests/test_subscriptions_streamable_http.rs" + +[[test]] +name = "test_task" +path = "tests/test_task.rs" +required-features = [ + "server", + "client", + "macros", +] + +[[test]] +name = "test_tool_builder_methods" +path = "tests/test_tool_builder_methods.rs" + +[[test]] +name = "test_tool_disable_notification" +path = "tests/test_tool_disable_notification.rs" + +[[test]] +name = "test_tool_handler" +path = "tests/test_tool_handler.rs" + +[[test]] +name = "test_tool_macro_annotations" +path = "tests/test_tool_macro_annotations.rs" + +[[test]] +name = "test_tool_macros" +path = "tests/test_tool_macros.rs" +required-features = [ + "server", + "client", +] + +[[test]] +name = "test_tool_result_meta" +path = "tests/test_tool_result_meta.rs" + +[[test]] +name = "test_tool_routers" +path = "tests/test_tool_routers.rs" + +[[test]] +name = "test_trace_context" +path = "tests/test_trace_context.rs" +required-features = [ + "server", + "client", +] + +[[test]] +name = "test_unix_socket_transport" +path = "tests/test_unix_socket_transport.rs" +required-features = [ + "client", + "server", + "transport-streamable-http-client-unix-socket", +] + +[[test]] +name = "test_with_js" +path = "tests/test_with_js.rs" +required-features = [ + "server", + "client", + "transport-child-process", + "transport-streamable-http-server", + "transport-streamable-http-client", + "__reqwest", +] + +[[test]] +name = "test_with_python" +path = "tests/test_with_python.rs" +required-features = [ + "reqwest", + "server", + "client", + "transport-child-process", +] + +[dependencies.async-trait] +version = "0.1.89" + +[dependencies.base64] +version = "0.23" +optional = true + +[dependencies.bytes] +version = "1" +optional = true + +[dependencies.futures] +version = "0.3" + +[dependencies.hmac] +version = "0.13" +optional = true + +[dependencies.http] +version = "1" +optional = true + +[dependencies.http-body] +version = "1" +optional = true + +[dependencies.http-body-util] +version = "0.1" +optional = true + +[dependencies.hyper] +version = "1" +features = [ + "client", + "http1", +] +optional = true + +[dependencies.hyper-util] +version = "0.1" +features = ["tokio"] +optional = true + +[dependencies.jsonwebtoken] +version = "11" +features = ["aws_lc_rs"] +optional = true + +[dependencies.oauth2] +version = "5.0" +optional = true +default-features = false + +[dependencies.pastey] +version = "0.2.0" +optional = true + +[dependencies.pin-project-lite] +version = "0.2" + +[dependencies.process-wrap] +version = "9.0" +features = ["tokio1"] +optional = true + +[dependencies.rand] +version = "0.10" +optional = true + +[dependencies.reqwest] +version = "0.13.2" +features = [ + "json", + "stream", +] +optional = true +default-features = false + +[dependencies.rmcp-macros] +version = "3.1.0" +optional = true + +[dependencies.schemars] +version = "1.0" +features = ["chrono04"] +optional = true + +[dependencies.serde] +version = "1.0" +features = [ + "derive", + "rc", +] + +[dependencies.serde_json] +version = "1.0" + +[dependencies.sha2] +version = "0.11" +optional = true + +[dependencies.sse-stream] +version = "0.2.4" +optional = true + +[dependencies.thiserror] +version = "2" + +[dependencies.tokio] +version = "1" +features = [ + "sync", + "macros", + "rt", + "time", +] + +[dependencies.tokio-stream] +version = "0.1" +optional = true + +[dependencies.tokio-util] +version = "0.7" + +[dependencies.tower-service] +version = "0.3" +optional = true + +[dependencies.tracing] +version = "0.1" + +[dependencies.url] +version = "2.4" +optional = true + +[dependencies.uuid] +version = "1" +features = ["v4"] +optional = true + +[dependencies.which] +version = "8" +optional = true + +[dev-dependencies.anyhow] +version = "1.0" + +[dev-dependencies.async-trait] +version = "0.1" + +[dev-dependencies.axum] +version = "0.8" +features = [ + "http1", + "tokio", +] +default-features = false + +[dev-dependencies.hyper] +version = "1" +features = [ + "server", + "http1", +] + +[dev-dependencies.hyper-util] +version = "0.1" +features = ["tokio"] + +[dev-dependencies.rstest] +version = "0.26.1" + +[dev-dependencies.schemars] +version = "1.1.0" +features = ["chrono04"] + +[dev-dependencies.tokio] +version = "1" +features = ["full"] + +[dev-dependencies.tower-service] +version = "0.3" + +[dev-dependencies.tracing-subscriber] +version = "0.3" +features = [ + "env-filter", + "std", + "fmt", +] + +[dev-dependencies.url] +version = "2.4" + +[target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies.chrono] +version = "0.4.38" +features = [ + "serde", + "clock", + "std", + "oldtime", +] +default-features = false + +[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies.chrono] +version = "0.4.38" +features = [ + "serde", + "now", +] +default-features = false + +[lints.clippy] +exhaustive_enums = "warn" +exhaustive_structs = "warn" diff --git a/vendor/rmcp/Cargo.toml.orig b/vendor/rmcp/Cargo.toml.orig new file mode 100644 index 000000000..7ce31aa0c --- /dev/null +++ b/vendor/rmcp/Cargo.toml.orig @@ -0,0 +1,463 @@ +[package] +name = "rmcp" +license = { workspace = true } +version = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +repository = { workspace = true } +homepage = { workspace = true } +readme = { workspace = true } +description = "Rust SDK for Model Context Protocol" +documentation = "https://docs.rs/rmcp" + +[lints.clippy] +exhaustive_structs = "warn" +exhaustive_enums = "warn" + +[package.metadata.docs.rs] +features = [ + "auth", + "auth-client-credentials-jwt", + "base64", + "client", + "client-side-sse", + "elicitation", + "macros", + "request-state", + "reqwest", + "reqwest-native-tls", + "reqwest-tls-no-provider", + "schemars", + "server", + "server-side-http", + "tower", + "transport-async-rw", + "transport-child-process", + "transport-io", + "transport-streamable-http-client", + "transport-streamable-http-client-reqwest", + "transport-streamable-http-client-unix-socket", + "transport-streamable-http-server", + "transport-streamable-http-server-session", + "transport-worker", + "uuid", +] +rustdoc-args = ["--cfg", "docsrs"] + +[dependencies] +async-trait = "0.1.89" +serde = { version = "1.0", features = ["derive", "rc"] } +serde_json = "1.0" +thiserror = "2" +tokio = { version = "1", features = ["sync", "macros", "rt", "time"] } +futures = "0.3" +tracing = { version = "0.1" } +tokio-util = { version = "0.7" } +pin-project-lite = "0.2" +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 = "11", optional = true, features = ["aws_lc_rs"] } + +# for auto generate schema +schemars = { version = "1.0", optional = true, features = ["chrono04"] } + +# for image encoding +base64 = { version = "0.23", optional = true } + +# for SEP-2322 requestState integrity sealing (opt-in via the `request-state` feature) +hmac = { version = "0.13", optional = true } +sha2 = { version = "0.11", optional = true } + +# for HTTP client +reqwest = { version = "0.13.2", default-features = false, features = [ + "json", + "stream", +], optional = true } + +sse-stream = { version = "0.2.4", optional = true } + +http = { version = "1", optional = true } +url = { version = "2.4", optional = true } + +# For tower compatibility +tower-service = { version = "0.3", optional = true } + +# for child process transport +process-wrap = { version = "9.0", features = ["tokio1"], optional = true } + +# for cross-platform executable path resolution +which = { version = "8", optional = true } + +# for ws transport +# tokio-tungstenite ={ version = "0.26", optional = true } + +# for http-server transport +rand = { version = "0.10", optional = true } +tokio-stream = { version = "0.1", optional = true } +uuid = { version = "1", features = ["v4"], optional = true } +http-body = { version = "1", optional = true } +http-body-util = { version = "0.1", optional = true } +bytes = { version = "1", optional = true } + +# for unix socket transport +hyper = { version = "1", features = ["client", "http1"], optional = true } +hyper-util = { version = "0.1", features = ["tokio"], optional = true } + +# macro +rmcp-macros = { version = "3.1.0", optional = true } +[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies] +chrono = { version = "0.4.38", default-features = false, features = ["serde", "now"] } + +[target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] +chrono = { version = "0.4.38", default-features = false, features = [ + "serde", + "clock", + "std", + "oldtime", +] } + +[features] +default = ["base64", "macros", "server"] +local = ["rmcp-macros?/local"] +client = ["dep:tokio-stream"] +server = ["transport-async-rw", "schemars", "dep:pastey", "uuid"] +macros = ["dep:rmcp-macros", "dep:pastey"] +elicitation = ["dep:url"] + +# SEP-2322 requestState integrity helper (HMAC-SHA256 seal/open codec) +request-state = ["dep:hmac", "dep:sha2", "base64"] + +# reqwest http client +__reqwest = ["dep:reqwest"] + +reqwest = ["__reqwest", "reqwest?/rustls"] + +reqwest-tls-no-provider = ["__reqwest", "reqwest?/rustls-no-provider"] + +reqwest-native-tls = ["__reqwest", "reqwest?/native-tls"] + +server-side-http = [ + "uuid", + "dep:rand", + "dep:tokio-stream", + "dep:http", + "dep:http-body", + "dep:http-body-util", + "dep:bytes", + "dep:sse-stream", + "tower", + "base64", +] + +transport-worker = ["dep:tokio-stream"] + +# SSE stream parsing utilities (used by streamable HTTP client for SSE-formatted responses) +client-side-sse = ["dep:sse-stream", "dep:http", "dep:bytes", "base64"] + +# Streamable HTTP client +transport-streamable-http-client = ["client-side-sse", "transport-worker"] +transport-streamable-http-client-reqwest = ["transport-streamable-http-client", "__reqwest"] +transport-streamable-http-client-unix-socket = [ + "transport-streamable-http-client", + "dep:hyper", + "dep:hyper-util", + "dep:http-body-util", + "dep:http", + "dep:bytes", + "tokio/net", +] + +transport-async-rw = ["tokio/io-util", "tokio-util/codec"] +transport-io = ["transport-async-rw", "tokio/io-std"] +transport-child-process = [ + "transport-async-rw", + "tokio/process", + "dep:process-wrap", +] +which-command = [ + "transport-child-process", + "dep:which", +] +transport-streamable-http-server = [ + "transport-streamable-http-server-session", + "server-side-http", + "transport-worker", +] +transport-streamable-http-server-session = [ + "transport-async-rw", + "dep:tokio-stream", +] +# transport-ws = ["transport-io", "dep:tokio-tungstenite"] +tower = ["dep:tower-service"] +auth = ["dep:oauth2", "__reqwest", "dep:url"] +auth-client-credentials-jwt = ["auth", "dep:jsonwebtoken", "uuid"] +schemars = ["dep:schemars"] + +[dev-dependencies] +tokio = { version = "1", features = ["full"] } +schemars = { version = "1.1.0", features = ["chrono04"] } +axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] } +hyper = { version = "1", features = ["server", "http1"] } +hyper-util = { version = "0.1", features = ["tokio"] } +tower-service = "0.3" +url = "2.4" +anyhow = "1.0" +tracing-subscriber = { version = "0.3", features = [ + "env-filter", + "std", + "fmt", +] } +async-trait = "0.1" +rstest = "0.26.1" +[[test]] +name = "test_tool_macros" +required-features = ["server", "client"] +path = "tests/test_tool_macros.rs" + +[[test]] +name = "test_with_python" +required-features = [ + "reqwest", + "server", + "client", + "transport-child-process", +] +path = "tests/test_with_python.rs" + +[[test]] +name = "test_with_js" +required-features = [ + "server", + "client", + "transport-child-process", + "transport-streamable-http-server", + "transport-streamable-http-client", + "__reqwest", +] +path = "tests/test_with_js.rs" + +[[test]] +name = "test_local_client_handler" +required-features = ["client", "local"] +path = "tests/test_local_client_handler.rs" + +[[test]] +name = "test_notification" +required-features = ["server", "client"] +path = "tests/test_notification.rs" + +[[test]] +name = "test_logging" +required-features = ["server", "client"] +path = "tests/test_logging.rs" + +[[test]] +name = "test_message_protocol" +required-features = ["client"] +path = "tests/test_message_protocol.rs" + +[[test]] +name = "test_message_schema" +required-features = ["server", "client", "schemars"] +path = "tests/test_message_schema.rs" + +[[test]] +name = "test_progress_subscriber" +required-features = ["server", "client", "macros"] +path = "tests/test_progress_subscriber.rs" + +[[test]] +name = "test_request_timeout_progress" +required-features = ["server", "client", "macros"] +path = "tests/test_request_timeout_progress.rs" + +[[test]] +name = "test_elicitation" +required-features = ["elicitation", "client", "server"] +path = "tests/test_elicitation.rs" + +[[test]] +name = "test_task" +required-features = ["server", "client", "macros"] +path = "tests/test_task.rs" + +[[test]] +name = "test_streamable_http_priming" +required-features = ["server", "client", "transport-streamable-http-server", "reqwest"] +path = "tests/test_streamable_http_priming.rs" + +[[test]] +name = "test_streamable_http_json_response" +required-features = ["server", "client", "transport-streamable-http-server", "reqwest"] +path = "tests/test_streamable_http_json_response.rs" + +[[test]] +name = "test_streamable_http_protocol_version" +required-features = ["server", "client", "transport-streamable-http-server", "reqwest"] +path = "tests/test_streamable_http_protocol_version.rs" + +[[test]] +name = "test_stateless_protocol_version" +required-features = ["server", "transport-streamable-http-server", "reqwest"] +path = "tests/test_stateless_protocol_version.rs" + +[[test]] +name = "test_protocol_version_negotiation" +required-features = ["server", "client"] +path = "tests/test_protocol_version_negotiation.rs" + +[[test]] +name = "test_client_lifecycle_modes" +required-features = ["client", "server"] +path = "tests/test_client_lifecycle_modes.rs" + +[[test]] +name = "test_stateless_server_requests" +required-features = ["client", "server"] +path = "tests/test_stateless_server_requests.rs" + +[[test]] +name = "test_discover_http_client_startup" +required-features = [ + "client", + "reqwest", + "transport-streamable-http-client-reqwest", + "transport-streamable-http-server", +] +path = "tests/test_discover_http_client_startup.rs" + +[[test]] +name = "test_streamable_http_standard_headers" +required-features = ["server", "client", "transport-streamable-http-server", "reqwest"] +path = "tests/test_streamable_http_standard_headers.rs" + +[[test]] +name = "test_streamable_http_4xx_error_body" +required-features = ["transport-streamable-http-client", "transport-streamable-http-client-reqwest"] +path = "tests/test_streamable_http_4xx_error_body.rs" + + +[[test]] +name = "test_custom_request" +required-features = ["server", "client"] +path = "tests/test_custom_request.rs" + +[[test]] +name = "test_trace_context" +required-features = ["server", "client"] +path = "tests/test_trace_context.rs" + +[[test]] +name = "test_mrtr_behavior" +required-features = ["server", "client"] +path = "tests/test_mrtr_behavior.rs" + +[[test]] +name = "test_prompt_macros" +required-features = ["server", "client"] +path = "tests/test_prompt_macros.rs" + +[[test]] +name = "test_sampling" +required-features = ["server", "client"] +path = "tests/test_sampling.rs" + + +[[test]] +name = "test_close_connection" +required-features = ["server", "client"] +path = "tests/test_close_connection.rs" + +[[test]] +name = "test_custom_headers" +required-features = [ + "client", + "server", + "transport-streamable-http-client-reqwest", + "transport-streamable-http-server", +] +path = "tests/test_custom_headers.rs" + +[[test]] +name = "test_sse_concurrent_streams" +required-features = ["server", "client", "transport-streamable-http-server", "transport-streamable-http-client", "reqwest"] +path = "tests/test_sse_concurrent_streams.rs" + +[[test]] +name = "test_client_credentials" +required-features = ["auth"] +path = "tests/test_client_credentials.rs" + +[[test]] +name = "test_auth_error_classification" +required-features = ["auth", "client", "transport-streamable-http-client"] +path = "tests/test_auth_error_classification.rs" + +[[test]] +name = "test_unix_socket_transport" +required-features = [ + "client", + "server", + "transport-streamable-http-client-unix-socket", +] +path = "tests/test_unix_socket_transport.rs" + +[[test]] +name = "test_streamable_http_stale_session" +required-features = [ + "server", + "client", + "transport-streamable-http-server", + "transport-streamable-http-client", + "transport-streamable-http-client-reqwest" +] +path = "tests/test_streamable_http_stale_session.rs" + +[[test]] +name = "test_streamable_http_session_store" +required-features = [ + "client", + "server", + "transport-streamable-http-client-reqwest", + "transport-streamable-http-server", +] +path = "tests/test_streamable_http_session_store.rs" + +[[test]] +name = "test_streamable_http_event_store" +required-features = [ + "client", + "server", + "transport-streamable-http-client-reqwest", + "transport-streamable-http-server", +] +path = "tests/test_streamable_http_event_store.rs" + +[[test]] +name = "test_streamable_http_connection_reuse" +required-features = [ + "server", + "client", + "macros", + "schemars", + "transport-streamable-http-server", + "transport-streamable-http-client", + "transport-streamable-http-client-reqwest", +] +path = "tests/test_streamable_http_connection_reuse.rs" + +[[test]] +name = "test_streamable_http_disconnect_cancel" +required-features = [ + "server", + "transport-streamable-http-server", + "reqwest", +] +path = "tests/test_streamable_http_disconnect_cancel.rs" + +[[test]] +name = "test_sep_2260_stream_routing" +required-features = ["server", "elicitation", "transport-streamable-http-server", "reqwest"] +path = "tests/test_sep_2260_stream_routing.rs" diff --git a/vendor/rmcp/README.md b/vendor/rmcp/README.md new file mode 100644 index 000000000..bb7837e84 --- /dev/null +++ b/vendor/rmcp/README.md @@ -0,0 +1,66 @@ + + +
+ +# rmcp + +[![Crates.io](https://img.shields.io/crates/v/rmcp.svg)](https://crates.io/crates/rmcp) +[![Documentation](https://docs.rs/rmcp/badge.svg)](https://docs.rs/rmcp) + +
+ +The official Rust SDK for the [Model Context Protocol](https://modelcontextprotocol.io/specification/2026-07-28). Build MCP servers that expose tools, resources, and prompts to AI assistants — or build clients that connect to them. + +For **getting started**, **usage guides**, and **full MCP feature documentation** (resources, prompts, sampling, roots, logging, completions, subscriptions, etc.), see the [main README](../../README.md). + +## Feature Flags + +| Feature | Description | Default | +|---------|-------------|---------| +| `server` | Server functionality and the tool system | ✅ | +| `client` | Client functionality | | +| `macros` | `#[tool]` / `#[prompt]` macros (re-exports [`rmcp-macros`](../rmcp-macros)) | ✅ | +| `schemars` | JSON Schema generation for tool definitions | | +| `auth` | OAuth 2.0 authentication support | | +| `elicitation` | Elicitation support | | + +### Transport features + +| Feature | Description | +|---------|-------------| +| `transport-io` | Server-side stdio transport | +| `transport-child-process` | Client-side stdio transport (spawns a child process) | +| `transport-async-rw` | Generic async read/write transport | +| `transport-streamable-http-client` | Streamable HTTP client (transport-agnostic) | +| `transport-streamable-http-client-reqwest` | Streamable HTTP client with default `reqwest` backend | +| `transport-streamable-http-server` | Streamable HTTP server transport | + +### TLS backend options (for HTTP transports) + +| Feature | Description | +|---------|-------------| +| `reqwest` | Uses rustls — pure Rust TLS (recommended default) | +| `reqwest-native-tls` | Uses platform-native TLS (OpenSSL / Secure Transport / SChannel) | +| `reqwest-tls-no-provider` | Uses rustls without a default crypto provider (bring your own) | + +## Transports + +The transport layer is pluggable. Two built-in pairs cover the most common cases: + +| | Client | Server | +|:-:|:-:|:-:| +| **stdio** | [`TokioChildProcess`](crate::transport::TokioChildProcess) | [`stdio`](crate::transport::stdio) | +| **Streamable HTTP** | [`StreamableHttpClientTransport`](crate::transport::StreamableHttpClientTransport) | `StreamableHttpService` | + +Any type that implements the [`Transport`](crate::transport::Transport) trait can be used. The [`IntoTransport`](crate::transport::IntoTransport) helper trait provides automatic conversions from: + +1. `(Sink, Stream)` or a combined `Sink + Stream` +2. `(AsyncRead, AsyncWrite)` or a combined `AsyncRead + AsyncWrite` +3. A [`Worker`](crate::transport::worker::Worker) implementation +4. A [`Transport`](crate::transport::Transport) implementation directly + +## License + +This project is licensed under the terms specified in the repository's [LICENSE](../../LICENSE) file. diff --git a/vendor/rmcp/build.rs b/vendor/rmcp/build.rs new file mode 100644 index 000000000..05702b405 --- /dev/null +++ b/vendor/rmcp/build.rs @@ -0,0 +1,23 @@ +// Install git hooks on build +fn main() { + // Only run in the workspace root (not when building as a dependency) + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let workspace_root = std::path::Path::new(&manifest_dir) + .parent() + .and_then(|p| p.parent()); + + if let Some(root) = workspace_root { + let githooks_dir = root.join(".githooks"); + let git_dir = root.join(".git"); + + // Only configure if we're in the actual workspace (not a dependency) + // and git directory exists + if githooks_dir.exists() && git_dir.exists() { + // Configure git to use our hooks directory + let _ = std::process::Command::new("git") + .args(["config", "core.hooksPath", ".githooks"]) + .current_dir(root) + .output(); + } + } +} diff --git a/vendor/rmcp/src/error.rs b/vendor/rmcp/src/error.rs new file mode 100644 index 000000000..0940f29c2 --- /dev/null +++ b/vendor/rmcp/src/error.rs @@ -0,0 +1,72 @@ +use std::{borrow::Cow, fmt::Display}; + +pub use crate::model::ErrorData; +impl Display for ErrorData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.code.0, self.message)?; + if let Some(data) = &self.data { + write!(f, "({})", data)?; + } + Ok(()) + } +} + +impl std::error::Error for ErrorData {} + +#[cfg(all(feature = "auth", any(feature = "client", feature = "server")))] +pub(crate) struct ErrorChain<'a>(pub(crate) &'a (dyn std::error::Error + 'static)); + +#[cfg(all(feature = "auth", any(feature = "client", feature = "server")))] +impl Display for ErrorChain<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0)?; + for source in std::iter::successors(self.0.source(), |source| source.source()) { + write!(f, "\n Caused by: {source}")?; + } + Ok(()) + } +} + +/// This is an unified error type for the errors could be returned by the service. +#[derive(Debug, thiserror::Error)] +#[allow(clippy::large_enum_variant)] +#[non_exhaustive] +pub enum RmcpError { + #[cfg(any(feature = "client", feature = "server"))] + #[error("Service error: {0}")] + Service(#[from] crate::ServiceError), + #[cfg(feature = "client")] + #[error("Client initialization error: {0}")] + ClientInitialize(#[from] crate::service::ClientInitializeError), + #[cfg(feature = "server")] + #[error("Server initialization error: {0}")] + ServerInitialize(#[from] crate::service::ServerInitializeError), + #[error("Runtime error: {0}")] + Runtime(#[from] tokio::task::JoinError), + #[error("Transport creation error: {error}")] + // TODO: Maybe we can introduce something like `TryIntoTransport` to auto wrap transport type, + // but it could be an breaking change, so we could do it in the future. + TransportCreation { + into_transport_type_name: Cow<'static, str>, + into_transport_type_id: std::any::TypeId, + #[source] + error: Box, + }, + // and cancellation shouldn't be an error? + + // TODO: add more error variants as needed + #[error("Task error: {0}")] + TaskError(String), +} + +impl RmcpError { + pub fn transport_creation( + error: impl Into>, + ) -> Self { + RmcpError::TransportCreation { + into_transport_type_id: std::any::TypeId::of::(), + into_transport_type_name: std::any::type_name::().into(), + error: error.into(), + } + } +} diff --git a/vendor/rmcp/src/handler.rs b/vendor/rmcp/src/handler.rs new file mode 100644 index 000000000..6b848cd2c --- /dev/null +++ b/vendor/rmcp/src/handler.rs @@ -0,0 +1,4 @@ +#[cfg(feature = "client")] +pub mod client; +#[cfg(feature = "server")] +pub mod server; diff --git a/vendor/rmcp/src/handler/client.rs b/vendor/rmcp/src/handler/client.rs new file mode 100644 index 000000000..414387028 --- /dev/null +++ b/vendor/rmcp/src/handler/client.rs @@ -0,0 +1,433 @@ +// Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected. +#![expect(deprecated)] +pub mod progress; +use std::sync::Arc; + +use crate::{ + error::ErrorData as McpError, + model::*, + service::{ + MaybeSendFuture, NotificationContext, RequestContext, RoleClient, Service, ServiceRole, + }, +}; + +impl Service for H { + async fn handle_request( + &self, + request: ::PeerReq, + context: RequestContext, + ) -> Result<::Resp, McpError> { + match request { + ServerRequest::PingRequest(_) => self.ping(context).await.map(ClientResult::empty), + ServerRequest::CreateMessageRequest(request) => self + .create_message(request.params, context) + .await + .map(Box::new) + .map(ClientResult::CreateMessageResult), + ServerRequest::ListRootsRequest(_) => self + .list_roots(context) + .await + .map(ClientResult::ListRootsResult), + ServerRequest::ElicitRequest(request) => self + .create_elicitation(request.params, context) + .await + .map(ClientResult::ElicitResult), + ServerRequest::CustomRequest(request) => self + .on_custom_request(request, context) + .await + .map(ClientResult::CustomResult), + } + } + + async fn handle_notification( + &self, + notification: ::PeerNot, + context: NotificationContext, + ) -> Result<(), McpError> { + match notification { + ServerNotification::CancelledNotification(notification) => { + self.on_cancelled(notification.params, context).await + } + ServerNotification::ProgressNotification(notification) => { + self.on_progress(notification.params, context).await + } + ServerNotification::LoggingMessageNotification(notification) => { + self.on_logging_message(notification.params, context).await + } + ServerNotification::ResourceUpdatedNotification(notification) => { + self.on_resource_updated(notification.params, context).await + } + ServerNotification::ResourceListChangedNotification(_notification_no_param) => { + self.on_resource_list_changed(context).await + } + ServerNotification::ToolListChangedNotification(_notification_no_param) => { + self.on_tool_list_changed(context).await + } + ServerNotification::PromptListChangedNotification(_notification_no_param) => { + self.on_prompt_list_changed(context).await + } + ServerNotification::SubscriptionsAcknowledgedNotification(notification) => { + self.on_subscriptions_acknowledged(notification.params, context) + .await + } + ServerNotification::TaskStatusNotification(notification) => { + self.on_task_status(notification.params, context).await + } + ServerNotification::CustomNotification(notification) => { + self.on_custom_notification(notification, context).await + } + }; + Ok(()) + } + + fn get_info(&self) -> ::Info { + self.get_info() + } +} + +macro_rules! client_handler_methods { + () => { + fn ping( + &self, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Ok(())) + } + + fn create_message( + &self, + params: CreateMessageRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Err( + McpError::method_not_found::(), + )) + } + + fn list_roots( + &self, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Ok(ListRootsResult::default())) + } + + /// Handle an elicitation request from a server asking for user input. + /// + /// This method is called when a server needs interactive input from the user + /// during tool execution. Implementations should present the message to the user, + /// collect their input according to the requested schema, and return the result. + /// + /// # Arguments + /// * `request` - The elicitation request with message and schema + /// * `context` - The request context + /// + /// # Returns + /// The user's response including action (accept/decline/cancel) and optional data + /// + /// # Default Behavior + /// The default implementation automatically declines all elicitation requests. + /// Real clients should override this to provide user interaction. + /// + /// # Example + /// ```rust,no_run + /// use rmcp::{ + /// ClientHandler, + /// model::{ + /// ElicitRequestParams, ElicitResult, ElicitationAction, ElicitationSchema, + /// ErrorData as McpError, + /// }, + /// service::{RequestContext, RoleClient}, + /// }; + /// + /// # struct MyClient; + /// # + /// # async fn get_user_input( + /// # _message: String, + /// # _schema: ElicitationSchema, + /// # ) -> Result { + /// # std::future::pending().await + /// # } + /// # + /// # async fn open_url_in_browser(_url: String) -> Result<(), McpError> { + /// # Ok(()) + /// # } + /// # + /// impl ClientHandler for MyClient { + /// async fn create_elicitation( + /// &self, + /// request: ElicitRequestParams, + /// _context: RequestContext, + /// ) -> Result { + /// match request { + /// ElicitRequestParams::FormElicitationParams { + /// message, + /// requested_schema, + /// .. + /// } => { + /// let input = get_user_input(message, requested_schema).await?; + /// Ok(ElicitResult::new(ElicitationAction::Accept).with_content(input)) + /// } + /// ElicitRequestParams::UrlElicitationParams { url, .. } => { + /// open_url_in_browser(url).await?; + /// Ok(ElicitResult::new(ElicitationAction::Accept)) + /// } + /// _ => Ok(ElicitResult::new(ElicitationAction::Decline)), + /// } + /// } + /// } + /// ``` + fn create_elicitation( + &self, + request: ElicitRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + // Default implementation declines all requests - real clients should override this + let _ = (request, context); + std::future::ready(Ok(ElicitResult { + action: ElicitationAction::Decline, + content: None, + meta: None, + })) + } + + fn on_custom_request( + &self, + request: CustomRequest, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let CustomRequest { method, .. } = request; + let _ = context; + std::future::ready(Err(McpError::new( + ErrorCode::METHOD_NOT_FOUND, + method, + None, + ))) + } + + fn on_cancelled( + &self, + params: CancelledNotificationParam, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + fn on_progress( + &self, + params: ProgressNotificationParam, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + fn on_logging_message( + &self, + params: LoggingMessageNotificationParam, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + fn on_resource_updated( + &self, + params: ResourceUpdatedNotificationParam, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + fn on_resource_list_changed( + &self, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + fn on_tool_list_changed( + &self, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + fn on_prompt_list_changed( + &self, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + fn on_subscriptions_acknowledged( + &self, + params: SubscriptionsAcknowledgedNotificationParams, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + + fn on_task_status( + &self, + params: TaskStatusNotificationParams, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + fn on_custom_notification( + &self, + notification: CustomNotification, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + let _ = (notification, context); + std::future::ready(()) + } + + fn get_info(&self) -> ClientInfo { + ClientInfo::default() + } + }; +} + +#[allow(unused_variables)] +#[cfg(not(feature = "local"))] +pub trait ClientHandler: Sized + Send + Sync + 'static { + client_handler_methods!(); +} + +#[allow(unused_variables)] +#[cfg(feature = "local")] +pub trait ClientHandler: Sized + 'static { + client_handler_methods!(); +} + +/// Do nothing, with default client info. +impl ClientHandler for () {} + +/// Do nothing, with a specific client info. +impl ClientHandler for ClientInfo { + fn get_info(&self) -> ClientInfo { + self.clone() + } +} + +macro_rules! impl_client_handler_for_wrapper { + ($wrapper:ident) => { + impl ClientHandler for $wrapper { + fn ping( + &self, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).ping(context) + } + + fn create_message( + &self, + params: CreateMessageRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ + { + (**self).create_message(params, context) + } + + fn list_roots( + &self, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ + { + (**self).list_roots(context) + } + + fn create_elicitation( + &self, + request: ElicitRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).create_elicitation(request, context) + } + + fn on_custom_request( + &self, + request: CustomRequest, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).on_custom_request(request, context) + } + + fn on_cancelled( + &self, + params: CancelledNotificationParam, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + (**self).on_cancelled(params, context) + } + + fn on_progress( + &self, + params: ProgressNotificationParam, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + (**self).on_progress(params, context) + } + + fn on_logging_message( + &self, + params: LoggingMessageNotificationParam, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + (**self).on_logging_message(params, context) + } + + fn on_resource_updated( + &self, + params: ResourceUpdatedNotificationParam, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + (**self).on_resource_updated(params, context) + } + + fn on_resource_list_changed( + &self, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + (**self).on_resource_list_changed(context) + } + + fn on_tool_list_changed( + &self, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + (**self).on_tool_list_changed(context) + } + + fn on_prompt_list_changed( + &self, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + (**self).on_prompt_list_changed(context) + } + + fn on_subscriptions_acknowledged( + &self, + params: SubscriptionsAcknowledgedNotificationParams, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + (**self).on_subscriptions_acknowledged(params, context) + } + + fn on_task_status( + &self, + params: TaskStatusNotificationParams, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + (**self).on_task_status(params, context) + } + + fn on_custom_notification( + &self, + notification: CustomNotification, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + (**self).on_custom_notification(notification, context) + } + + fn get_info(&self) -> ClientInfo { + (**self).get_info() + } + } + }; +} + +impl_client_handler_for_wrapper!(Box); +impl_client_handler_for_wrapper!(Arc); diff --git a/vendor/rmcp/src/handler/client/progress.rs b/vendor/rmcp/src/handler/client/progress.rs new file mode 100644 index 000000000..04f31610f --- /dev/null +++ b/vendor/rmcp/src/handler/client/progress.rs @@ -0,0 +1,100 @@ +use std::{collections::HashMap, sync::Arc}; + +use futures::{Stream, StreamExt}; +use tokio::sync::RwLock; +use tokio_stream::wrappers::ReceiverStream; + +use crate::model::{ProgressNotificationParam, ProgressToken}; +type Dispatcher = + Arc>>>; + +/// A dispatcher for progress notifications. +#[derive(Debug, Clone, Default)] +pub struct ProgressDispatcher { + pub(crate) dispatcher: Dispatcher, +} + +impl ProgressDispatcher { + const CHANNEL_SIZE: usize = 16; + pub fn new() -> Self { + Self::default() + } + + /// Handle a progress notification by sending it to the appropriate subscriber + pub async fn handle_notification(&self, notification: ProgressNotificationParam) { + let token = ¬ification.progress_token; + if let Some(sender) = self.dispatcher.read().await.get(token).cloned() { + let send_result = sender.send(notification).await; + if let Err(e) = send_result { + tracing::warn!("Failed to send progress notification: {e}"); + } + } + } + + /// Subscribe to progress notifications for a specific token. + /// + /// If you drop the returned `ProgressSubscriber`, it will automatically unsubscribe from notifications for that token. + pub async fn subscribe(&self, progress_token: ProgressToken) -> ProgressSubscriber { + let (sender, receiver) = tokio::sync::mpsc::channel(Self::CHANNEL_SIZE); + self.dispatcher + .write() + .await + .insert(progress_token.clone(), sender); + let receiver = ReceiverStream::new(receiver); + ProgressSubscriber { + progress_token, + receiver, + dispatcher: self.dispatcher.clone(), + } + } + + /// Unsubscribe from progress notifications for a specific token. + pub async fn unsubscribe(&self, token: &ProgressToken) { + self.dispatcher.write().await.remove(token); + } + + /// Clear all dispatcher. + pub async fn clear(&self) { + let mut dispatcher = self.dispatcher.write().await; + dispatcher.clear(); + } +} + +pub struct ProgressSubscriber { + pub(crate) progress_token: ProgressToken, + pub(crate) receiver: ReceiverStream, + pub(crate) dispatcher: Dispatcher, +} + +impl ProgressSubscriber { + pub fn progress_token(&self) -> &ProgressToken { + &self.progress_token + } +} + +impl Stream for ProgressSubscriber { + type Item = ProgressNotificationParam; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.receiver.poll_next_unpin(cx) + } + + fn size_hint(&self) -> (usize, Option) { + self.receiver.size_hint() + } +} + +impl Drop for ProgressSubscriber { + fn drop(&mut self) { + let token = self.progress_token.clone(); + self.receiver.close(); + let dispatcher = self.dispatcher.clone(); + tokio::spawn(async move { + let mut dispatcher = dispatcher.write_owned().await; + dispatcher.remove(&token); + }); + } +} diff --git a/vendor/rmcp/src/handler/server.rs b/vendor/rmcp/src/handler/server.rs new file mode 100644 index 000000000..6dc7883ed --- /dev/null +++ b/vendor/rmcp/src/handler/server.rs @@ -0,0 +1,817 @@ +// Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected. +#![expect(deprecated)] +use std::{borrow::Cow, sync::Arc}; + +use crate::{ + error::ErrorData as McpError, + model::*, + service::{ + MaybeSendFuture, NotificationContext, RequestContext, RoleServer, Service, ServiceRole, + SubscriptionContext, negotiate_protocol_version, uses_legacy_lifecycle, + }, +}; + +pub mod common; +pub mod prompt; +mod resource; +pub mod router; +pub mod tool; +pub mod tool_name_validation; +pub mod wrapper; + +/// SEP-2663: gate `tasks/*` methods on the client's declared tasks-extension +/// capability. +/// +/// - If the server does not advertise the tasks extension, the methods are +/// simply unimplemented: `-32601` Method not found. +/// - If the server advertises it but the client did not declare it (either in +/// the request's `_meta` per-request capabilities or, for session-mode +/// peers, at `initialize` time), the spec requires `-32021` Missing +/// Required Client Capability with the required capability in `data`. +fn validate_tasks_capability( + handler: &H, + context: &RequestContext, +) -> Result<(), McpError> { + if !handler.get_info().capabilities.supports_tasks() { + return Err(McpError::method_not_found::()); + } + let client_declared = context + .client_capabilities() + .is_some_and(|caps| caps.supports_tasks()); + if client_declared { + Ok(()) + } else { + Err(McpError::missing_required_client_capability( + ClientCapabilities::builder().enable_tasks().build(), + )) + } +} + +impl Service for H { + async fn handle_request( + &self, + request: ::PeerReq, + context: RequestContext, + ) -> Result<::Resp, McpError> { + // `context` is moved into the dispatch below, so read the negotiated version first. + let protocol_version = context.protocol_version(); + // SEP-2322 (`resultType` discriminator, MRTR) exists from 2026-07-28. + let sep_2322_supported = protocol_version + .as_ref() + .is_some_and(|v| v.as_str() >= ProtocolVersion::V_2026_07_28.as_str()); + let requested_version = context.meta.protocol_version(); + let uses_inline_negotiation = !matches!(&request, ClientRequest::InitializeRequest(_)); + if uses_inline_negotiation && let Some(requested_version) = requested_version.as_ref() { + let supported_versions = self.supported_protocol_versions(); + if !supported_versions.contains(requested_version) { + return Err(McpError::unsupported_protocol_version( + requested_version.clone(), + &supported_versions, + )); + } + } + // Self-contained metadata is required only when the request itself uses + // the inline lifecycle: a discover opener, a session that started without + // `initialize`, or a request that declares 2026-07-28+ in its own _meta. + // Sessions that negotiated via `initialize` (or `serve_directly`) keep the + // session model and may omit per-request metadata. + let requires_request_metadata = uses_inline_negotiation + && (matches!(&request, ClientRequest::DiscoverRequest(_)) + || context.peer.request_metadata_required() + || requested_version.as_ref().is_some_and(|version| { + version.as_str() >= ProtocolVersion::V_2026_07_28.as_str() + })); + if requires_request_metadata { + // Inline lifecycle requests are defined by the 2026-07-28 protocol. + // Validate that lifecycle contract even when a request selects an + // older application protocol version. + let missing = context + .meta + .missing_required_keys(&ProtocolVersion::V_2026_07_28); + if !missing.is_empty() { + return Err(McpError::invalid_params( + format!( + "request _meta is missing or has malformed required fields: {}", + missing.join(", ") + ), + None, + )); + } + } + let legacy_request = + uses_legacy_lifecycle(protocol_version.as_ref(), requires_request_metadata); + let result = match request { + ClientRequest::InitializeRequest(request) => self + .initialize(request.params, context) + .await + .map(ServerResult::InitializeResult), + ClientRequest::DiscoverRequest(_request) => self + .discover(context) + .await + .map(ServerResult::DiscoverResult), + ClientRequest::PingRequest(_request) => { + if !legacy_request { + Err(McpError::method_not_found::()) + } else { + self.ping(context).await.map(ServerResult::empty) + } + } + ClientRequest::CompleteRequest(request) => self + .complete(request.params, context) + .await + .map(ServerResult::CompleteResult), + ClientRequest::SetLevelRequest(request) => self + .set_level(request.params, context) + .await + .map(ServerResult::empty), + ClientRequest::GetPromptRequest(request) => self + .get_prompt(request.params, context) + .await + .map(ServerResult::from), + ClientRequest::ListPromptsRequest(request) => self + .list_prompts(request.params, context) + .await + .map(ServerResult::ListPromptsResult), + ClientRequest::ListResourcesRequest(request) => self + .list_resources(request.params, context) + .await + .map(ServerResult::ListResourcesResult), + ClientRequest::ListResourceTemplatesRequest(request) => self + .list_resource_templates(request.params, context) + .await + .map(ServerResult::ListResourceTemplatesResult), + ClientRequest::ReadResourceRequest(request) => self + .read_resource(request.params, context) + .await + .map(ServerResult::from), + ClientRequest::SubscriptionsListenRequest(request) => { + if legacy_request { + Err(McpError::method_not_found::()) + } else { + let requested = request.params.notifications; + let Some(candidate) = self.accepted_subscription_filter(&requested) else { + return Err( + McpError::method_not_found::(), + ); + }; + let server_info = self.get_info(); + let advertised = requested.supported_by(&server_info.capabilities); + let handler_accepted = requested.intersection(&candidate); + let accepted = handler_accepted.intersection(&advertised); + if accepted != handler_accepted { + tracing::debug!( + requested_resource_count = requested + .resource_subscriptions + .as_ref() + .map_or(0, Vec::len), + accepted_resource_count = + accepted.resource_subscriptions.as_ref().map_or(0, Vec::len), + "subscription filter reduced to advertised server capabilities" + ); + } + let server_implementation = server_info.server_info; + let subscription_id = context.id.clone(); + let subscription = + SubscriptionContext::establish(context, requested, accepted).await?; + // The 2026-07-28 schema defines a final result for graceful + // server teardown; explicit stdio cancellation remains a notification. + self.listen(subscription).await.map(|()| { + let mut result = SubscriptionsListenResult::complete(subscription_id); + result.meta.set_server_info(server_implementation); + ServerResult::SubscriptionsListenResult(result) + }) + } + } + ClientRequest::SubscribeRequest(request) => { + if !legacy_request { + Err(McpError::method_not_found::()) + } else { + self.subscribe(request.params, context) + .await + .map(ServerResult::empty) + } + } + ClientRequest::UnsubscribeRequest(request) => { + if !legacy_request { + Err(McpError::method_not_found::()) + } else { + self.unsubscribe(request.params, context) + .await + .map(ServerResult::empty) + } + } + ClientRequest::CallToolRequest(request) => { + let client_declared_tasks = context + .client_capabilities() + .is_some_and(|caps| caps.supports_tasks()); + let response = self.call_tool(request.params, context).await?; + // SEP-2663: the server MUST NOT return CreateTaskResult unless + // the request declared the tasks extension capability. Guard + // against handlers that fail to check before materializing a + // task; such clients cannot parse a task handle. + if matches!(response, CallToolResponse::Task(_)) && !client_declared_tasks { + return Err(McpError::missing_required_client_capability( + ClientCapabilities::builder().enable_tasks().build(), + )); + } + Ok(ServerResult::from(response)) + } + ClientRequest::ListToolsRequest(request) => self + .list_tools(request.params, context) + .await + .map(ServerResult::ListToolsResult), + ClientRequest::CustomRequest(request) => self + .on_custom_request(request, context) + .await + .map(ServerResult::CustomResult), + ClientRequest::GetTaskRequest(request) => { + validate_tasks_capability::(self, &context)?; + self.get_task(request.params, context) + .await + .map(ServerResult::GetTaskResult) + } + ClientRequest::UpdateTaskRequest(request) => { + validate_tasks_capability::(self, &context)?; + self.update_task(request.params, context) + .await + .map(ServerResult::task_ack) + } + ClientRequest::CancelTaskRequest(request) => { + validate_tasks_capability::(self, &context)?; + self.cancel_task(request.params, context) + .await + .map(ServerResult::task_ack) + } + }; + let result = result.and_then(|mut result| { + if matches!(result, ServerResult::InputRequiredResult(_)) && !sep_2322_supported { + Err(McpError::invalid_request( + "InputRequiredResult requires negotiated protocol version 2026-07-28 or newer", + None, + )) + } else { + // Peers on protocol versions older than 2026-07-28 keep the + // legacy wire shape without `resultType: "complete"`. + if !sep_2322_supported { + result.strip_result_type_for_legacy_peer(); + } + Ok(result) + } + }); + + // SEP-2164: peers negotiating 2026-07-28+ get the standard INVALID_PARAMS code for + // resource-not-found; older peers keep RESOURCE_NOT_FOUND. ISO `YYYY-MM-DD` versions + // compare lexically the same as chronologically. + let use_invalid_params = + protocol_version.is_some_and(|v| v.as_str() >= ProtocolVersion::V_2026_07_28.as_str()); + result.map_err(|mut error| { + if use_invalid_params && error.code == ErrorCode::RESOURCE_NOT_FOUND { + error.code = ErrorCode::INVALID_PARAMS; + } + error + }) + } + + async fn handle_notification( + &self, + notification: ::PeerNot, + context: NotificationContext, + ) -> Result<(), McpError> { + match notification { + ClientNotification::CancelledNotification(notification) => { + self.on_cancelled(notification.params, context).await + } + ClientNotification::ProgressNotification(notification) => { + self.on_progress(notification.params, context).await + } + ClientNotification::InitializedNotification(_notification) => { + self.on_initialized(context).await + } + ClientNotification::RootsListChangedNotification(_notification) => { + self.on_roots_list_changed(context).await + } + ClientNotification::CustomNotification(notification) => { + self.on_custom_notification(notification, context).await + } + }; + Ok(()) + } + + fn get_info(&self) -> ::Info { + self.get_info() + } + + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + ServerHandler::supported_protocol_versions(self) + } +} + +macro_rules! server_handler_methods { + () => { + fn ping( + &self, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Ok(())) + } + // handle requests + fn initialize( + &self, + request: InitializeRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + context.peer.set_peer_info(request.clone()); + let mut info = self.get_info(); + info.protocol_version = negotiate_protocol_version( + &request.protocol_version, + info.protocol_version, + &self.supported_protocol_versions(), + ); + std::future::ready(Ok(info)) + } + /// Return the protocol versions supported by this server. + /// + /// Defaults to every version this SDK knows. Override it to narrow the + /// set to the revisions the server actually implements: the returned + /// list is advertised by [`Self::discover`], bounds what `initialize` + /// negotiation may agree to, and is what per-request versions are + /// validated against. + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS) + } + /// Return this server's discovery information. + fn discover( + &self, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Ok(DiscoverResult::from_server_info( + self.supported_protocol_versions().into_owned(), + self.get_info(), + ))) + } + fn complete( + &self, + request: CompleteRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Ok(CompleteResult::default())) + } + fn set_level( + &self, + request: SetLevelRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Err(McpError::method_not_found::())) + } + fn get_prompt( + &self, + request: GetPromptRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Err(McpError::method_not_found::())) + } + fn list_prompts( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Ok(ListPromptsResult::default())) + } + fn list_resources( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Ok(ListResourcesResult::default())) + } + fn list_resource_templates( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + + MaybeSendFuture + + '_ { + std::future::ready(Ok(ListResourceTemplatesResult::default())) + } + fn read_resource( + &self, + request: ReadResourceRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Err( + McpError::method_not_found::(), + )) + } + /// Return the subset of a requested notification filter this server accepts. + /// + /// Returning `None` leaves `subscriptions/listen` unimplemented. The SDK + /// intersects the returned filter with both `requested` and the notification + /// capabilities advertised by [`Self::get_info`] before acknowledging it. + /// Categories that were not requested or advertised are always removed. + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + None + } + /// Run one established subscription until it is cancelled or closed gracefully. + /// + /// The SDK sends the acknowledgment before invoking this method. Returning + /// `Ok(())` sends the final [`SubscriptionsListenResult`] defined by the + /// 2026-07-28 schema, marking graceful server teardown. Explicit + /// stdio cancellation uses `notifications/cancelled` instead. + fn listen( + &self, + context: SubscriptionContext, + ) -> impl Future> + MaybeSendFuture + '_ { + async move { + context.cancelled().await; + Ok(()) + } + } + #[deprecated( + note = "resources/subscribe is legacy-only; implement accepted_subscription_filter and listen for protocol version 2026-07-28" + )] + fn subscribe( + &self, + request: SubscribeRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Err(McpError::method_not_found::())) + } + #[deprecated( + note = "resources/unsubscribe is legacy-only; subscriptions/listen is cancelled through its request lifecycle" + )] + fn unsubscribe( + &self, + request: UnsubscribeRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Err( + McpError::method_not_found::(), + )) + } + /// Handle a `tools/call` request from a client. + /// + /// # Choosing a return value + /// + /// MCP distinguishes two failure modes; the API forces you to pick + /// the right one explicitly because they reach the caller's UI very + /// differently: + /// + /// - `Ok(`[`CallToolResult::error`]`(...))` — the tool ran (or tried + /// to) and produced a failure the caller should see. The + /// `content` you supply is rendered in the caller's MCP client, + /// so the user gets your message. **This is the right return + /// value for almost every "the tool didn't work" path** — empty + /// results, validation failures the user can fix, downstream + /// service unavailability, etc. + /// + /// - `Err(`[`McpError`]`)` — a JSON-RPC protocol error. Use this + /// only when the request itself is unroutable: unknown tool + /// ([`ErrorCode::METHOD_NOT_FOUND`]), malformed request shape that + /// cannot be treated as a valid `tools/call`, or a server-internal + /// failure that means the server cannot serve any request right now + /// ([`ErrorCode::INTERNAL_ERROR`], `-32603`). MCP clients + /// typically render protocol errors opaquely; **the caller will + /// not see your message** — they see something like "Tool result + /// missing due to internal error". If you want the caller to read + /// your error, use `Ok(CallToolResult::error(...))`. + /// + /// See [`CallToolResult::error`] for a worked example. + fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Err(McpError::method_not_found::())) + } + fn list_tools( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Ok(ListToolsResult::default())) + } + /// Get a tool definition by name. + /// + /// The default implementation returns `None`, which bypasses validation. + /// When using `#[tool_handler]`, this method is automatically implemented. + fn get_tool(&self, _name: &str) -> Option { + None + } + fn on_custom_request( + &self, + request: CustomRequest, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let CustomRequest { method, .. } = request; + let _ = context; + std::future::ready(Err(McpError::new( + ErrorCode::METHOD_NOT_FOUND, + method, + None, + ))) + } + + fn on_cancelled( + &self, + notification: CancelledNotificationParam, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + fn on_progress( + &self, + notification: ProgressNotificationParam, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + fn on_initialized( + &self, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + tracing::info!("client initialized"); + std::future::ready(()) + } + fn on_roots_list_changed( + &self, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + fn on_custom_notification( + &self, + notification: CustomNotification, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + let _ = (notification, context); + std::future::ready(()) + } + + fn get_info(&self) -> ServerInfo { + ServerInfo::default() + } + + /// SEP-2663 `tasks/get`: return the current [`DetailedTask`] state. + fn get_task( + &self, + request: GetTaskParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let _ = (request, context); + std::future::ready(Err(McpError::method_not_found::())) + } + + /// SEP-2663 `tasks/update`: accept responses to outstanding in-task + /// input requests. Returns an empty acknowledgement on success. + fn update_task( + &self, + request: UpdateTaskParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let _ = (request, context); + std::future::ready(Err(McpError::method_not_found::())) + } + + /// SEP-2663 `tasks/cancel`: cooperative cancellation. Returns an empty + /// acknowledgement; the task's observable status may lag. + fn cancel_task( + &self, + request: CancelTaskParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let _ = (request, context); + std::future::ready(Err(McpError::method_not_found::())) + } + }; +} + +#[allow(unused_variables)] +#[cfg(not(feature = "local"))] +pub trait ServerHandler: Sized + Send + Sync + 'static { + server_handler_methods!(); +} + +#[allow(unused_variables)] +#[cfg(feature = "local")] +pub trait ServerHandler: Sized + 'static { + server_handler_methods!(); +} + +macro_rules! impl_server_handler_for_wrapper { + ($wrapper:ident) => { + impl ServerHandler for $wrapper { + fn ping( + &self, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).ping(context) + } + + fn initialize( + &self, + request: InitializeRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).initialize(request, context) + } + + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + (**self).supported_protocol_versions() + } + + fn discover( + &self, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).discover(context) + } + + fn complete( + &self, + request: CompleteRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).complete(request, context) + } + + fn set_level( + &self, + request: SetLevelRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).set_level(request, context) + } + + fn get_prompt( + &self, + request: GetPromptRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).get_prompt(request, context) + } + + fn list_prompts( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).list_prompts(request, context) + } + + fn list_resources( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).list_resources(request, context) + } + + fn list_resource_templates( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ + { + (**self).list_resource_templates(request, context) + } + + fn read_resource( + &self, + request: ReadResourceRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).read_resource(request, context) + } + + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + (**self).accepted_subscription_filter(requested) + } + + fn listen( + &self, + context: SubscriptionContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).listen(context) + } + + fn subscribe( + &self, + request: SubscribeRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).subscribe(request, context) + } + + fn unsubscribe( + &self, + request: UnsubscribeRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).unsubscribe(request, context) + } + + fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).call_tool(request, context) + } + + fn list_tools( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).list_tools(request, context) + } + + fn get_tool(&self, name: &str) -> Option { + (**self).get_tool(name) + } + + fn on_custom_request( + &self, + request: CustomRequest, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).on_custom_request(request, context) + } + + fn on_cancelled( + &self, + notification: CancelledNotificationParam, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + (**self).on_cancelled(notification, context) + } + + fn on_progress( + &self, + notification: ProgressNotificationParam, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + (**self).on_progress(notification, context) + } + + fn on_initialized( + &self, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + (**self).on_initialized(context) + } + + fn on_roots_list_changed( + &self, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + (**self).on_roots_list_changed(context) + } + + fn on_custom_notification( + &self, + notification: CustomNotification, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + (**self).on_custom_notification(notification, context) + } + + fn get_info(&self) -> ServerInfo { + (**self).get_info() + } + + fn get_task( + &self, + request: GetTaskParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).get_task(request, context) + } + + fn update_task( + &self, + request: UpdateTaskParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).update_task(request, context) + } + + fn cancel_task( + &self, + request: CancelTaskParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).cancel_task(request, context) + } + } + }; +} + +impl_server_handler_for_wrapper!(Box); +impl_server_handler_for_wrapper!(Arc); diff --git a/vendor/rmcp/src/handler/server/common.rs b/vendor/rmcp/src/handler/server/common.rs new file mode 100644 index 000000000..8d3e4a8a1 --- /dev/null +++ b/vendor/rmcp/src/handler/server/common.rs @@ -0,0 +1,404 @@ +//! Common utilities shared between tool and prompt handlers + +use std::{ + any::TypeId, + collections::HashMap, + sync::{Arc, LazyLock}, +}; + +use schemars::JsonSchema; + +use crate::{ + RoleServer, model::JsonObject, schemars::generate::SchemaSettings, service::RequestContext, +}; + +/// Generates a JSON schema for a type +pub fn schema_for_type() -> Arc { + thread_local! { + static CACHE_FOR_TYPE: std::sync::RwLock>> = Default::default(); + }; + CACHE_FOR_TYPE.with(|cache| { + if let Some(x) = cache + .read() + .expect("schema cache lock poisoned") + .get(&TypeId::of::()) + { + x.clone() + } else { + // explicitly to align json schema version to official specifications. + // refer to https://github.com/modelcontextprotocol/modelcontextprotocol/pull/655 for details. + let settings = SchemaSettings::draft2020_12(); + // Note: AddNullable is intentionally NOT used here because the `nullable` keyword + // is an OpenAPI 3.0 extension, not part of JSON Schema 2020-12. Using it would + // cause validation failures with strict JSON Schema validators. + let generator = settings.into_generator(); + let schema = generator.into_root_schema_for::(); + let object = serde_json::to_value(schema).expect("failed to serialize schema"); + let serde_json::Value::Object(object) = object else { + panic!( + "Schema serialization produced non-object value: expected JSON object but got {object:?}" + ); + }; + let schema = Arc::new(object); + cache + .write() + .expect("schema cache lock poisoned") + .insert(TypeId::of::(), schema.clone()); + + schema + } + }) +} + +/// Validate that the schema root is `type: "object"` (per MCP spec) and strip top-level +/// `title`/`description` (the wrapper type name and doc, which are noise to the LLM). +fn validate_and_strip(raw: &Arc, purpose: &str) -> Result, String> { + match raw.get("type") { + Some(serde_json::Value::String(t)) if t == "object" => { + let mut object = raw.as_ref().clone(); + object.remove("title"); + object.remove("description"); + Ok(Arc::new(object)) + } + Some(serde_json::Value::String(t)) => Err(format!( + "MCP specification requires tool {purpose} to have root type 'object', but found '{t}'." + )), + None => Err(format!( + "Schema is missing 'type' field. MCP specification requires {purpose} to have root type 'object'." + )), + Some(other) => Err(format!( + "Schema 'type' field has unexpected format: {other:?}. Expected \"object\"." + )), + } +} + +/// Generate, validate, and strip a JSON schema for inputSchema (must have root type "object"; +/// top-level "title" and "description" are removed). +pub fn schema_for_input() -> Result, String> { + thread_local! { + static CACHE_FOR_INPUT: std::sync::RwLock, String>>> = Default::default(); + }; + CACHE_FOR_INPUT.with(|cache| { + if let Some(result) = cache + .read() + .expect("input schema cache lock poisoned") + .get(&TypeId::of::()) + { + return result.clone(); + } + let result = validate_and_strip(&schema_for_type::(), "inputSchema"); + cache + .write() + .expect("input schema cache lock poisoned") + .insert(TypeId::of::(), result.clone()); + result + }) +} + +/// Schema used when input is empty. +pub fn schema_for_empty_input() -> Arc { + static EMPTY: LazyLock> = LazyLock::new(|| { + let mut object = JsonObject::new(); + object.insert("type".into(), serde_json::json!("object")); + object.insert("properties".into(), serde_json::json!({})); + Arc::new(object) + }); + EMPTY.clone() +} + +/// Strip top-level `title` and `description` from a JSON schema for outputSchema. +/// Unlike `validate_and_strip`, this performs no validation — output schemas are not +/// restricted to `type: "object"` (per SEP-2106). +fn strip_output(raw: &Arc) -> Arc { + let mut object = raw.as_ref().clone(); + object.remove("title"); + object.remove("description"); + Arc::new(object) +} + +/// Generate and strip a JSON schema for outputSchema (top-level "title" and +/// "description" are removed; output schemas are not restricted to root type "object"). +pub fn schema_for_output() -> Arc { + thread_local! { + static CACHE_FOR_OUTPUT: std::sync::RwLock>> = Default::default(); + }; + + CACHE_FOR_OUTPUT.with(|cache| { + if let Some(schema) = cache + .read() + .expect("output schema cache lock poisoned") + .get(&TypeId::of::()) + { + return schema.clone(); + } + + let schema = strip_output(&schema_for_type::()); + + cache + .write() + .expect("output schema cache lock poisoned") + .insert(TypeId::of::(), schema.clone()); + + schema + }) +} + +/// Trait for extracting parts from a context, unifying tool and prompt extraction +pub trait FromContextPart: Sized { + fn from_context_part(context: &mut C) -> Result; +} + +/// Common extractors that can be used by both tool and prompt handlers +impl FromContextPart for RequestContext +where + C: AsRequestContext, +{ + fn from_context_part(context: &mut C) -> Result { + Ok(context.as_request_context().clone()) + } +} + +impl FromContextPart for tokio_util::sync::CancellationToken +where + C: AsRequestContext, +{ + fn from_context_part(context: &mut C) -> Result { + Ok(context.as_request_context().ct.clone()) + } +} + +impl FromContextPart for crate::model::Extensions +where + C: AsRequestContext, +{ + fn from_context_part(context: &mut C) -> Result { + Ok(context.as_request_context().extensions.clone()) + } +} + +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct Extension(pub T); + +impl FromContextPart for Extension +where + C: AsRequestContext, + T: Send + Sync + 'static + Clone, +{ + fn from_context_part(context: &mut C) -> Result { + let extension = context + .as_request_context() + .extensions + .get::() + .cloned() + .ok_or_else(|| { + crate::ErrorData::invalid_params( + format!("missing extension {}", std::any::type_name::()), + None, + ) + })?; + Ok(Extension(extension)) + } +} + +impl FromContextPart for crate::Peer +where + C: AsRequestContext, +{ + fn from_context_part(context: &mut C) -> Result { + Ok(context.as_request_context().peer.clone()) + } +} + +impl FromContextPart for crate::model::RequestMetaObject +where + C: AsRequestContext, +{ + fn from_context_part(context: &mut C) -> Result { + let request_context = context.as_request_context_mut(); + let mut meta = crate::model::RequestMetaObject::default(); + std::mem::swap(&mut meta, &mut request_context.meta); + Ok(meta) + } +} + +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct RequestId(pub crate::model::RequestId); + +impl FromContextPart for RequestId +where + C: AsRequestContext, +{ + fn from_context_part(context: &mut C) -> Result { + Ok(RequestId(context.as_request_context().id.clone())) + } +} + +/// Trait for types that can provide access to RequestContext +pub trait AsRequestContext { + fn as_request_context(&self) -> &RequestContext; + fn as_request_context_mut(&mut self) -> &mut RequestContext; +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + + #[derive(serde::Serialize, serde::Deserialize, JsonSchema)] + struct TestObject { + value: i32, + } + + #[derive(serde::Serialize, serde::Deserialize, JsonSchema)] + struct AnotherTestObject { + value: i32, + } + + #[rstest] + #[case::primitive(schema_for_type::, "integer")] + #[case::array(schema_for_type::>, "array")] + #[case::struct_object(schema_for_type::, "object")] + fn schema_for_type_sets_expected_root_type( + #[case] schema_fn: fn() -> Arc, + #[case] expected_type: &str, + ) { + let schema = schema_fn(); + + assert_eq!(schema.get("type"), Some(&serde_json::json!(expected_type))); + } + + #[test] + fn schema_for_type_sets_array_item_type() { + let schema = schema_for_type::>(); + let items = schema.get("items").and_then(|v| v.as_object()).unwrap(); + + assert_eq!(items.get("type"), Some(&serde_json::json!("integer"))); + } + + #[test] + fn schema_for_type_sets_struct_properties() { + let schema = schema_for_type::(); + let properties = schema + .get("properties") + .and_then(|v| v.as_object()) + .unwrap(); + + assert!(properties.contains_key("value")); + } + + #[rstest] + #[case::primitive(schema_for_type::)] + #[case::struct_object(schema_for_type::)] + fn test_schema_for_type_caches_schemas(#[case] schema_fn: fn() -> Arc) { + let schema1 = schema_fn(); + let schema2 = schema_fn(); + + assert!(Arc::ptr_eq(&schema1, &schema2)); + } + + #[test] + fn test_schema_for_type_different_types_different_schemas() { + let schema1 = schema_for_type::(); + let schema2 = schema_for_type::(); + + assert!(!Arc::ptr_eq(&schema1, &schema2)); + } + + #[test] + fn test_schema_for_type_arc_can_be_shared() { + let schema = schema_for_type::(); + let cloned = schema.clone(); + + assert!(Arc::ptr_eq(&schema, &cloned)); + } + + #[test] + fn test_schema_for_output_accepts_primitive() { + let schema = schema_for_output::(); + assert_eq!(schema.get("type"), Some(&serde_json::json!("integer"))); + } + + #[test] + fn test_schema_for_output_strips_description_for_primitive() { + let schema = schema_for_output::(); + assert!(!schema.contains_key("description")); + } + + #[test] + fn test_schema_for_output_accepts_composition() { + let schema = schema_for_output::>(); + let schema_str = serde_json::to_string(&schema).unwrap(); + assert!( + schema_str.contains("anyOf") + || schema_str.contains("oneOf") + || schema_str.contains("null"), + "Expected composition schema for Option, got: {schema_str}" + ); + } + + #[test] + fn test_schema_for_output_caches_result() { + let schema1 = schema_for_output::(); + let schema2 = schema_for_output::(); + assert!(Arc::ptr_eq(&schema1, &schema2)); + } + + #[test] + fn test_schema_for_input_rejects_array() { + let result = schema_for_input::>(); + assert!(result.is_err()); + } + + #[test] + fn test_schema_for_output_accepts_unit() { + let _schema = schema_for_output::<()>(); + } + + #[test] + fn test_schema_for_output_accepts_object() { + let schema = schema_for_output::(); + assert_eq!(schema.get("type"), Some(&serde_json::json!("object"))); + } + + #[test] + fn test_schema_for_output_strips_top_level_title() { + let schema = schema_for_output::(); + assert!(!schema.contains_key("title")); + } + + #[test] + fn test_schema_for_output_strips_top_level_description() { + let schema = schema_for_output::(); + assert!(!schema.contains_key("description")); + } + + #[rstest] + #[case::input(schema_for_input::)] + fn test_schema_for_input_rejects_primitives( + #[case] schema_fn: fn() -> Result, String>, + ) { + let result = schema_fn(); + assert!(result.is_err()); + } + + #[rstest] + #[case::input(schema_for_input::)] + fn test_schema_for_input_accepts_objects( + #[case] schema_fn: fn() -> Result, String>, + ) { + let result = schema_fn(); + assert!(result.is_ok()); + } + + #[rstest] + #[case::input_title(schema_for_input::, "title")] + #[case::input_description(schema_for_input::, "description")] + fn test_schema_for_input_strips_top_level_metadata( + #[case] schema_fn: fn() -> Result, String>, + #[case] field: &str, + ) { + let schema = schema_fn().unwrap(); + assert!(!schema.contains_key(field)); + } +} diff --git a/vendor/rmcp/src/handler/server/prompt.rs b/vendor/rmcp/src/handler/server/prompt.rs new file mode 100644 index 000000000..b5b3c469b --- /dev/null +++ b/vendor/rmcp/src/handler/server/prompt.rs @@ -0,0 +1,383 @@ +//! Prompt handling infrastructure for MCP servers +//! +//! This module provides the core types and traits for implementing prompt handlers +//! in MCP servers. Prompts allow servers to provide reusable templates for LLM +//! interactions with customizable arguments. + +use std::{future::Future, marker::PhantomData}; + +#[cfg(not(feature = "local"))] +use futures::future::BoxFuture; +use serde::de::DeserializeOwned; + +use super::common::{AsRequestContext, FromContextPart}; +pub use super::common::{Extension, RequestId}; +use crate::{ + RoleServer, + handler::server::wrapper::Parameters, + model::{GetPromptResponse, GetPromptResult, InputRequiredResult, PromptMessage}, + service::{MaybeBoxFuture, MaybeSend, MaybeSendFuture, RequestContext}, +}; + +/// Context for prompt retrieval operations +#[non_exhaustive] +pub struct PromptContext<'a, S> { + pub server: &'a S, + pub name: String, + pub arguments: Option>, + pub context: RequestContext, +} + +impl<'a, S> PromptContext<'a, S> { + pub fn new( + server: &'a S, + name: String, + arguments: Option>, + context: RequestContext, + ) -> Self { + Self { + server, + name, + arguments, + context, + } + } +} + +impl AsRequestContext for PromptContext<'_, S> { + fn as_request_context(&self) -> &RequestContext { + &self.context + } + + fn as_request_context_mut(&mut self) -> &mut RequestContext { + &mut self.context + } +} + +/// Trait for handling prompt retrieval +pub trait GetPromptHandler { + fn handle( + self, + context: PromptContext<'_, S>, + ) -> MaybeBoxFuture<'_, Result>; +} + +/// Type alias for dynamic prompt handlers +#[cfg(not(feature = "local"))] +pub type DynGetPromptHandler = dyn for<'a> Fn(PromptContext<'a, S>) -> BoxFuture<'a, Result> + + Send + + Sync; + +#[cfg(feature = "local")] +pub type DynGetPromptHandler = dyn for<'a> Fn( + PromptContext<'a, S>, +) -> futures::future::LocalBoxFuture< + 'a, + Result, +>; + +/// Adapter type for async methods that return `Vec` +pub struct AsyncMethodAdapter(PhantomData); + +/// Adapter type for async methods with parameters that return `Vec` +pub struct AsyncMethodWithArgsAdapter(PhantomData); + +/// Adapter types for macro-generated implementations +#[allow(clippy::type_complexity)] +pub struct AsyncPromptAdapter(PhantomData fn(Fut) -> R>); +pub struct SyncPromptAdapter(PhantomData R>); +pub struct AsyncPromptMethodAdapter(PhantomData R>); +pub struct SyncPromptMethodAdapter(PhantomData R>); + +/// Trait for types that can be converted into GetPromptResult +pub trait IntoGetPromptResult { + fn into_get_prompt_result(self) -> Result; +} + +impl IntoGetPromptResult for GetPromptResult { + fn into_get_prompt_result(self) -> Result { + Ok(self.into()) + } +} + +impl IntoGetPromptResult for InputRequiredResult { + fn into_get_prompt_result(self) -> Result { + Ok(self.into()) + } +} + +impl IntoGetPromptResult for Vec { + fn into_get_prompt_result(self) -> Result { + Ok(GetPromptResult::new(self).into()) + } +} + +impl IntoGetPromptResult for Result { + fn into_get_prompt_result(self) -> Result { + self.and_then(|v| v.into_get_prompt_result()) + } +} + +// Future wrapper that automatically handles IntoGetPromptResult conversion +pin_project_lite::pin_project! { + #[project = IntoGetPromptResultFutProj] + #[non_exhaustive] + pub enum IntoGetPromptResultFut { + Pending { + #[pin] + fut: F, + _marker: PhantomData, + }, + Ready { + #[pin] + result: futures::future::Ready>, + } + } +} + +impl Future for IntoGetPromptResultFut +where + F: Future, + R: IntoGetPromptResult, +{ + type Output = Result; + + fn poll( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + match self.project() { + IntoGetPromptResultFutProj::Pending { fut, _marker } => fut + .poll(cx) + .map(IntoGetPromptResult::into_get_prompt_result), + IntoGetPromptResultFutProj::Ready { result } => result.poll(cx), + } + } +} + +// Prompt-specific extractor for prompt name +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct PromptName(pub String); + +impl FromContextPart> for PromptName { + fn from_context_part(context: &mut PromptContext) -> Result { + Ok(Self(context.name.clone())) + } +} + +// Special implementation for Parameters that handles prompt arguments +impl FromContextPart> for Parameters

+where + P: DeserializeOwned, +{ + fn from_context_part(context: &mut PromptContext) -> Result { + let params = if let Some(args_map) = context.arguments.take() { + let args_value = serde_json::Value::Object(args_map); + serde_json::from_value::

(args_value).map_err(|e| { + crate::ErrorData::invalid_params(format!("Failed to parse parameters: {}", e), None) + })? + } else { + // Try to deserialize from empty object for optional fields + serde_json::from_value::

(serde_json::json!({})).map_err(|e| { + crate::ErrorData::invalid_params( + format!("Missing required parameters: {}", e), + None, + ) + })? + }; + Ok(Parameters(params)) + } +} + +// Macro to generate GetPromptHandler implementations for various parameter combinations +macro_rules! impl_prompt_handler_for { + ($($T: ident)*) => { + impl_prompt_handler_for!([] [$($T)*]); + }; + // finished + ([$($Tn: ident)*] []) => { + impl_prompt_handler_for!(@impl $($Tn)*); + }; + ([$($Tn: ident)*] [$Tn_1: ident $($Rest: ident)*]) => { + impl_prompt_handler_for!(@impl $($Tn)*); + impl_prompt_handler_for!([$($Tn)* $Tn_1] [$($Rest)*]); + }; + (@impl $($Tn: ident)*) => { + // Implementation for async methods (transformed by #[prompt] macro) + impl<$($Tn,)* S, F, R> GetPromptHandler for F + where + $( + $Tn: for<'a> FromContextPart> + MaybeSendFuture, + )* + F: FnOnce(&S, $($Tn,)*) -> MaybeBoxFuture<'_, R> + MaybeSendFuture, + R: IntoGetPromptResult + MaybeSendFuture + 'static, + S: MaybeSend + 'static, + { + #[allow(unused_variables, non_snake_case, unused_mut)] + fn handle( + self, + mut context: PromptContext<'_, S>, + ) -> MaybeBoxFuture<'_, Result> + { + $( + let result = $Tn::from_context_part(&mut context); + let $Tn = match result { + Ok(value) => value, + Err(e) => return Box::pin(std::future::ready(Err(e))), + }; + )* + let service = context.server; + let fut = self(service, $($Tn,)*); + Box::pin(async move { + let result = fut.await; + result.into_get_prompt_result() + }) + } + } + + + // Implementation for sync methods + impl<$($Tn,)* S, F, R> GetPromptHandler> for F + where + $( + $Tn: for<'a> FromContextPart> + MaybeSendFuture, + )* + F: FnOnce(&S, $($Tn,)*) -> R + MaybeSendFuture, + R: IntoGetPromptResult + MaybeSendFuture, + S: MaybeSend, + { + #[allow(unused_variables, non_snake_case, unused_mut)] + fn handle( + self, + mut context: PromptContext<'_, S>, + ) -> MaybeBoxFuture<'_, Result> + { + $( + let result = $Tn::from_context_part(&mut context); + let $Tn = match result { + Ok(value) => value, + Err(e) => return Box::pin(std::future::ready(Err(e))), + }; + )* + let service = context.server; + let result = self(service, $($Tn,)*); + Box::pin(std::future::ready(result.into_get_prompt_result())) + } + } + + + // AsyncPromptAdapter - for standalone functions returning GetPromptResult + impl<$($Tn,)* S, F, Fut, R> GetPromptHandler> for F + where + $( + $Tn: for<'a> FromContextPart> + MaybeSendFuture + 'static, + )* + F: FnOnce($($Tn,)*) -> Fut + MaybeSendFuture + 'static, + Fut: Future> + MaybeSendFuture + 'static, + R: IntoGetPromptResult + MaybeSendFuture + 'static, + S: MaybeSend + 'static, + { + #[allow(unused_variables, non_snake_case, unused_mut)] + fn handle( + self, + mut context: PromptContext<'_, S>, + ) -> MaybeBoxFuture<'_, Result> + { + // Extract all parameters before moving into the async block + $( + let result = $Tn::from_context_part(&mut context); + let $Tn = match result { + Ok(value) => value, + Err(e) => return Box::pin(std::future::ready(Err(e))), + }; + )* + + // Since we're dealing with standalone functions that don't take &S, + // we can return a 'static future + Box::pin(async move { + let result = self($($Tn,)*).await?; + result.into_get_prompt_result() + }) + } + } + + + // SyncPromptAdapter - for standalone sync functions returning Result + impl<$($Tn,)* S, F, R> GetPromptHandler> for F + where + $( + $Tn: for<'a> FromContextPart> + MaybeSendFuture + 'static, + )* + F: FnOnce($($Tn,)*) -> Result + MaybeSendFuture + 'static, + R: IntoGetPromptResult + MaybeSendFuture + 'static, + S: MaybeSend, + { + #[allow(unused_variables, non_snake_case, unused_mut)] + fn handle( + self, + mut context: PromptContext<'_, S>, + ) -> MaybeBoxFuture<'_, Result> + { + $( + let result = $Tn::from_context_part(&mut context); + let $Tn = match result { + Ok(value) => value, + Err(e) => return Box::pin(std::future::ready(Err(e))), + }; + )* + let result = self($($Tn,)*); + Box::pin(std::future::ready(result.and_then(|r| r.into_get_prompt_result()))) + } + } + + }; +} + +// Invoke the macro to generate implementations for up to 16 parameters +impl_prompt_handler_for!(T0 T1 T2 T3 T4 T5 T6 T7 T8 T9 T10 T11 T12 T13 T14 T15); + +/// Extract prompt arguments from a type's JSON schema +/// This function analyzes the schema of a type and extracts the properties +/// as PromptArgument entries with name, description, and required status +pub fn cached_arguments_from_schema() +-> Option> { + let schema = super::common::schema_for_type::(); + let schema_value = serde_json::Value::Object((*schema).clone()); + + let properties = schema_value.get("properties").and_then(|p| p.as_object()); + + if let Some(props) = properties { + let required = schema_value + .get("required") + .and_then(|r| r.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .collect::>() + }) + .unwrap_or_default(); + + let mut arguments = Vec::new(); + for (name, prop_schema) in props { + let description = prop_schema + .get("description") + .and_then(|d| d.as_str()) + .map(|s| s.to_string()); + + arguments.push(crate::model::PromptArgument { + name: name.clone(), + title: None, + description, + required: Some(required.contains(name.as_str())), + }); + } + + if arguments.is_empty() { + None + } else { + Some(arguments) + } + } else { + None + } +} diff --git a/vendor/rmcp/src/handler/server/resource.rs b/vendor/rmcp/src/handler/server/resource.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/vendor/rmcp/src/handler/server/resource.rs @@ -0,0 +1 @@ + diff --git a/vendor/rmcp/src/handler/server/router.rs b/vendor/rmcp/src/handler/server/router.rs new file mode 100644 index 000000000..c49c40894 --- /dev/null +++ b/vendor/rmcp/src/handler/server/router.rs @@ -0,0 +1,235 @@ +use std::{borrow::Cow, sync::Arc}; + +use prompt::{IntoPromptRoute, PromptRoute}; +use tool::{IntoToolRoute, ToolRoute}; + +use super::ServerHandler; +use crate::{ + RoleServer, Service, + model::{ + ClientNotification, ClientRequest, ListPromptsResult, ListToolsResult, ProtocolVersion, + ServerResult, + }, + service::NotificationContext, +}; + +pub mod prompt; +pub mod tool; + +#[non_exhaustive] +pub struct Router { + pub tool_router: tool::ToolRouter, + pub prompt_router: prompt::PromptRouter, + pub service: Arc, + peer_slot: Arc>>, +} + +impl Router +where + S: ServerHandler, +{ + pub fn new(service: S) -> Self { + let (notifier, peer_slot) = tool::ToolRouter::::deferred_peer_notifier(); + let mut tool_router = tool::ToolRouter::new(); + tool_router.set_notifier(notifier); + Self { + tool_router, + prompt_router: prompt::PromptRouter::new(), + service: Arc::new(service), + peer_slot, + } + } + + pub fn with_tool(mut self, route: R) -> Self + where + R: IntoToolRoute, + { + self.tool_router.add_route(route.into_tool_route()); + self + } + + pub fn with_tools(mut self, routes: impl IntoIterator>) -> Self { + for route in routes { + self.tool_router.add_route(route); + } + self + } + + pub fn with_prompt(mut self, route: R) -> Self + where + R: IntoPromptRoute, + { + self.prompt_router.add_route(route.into_prompt_route()); + self + } + + pub fn with_prompts(mut self, routes: impl IntoIterator>) -> Self { + for route in routes { + self.prompt_router.add_route(route); + } + self + } +} + +impl Service for Router +where + S: ServerHandler, +{ + async fn handle_notification( + &self, + notification: ::PeerNot, + context: NotificationContext, + ) -> Result<(), crate::ErrorData> { + if matches!( + ¬ification, + ClientNotification::InitializedNotification(_) + ) { + let _ = self.peer_slot.set(context.peer.clone()); + } + self.service + .handle_notification(notification, context) + .await + } + async fn handle_request( + &self, + request: ::PeerReq, + context: crate::service::RequestContext, + ) -> Result<::Resp, crate::ErrorData> { + match request { + ClientRequest::CallToolRequest(request) => { + if self + .tool_router + .map + .contains_key(request.params.name.as_ref()) + || !self.tool_router.transparent_when_not_found + { + let tool_call_context = crate::handler::server::tool::ToolCallContext::new( + self.service.as_ref(), + request.params, + context, + ); + let result = self.tool_router.call(tool_call_context).await?; + Ok(ServerResult::from(result)) + } else { + self.service + .handle_request(ClientRequest::CallToolRequest(request), context) + .await + } + } + ClientRequest::ListToolsRequest(_) => { + let tools = self.tool_router.list_all(); + Ok(ServerResult::ListToolsResult(ListToolsResult { + tools, + ..Default::default() + })) + } + ClientRequest::GetPromptRequest(request) => { + if self.prompt_router.has_route(request.params.name.as_ref()) { + let prompt_context = crate::handler::server::prompt::PromptContext::new( + self.service.as_ref(), + request.params.name, + request.params.arguments, + context, + ); + let result = self.prompt_router.get_prompt(prompt_context).await?; + Ok(ServerResult::from(result)) + } else { + self.service + .handle_request(ClientRequest::GetPromptRequest(request), context) + .await + } + } + ClientRequest::ListPromptsRequest(_) => { + let prompts = self.prompt_router.list_all(); + Ok(ServerResult::ListPromptsResult(ListPromptsResult { + prompts, + ..Default::default() + })) + } + rest => self.service.handle_request(rest, context).await, + } + } + + fn get_info(&self) -> ::Info { + let mut info = ServerHandler::get_info(&self.service); + info.capabilities + .tools + .get_or_insert_with(Default::default) + .list_changed = Some(true); + info + } + + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + ServerHandler::supported_protocol_versions(&self.service) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::{ + model::{CallToolResult, ClientNotification, ServerNotification, Tool}, + service::{AtomicU32RequestIdProvider, Peer, PeerSinkMessage, RequestIdProvider}, + }; + + struct DummyHandler; + impl ServerHandler for DummyHandler {} + + async fn recv_notification( + rx: &mut tokio::sync::mpsc::Receiver>, + ) -> ServerNotification { + let msg = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) + .await + .expect("timed out") + .expect("channel closed"); + match msg { + PeerSinkMessage::Notification { + notification, + responder, + } => { + let _ = responder.send(Ok(())); + notification + } + other => panic!("expected notification, got {other:?}"), + } + } + + #[tokio::test] + async fn test_router_deferred_notifier_e2e() { + let mut router = Router::new(DummyHandler).with_tool(tool::ToolRoute::new_dyn( + Tool::new("my_tool", "test", Arc::new(Default::default())), + |_ctx| Box::pin(async { Ok(CallToolResult::default().into()) }), + )); + + let id_provider: Arc = + Arc::new(AtomicU32RequestIdProvider::default()); + let (peer, mut rx) = Peer::::new(id_provider, None); + + let context = crate::service::NotificationContext { + peer: peer.clone(), + meta: Default::default(), + extensions: Default::default(), + }; + router + .handle_notification( + ClientNotification::InitializedNotification(Default::default()), + context, + ) + .await + .unwrap(); + + router.tool_router.disable_route("my_tool"); + assert!(matches!( + recv_notification(&mut rx).await, + ServerNotification::ToolListChangedNotification(_) + )); + + router.tool_router.enable_route("my_tool"); + assert!(matches!( + recv_notification(&mut rx).await, + ServerNotification::ToolListChangedNotification(_) + )); + } +} diff --git a/vendor/rmcp/src/handler/server/router/prompt.rs b/vendor/rmcp/src/handler/server/router/prompt.rs new file mode 100644 index 000000000..509fb4287 --- /dev/null +++ b/vendor/rmcp/src/handler/server/router/prompt.rs @@ -0,0 +1,217 @@ +use std::{borrow::Cow, sync::Arc}; + +use crate::{ + handler::server::prompt::{DynGetPromptHandler, GetPromptHandler, PromptContext}, + model::{GetPromptResponse, Prompt}, + service::{MaybeBoxFuture, MaybeSend}, +}; + +#[non_exhaustive] +pub struct PromptRoute { + #[allow(clippy::type_complexity)] + pub get: Arc>, + pub attr: crate::model::Prompt, +} + +impl std::fmt::Debug for PromptRoute { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PromptRoute") + .field("name", &self.attr.name) + .field("description", &self.attr.description) + .field("arguments", &self.attr.arguments) + .finish() + } +} + +impl Clone for PromptRoute { + fn clone(&self) -> Self { + Self { + get: self.get.clone(), + attr: self.attr.clone(), + } + } +} + +impl PromptRoute { + pub fn new(attr: impl Into, handler: H) -> Self + where + H: GetPromptHandler + MaybeSend + Clone + 'static, + { + Self { + get: Arc::new(move |context: PromptContext| { + let handler = handler.clone(); + handler.handle(context) + }), + attr: attr.into(), + } + } + + pub fn new_dyn(attr: impl Into, handler: H) -> Self + where + H: for<'a> Fn( + PromptContext<'a, S>, + ) + -> MaybeBoxFuture<'a, Result> + + MaybeSend + + 'static, + { + Self { + get: Arc::new(handler), + attr: attr.into(), + } + } + + pub fn name(&self) -> &str { + &self.attr.name + } +} + +pub trait IntoPromptRoute { + fn into_prompt_route(self) -> PromptRoute; +} + +impl IntoPromptRoute for (P, H) +where + S: MaybeSend + 'static, + A: 'static, + H: GetPromptHandler + MaybeSend + Clone + 'static, + P: Into, +{ + fn into_prompt_route(self) -> PromptRoute { + PromptRoute::new(self.0.into(), self.1) + } +} + +impl IntoPromptRoute for PromptRoute +where + S: MaybeSend + 'static, +{ + fn into_prompt_route(self) -> PromptRoute { + self + } +} + +/// Adapter for functions generated by the #\[prompt\] macro +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct PromptAttrGenerateFunctionAdapter; + +impl IntoPromptRoute for F +where + S: MaybeSend + 'static, + F: Fn() -> PromptRoute, +{ + fn into_prompt_route(self) -> PromptRoute { + (self)() + } +} + +#[derive(Debug)] +#[non_exhaustive] +pub struct PromptRouter { + #[allow(clippy::type_complexity)] + pub map: std::collections::HashMap, PromptRoute>, +} + +impl Default for PromptRouter { + fn default() -> Self { + Self { + map: std::collections::HashMap::new(), + } + } +} + +impl Clone for PromptRouter { + fn clone(&self) -> Self { + Self { + map: self.map.clone(), + } + } +} + +impl IntoIterator for PromptRouter { + type Item = PromptRoute; + type IntoIter = std::collections::hash_map::IntoValues, PromptRoute>; + + fn into_iter(self) -> Self::IntoIter { + self.map.into_values() + } +} + +impl PromptRouter +where + S: MaybeSend + 'static, +{ + pub fn new() -> Self { + Self { + map: std::collections::HashMap::new(), + } + } + + pub fn with_route(mut self, route: R) -> Self + where + R: IntoPromptRoute, + { + self.add_route(route.into_prompt_route()); + self + } + + pub fn add_route(&mut self, item: PromptRoute) { + self.map.insert(item.attr.name.clone().into(), item); + } + + pub fn merge(&mut self, other: PromptRouter) { + for item in other.map.into_values() { + self.add_route(item); + } + } + + pub fn remove_route(&mut self, name: &str) { + self.map.remove(name); + } + + pub fn has_route(&self, name: &str) -> bool { + self.map.contains_key(name) + } + + pub async fn get_prompt( + &self, + context: PromptContext<'_, S>, + ) -> Result { + let item = self.map.get(context.name.as_str()).ok_or_else(|| { + crate::ErrorData::invalid_params( + format!("prompt '{}' not found", context.name), + Some(serde_json::json!({ + "available_prompts": self.list_all().iter().map(|p| &p.name).collect::>() + })), + ) + })?; + (item.get)(context).await + } + + pub fn list_all(&self) -> Vec { + let mut prompts: Vec<_> = self.map.values().map(|item| item.attr.clone()).collect(); + prompts.sort_by(|a, b| a.name.cmp(&b.name)); + prompts + } +} + +impl std::ops::Add> for PromptRouter +where + S: MaybeSend + 'static, +{ + type Output = Self; + + fn add(mut self, other: PromptRouter) -> Self::Output { + self.merge(other); + self + } +} + +impl std::ops::AddAssign> for PromptRouter +where + S: MaybeSend + 'static, +{ + fn add_assign(&mut self, other: PromptRouter) { + self.merge(other); + } +} diff --git a/vendor/rmcp/src/handler/server/router/tool.rs b/vendor/rmcp/src/handler/server/router/tool.rs new file mode 100644 index 000000000..f8fab0239 --- /dev/null +++ b/vendor/rmcp/src/handler/server/router/tool.rs @@ -0,0 +1,726 @@ +//! Tools for MCP servers. +//! +//! It's straightforward to define tools using [`tool_router`][crate::tool_router] and +//! [`tool`][crate::tool] macro. +//! +//! ```rust +//! # use rmcp::{ +//! # tool_router, tool, +//! # handler::server::{wrapper::{Parameters, Json}, tool::ToolRouter}, +//! # schemars +//! # }; +//! # use serde::{Serialize, Deserialize}; +//! struct Server; +//! +//! #[derive(Deserialize, schemars::JsonSchema, Default)] +//! struct AddParameter { +//! left: usize, +//! right: usize +//! } +//! #[derive(Serialize, schemars::JsonSchema)] +//! struct AddOutput { +//! sum: usize +//! } +//! #[tool_router(server_handler)] +//! impl Server { +//! #[tool(name = "adder", description = "Modular add two integers")] +//! fn add( +//! &self, +//! Parameters(AddParameter { left, right }): Parameters +//! ) -> Json { +//! Json(AddOutput { sum: left.wrapping_add(right) }) +//! } +//! } +//! ``` +//! +//! The `server_handler` flag emits `#[tool_handler]` for you (tools-only servers). For custom +//! `#[tool_handler(...)]` options or multiple handler macros on one `impl ServerHandler`, write +//! `#[tool_router]` and `#[tool_handler] impl ServerHandler for ...` explicitly—see +//! [`tool_router`][crate::tool_router] and [`tool_handler`][crate::tool_handler]. +//! +//! Using the macro-based code pattern above is suitable for small MCP servers with simple interfaces. +//! When the business logic become larger, it is recommended that each tool should reside +//! in individual file, combined into MCP server using [`SyncTool`] and [`AsyncTool`] traits. +//! +//! ```rust +//! # use rmcp::{ +//! # handler::server::{ +//! # tool::ToolRouter, +//! # router::tool::{SyncTool, AsyncTool, ToolBase}, +//! # }, +//! # schemars, ErrorData +//! # }; +//! # pub struct MyCustomError; +//! # impl From for ErrorData { +//! # fn from(err: MyCustomError) -> ErrorData { unimplemented!() } +//! # } +//! # use serde::{Serialize, Deserialize}; +//! # use std::borrow::Cow; +//! // In tool1.rs +//! pub struct ComplexTool1; +//! #[derive(Deserialize, schemars::JsonSchema, Default)] +//! pub struct ComplexTool1Input { /* ... */ } +//! #[derive(Serialize, schemars::JsonSchema)] +//! pub struct ComplexTool1Output { /* ... */ } +//! +//! impl ToolBase for ComplexTool1 { +//! type Parameter = ComplexTool1Input; +//! type Output = ComplexTool1Output; +//! type Error = MyCustomError; +//! fn name() -> Cow<'static, str> { +//! "complex-tool1".into() +//! } +//! +//! fn description() -> Option> { +//! Some("...".into()) +//! } +//! } +//! impl SyncTool for ComplexTool1 { +//! fn invoke(service: &MyToolServer, param: Self::Parameter) -> Result { +//! // ... +//! # unimplemented!() +//! } +//! } +//! // In tool2.rs +//! pub struct ComplexTool2; +//! #[derive(Deserialize, schemars::JsonSchema, Default)] +//! pub struct ComplexTool2Input { /* ... */ } +//! #[derive(Serialize, schemars::JsonSchema)] +//! pub struct ComplexTool2Output { /* ... */ } +//! +//! impl ToolBase for ComplexTool2 { +//! type Parameter = ComplexTool2Input; +//! type Output = ComplexTool2Output; +//! type Error = MyCustomError; +//! fn name() -> Cow<'static, str> { +//! "complex-tool2".into() +//! } +//! +//! fn description() -> Option> { +//! Some("...".into()) +//! } +//! } +//! impl AsyncTool for ComplexTool2 { +//! async fn invoke(service: &MyToolServer, param: Self::Parameter) -> Result { +//! // ... +//! # unimplemented!() +//! } +//! } +//! +//! // In tool_router.rs +//! struct MyToolServer { +//! tool_router: ToolRouter, +//! } +//! impl MyToolServer { +//! pub fn tool_router() -> ToolRouter { +//! ToolRouter::new() +//! .with_sync_tool::() +//! .with_async_tool::() +//! } +//! } +//! ``` +//! +//! It's also possible to use macro-based and trait-based tool definition together: Since +//! [`ToolRouter`] implements [`Add`][std::ops::Add], you can add two tool routers into final +//! router as showed in [the documentation of `tool_router`][crate::tool_router]. + +mod tool_traits; + +use std::{borrow::Cow, sync::Arc}; + +use schemars::JsonSchema; +pub use tool_traits::{AsyncTool, SyncTool, ToolBase}; + +use crate::{ + handler::server::{ + common::schema_for_input, + tool::{CallToolHandler, DynCallToolHandler, ToolCallContext}, + tool_name_validation::validate_and_warn_tool_name, + }, + model::{CallToolResponse, CallToolResult, ContentBlock, ErrorCode, Tool, ToolAnnotations}, + service::{MaybeBoxFuture, MaybeSend}, +}; + +const TOOL_ARGUMENT_DESERIALIZATION_ERROR_PREFIX: &str = "failed to deserialize parameters:"; + +fn into_tool_argument_error(error: crate::ErrorData) -> Result { + if error.code == ErrorCode::INVALID_PARAMS + && error + .message + .starts_with(TOOL_ARGUMENT_DESERIALIZATION_ERROR_PREFIX) + { + return Ok(CallToolResult::error(vec![ContentBlock::text(error.message)]).into()); + } + + Err(error) +} + +#[non_exhaustive] +pub struct ToolRoute { + #[allow(clippy::type_complexity)] + pub call: Arc>, + pub attr: crate::model::Tool, +} + +impl std::fmt::Debug for ToolRoute { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ToolRoute") + .field("name", &self.attr.name) + .field("description", &self.attr.description) + .field("input_schema", &self.attr.input_schema) + .finish() + } +} + +impl Clone for ToolRoute { + fn clone(&self) -> Self { + Self { + call: self.call.clone(), + attr: self.attr.clone(), + } + } +} + +impl ToolRoute { + pub fn new(attr: impl Into, call: C) -> Self + where + C: CallToolHandler + MaybeSend + Clone + 'static, + { + Self { + call: Arc::new(move |context: ToolCallContext| { + let call = call.clone(); + context.invoke(call) + }), + attr: attr.into(), + } + } + pub fn new_dyn(attr: impl Into, call: C) -> Self + where + C: for<'a> Fn( + ToolCallContext<'a, S>, + ) + -> MaybeBoxFuture<'a, Result> + + MaybeSend + + 'static, + { + Self { + call: Arc::new(call), + attr: attr.into(), + } + } + pub fn name(&self) -> &str { + &self.attr.name + } +} + +pub trait IntoToolRoute { + fn into_tool_route(self) -> ToolRoute; +} + +impl IntoToolRoute for (T, C) +where + S: MaybeSend + 'static, + C: CallToolHandler + MaybeSend + Clone + 'static, + T: Into, +{ + fn into_tool_route(self) -> ToolRoute { + ToolRoute::new(self.0.into(), self.1) + } +} + +impl IntoToolRoute for ToolRoute +where + S: MaybeSend + 'static, +{ + fn into_tool_route(self) -> ToolRoute { + self + } +} + +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct ToolAttrGenerateFunctionAdapter; +impl IntoToolRoute for F +where + S: MaybeSend + 'static, + F: Fn() -> ToolRoute, +{ + fn into_tool_route(self) -> ToolRoute { + (self)() + } +} + +pub trait CallToolHandlerExt: Sized +where + Self: CallToolHandler + MaybeSend + Clone + 'static, +{ + fn name(self, name: impl Into>) -> WithToolAttr; +} + +impl CallToolHandlerExt for C +where + C: CallToolHandler + MaybeSend + Clone + 'static, +{ + fn name(self, name: impl Into>) -> WithToolAttr { + WithToolAttr { + attr: Tool::new( + name.into(), + "", + schema_for_input::().unwrap_or_else(|e| { + panic!("Invalid input schema for JsonObject: {e}"); + }), + ), + call: self, + _marker: std::marker::PhantomData, + } + } +} + +#[non_exhaustive] +pub struct WithToolAttr +where + C: CallToolHandler + MaybeSend + Clone + 'static, +{ + pub attr: crate::model::Tool, + pub call: C, + pub _marker: std::marker::PhantomData, +} + +impl IntoToolRoute for WithToolAttr +where + C: CallToolHandler + MaybeSend + Clone + 'static, + S: MaybeSend + 'static, +{ + fn into_tool_route(self) -> ToolRoute { + ToolRoute::new(self.attr, self.call) + } +} + +impl WithToolAttr +where + C: CallToolHandler + MaybeSend + Clone + 'static, +{ + pub fn description(mut self, description: impl Into>) -> Self { + self.attr.description = Some(description.into()); + self + } + pub fn parameters(mut self) -> Self { + self.attr.input_schema = schema_for_input::().unwrap_or_else(|e| { + panic!( + "Invalid input schema for `{}`: {e}", + std::any::type_name::() + ) + }); + self + } + pub fn parameters_value(mut self, schema: serde_json::Value) -> Self { + self.attr.input_schema = crate::model::object(schema).into(); + self + } + pub fn annotation(mut self, annotation: impl Into) -> Self { + self.attr.annotations = Some(annotation.into()); + self + } +} +#[non_exhaustive] +pub struct ToolRouter { + #[allow(clippy::type_complexity)] + pub map: std::collections::HashMap, ToolRoute>, + + pub transparent_when_not_found: bool, + + disabled: std::collections::HashSet>, + + notifier: Option>, +} + +impl std::fmt::Debug for ToolRouter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ToolRouter") + .field("map", &self.map) + .field( + "transparent_when_not_found", + &self.transparent_when_not_found, + ) + .field("disabled", &self.disabled) + .field("notifier", &self.notifier.as_ref().map(|_| "...")) + .finish() + } +} + +impl Default for ToolRouter { + fn default() -> Self { + Self { + map: std::collections::HashMap::new(), + transparent_when_not_found: false, + disabled: std::collections::HashSet::new(), + notifier: None, + } + } +} + +impl Clone for ToolRouter { + fn clone(&self) -> Self { + Self { + map: self.map.clone(), + transparent_when_not_found: self.transparent_when_not_found, + disabled: self.disabled.clone(), + notifier: self.notifier.clone(), + } + } +} + +impl IntoIterator for ToolRouter { + type Item = ToolRoute; + type IntoIter = std::collections::hash_map::IntoValues, ToolRoute>; + + fn into_iter(self) -> Self::IntoIter { + let mut map = self.map; + for name in &self.disabled { + map.remove(name); + } + map.into_values() + } +} + +impl ToolRouter +where + S: MaybeSend + 'static, +{ + pub fn new() -> Self { + Self::default() + } + pub fn with_route(mut self, route: R) -> Self + where + R: IntoToolRoute, + { + self.add_route(route.into_tool_route()); + self + } + + /// Add a tool that implements [`SyncTool`] + pub fn with_sync_tool(self) -> Self + where + T: SyncTool + 'static, + { + if T::input_schema().is_some() { + self.with_route(( + tool_traits::tool_attribute::(), + tool_traits::sync_tool_wrapper::, + )) + } else { + self.with_route(( + tool_traits::tool_attribute::(), + tool_traits::sync_tool_wrapper_with_empty_params::, + )) + } + } + + /// Add a tool that implements [`AsyncTool`] + pub fn with_async_tool(self) -> Self + where + T: AsyncTool + 'static, + { + if T::input_schema().is_some() { + self.with_route(( + tool_traits::tool_attribute::(), + tool_traits::async_tool_wrapper::, + )) + } else { + self.with_route(( + tool_traits::tool_attribute::(), + tool_traits::async_tool_wrapper_with_empty_params::, + )) + } + } + + pub fn add_route(&mut self, item: ToolRoute) { + let new_name = &item.attr.name; + validate_and_warn_tool_name(new_name); + self.map.insert(new_name.clone(), item); + } + + pub fn merge(&mut self, other: ToolRouter) { + self.disabled.extend(other.disabled); + for item in other.map.into_values() { + self.add_route(item); + } + } + + /// Remove a tool route from the router. + /// + /// The disabled state is **preserved**: if the name was in the disabled + /// set, it stays there so that a future [`add_route`](Self::add_route) + /// or [`merge`](Self::merge) with the same name will inherit the + /// disabled state. To also clear the disabled marker, call + /// [`enable_route`](Self::enable_route) afterwards. + pub fn remove_route(&mut self, name: &str) { + self.map.remove(name); + } + + /// Returns `true` if the tool is registered **and** not currently + /// disabled. + pub fn has_route(&self, name: &str) -> bool { + self.map.contains_key(name) && !self.disabled.contains(name) + } + + /// Disable a tool by name. Hidden from `list_all`, `get`, rejected by + /// `call`. Re-enable with [`enable_route`](Self::enable_route). + /// + /// Returns `true` if the name was newly added to the disabled set. + /// The name is recorded even if no matching route exists yet, so routes + /// added later will inherit the disabled state. + pub fn disable_route(&mut self, name: impl Into>) -> bool { + let name = name.into(); + let was_visible = self.map.contains_key(&name) && !self.disabled.contains(&name); + if was_visible { + self.notify_if_visible(&name); + } + self.disabled.insert(name) + } + + /// Re-enable a previously disabled tool. Returns `true` if the name + /// was in the disabled set. + pub fn enable_route(&mut self, name: &str) -> bool { + let removed = self.disabled.remove(name); + if removed { + self.notify_if_visible(name); + } + removed + } + + /// Returns `true` if the tool exists in the router **and** is currently + /// disabled. Returns `false` if the tool does not exist or if the name + /// was pre-disabled without a matching route. + pub fn is_disabled(&self, name: &str) -> bool { + self.map.contains_key(name) && self.disabled.contains(name) + } + + /// Builder-style variant of [`disable_route`](Self::disable_route). + /// + /// The name is recorded even if no matching route has been added yet, + /// so it can be called before [`with_route`](Self::with_route) in a + /// builder chain. + pub fn with_disabled(mut self, name: impl Into>) -> Self { + self.disabled.insert(name.into()); + self + } + + /// Install a callback invoked when the visible tool list changes. + pub fn set_notifier(&mut self, f: impl Fn() + Send + Sync + 'static) { + self.notifier = Some(Arc::new(f)); + } + + pub fn clear_notifier(&mut self) { + self.notifier = None; + } + + /// Install a notifier that sends `notifications/tools/list_changed` + /// via the given peer. + pub fn bind_peer_notifier(&mut self, peer: &crate::service::Peer) { + let peer = peer.clone(); + self.set_notifier(move || { + let peer = peer.clone(); + tokio::spawn(async move { + if let Err(e) = peer.notify_tool_list_changed().await { + tracing::warn!("failed to send tools/list_changed notification: {e}"); + } + }); + }); + } + + /// Deferred notifier: no-op until the peer slot is filled. + pub(crate) fn deferred_peer_notifier() -> ( + impl Fn() + Send + Sync + 'static, + Arc>>, + ) { + let peer_slot = + Arc::new(std::sync::OnceLock::>::new()); + let slot_clone = peer_slot.clone(); + let notifier = move || { + if let Some(peer) = slot_clone.get() { + let peer = peer.clone(); + tokio::spawn(async move { + if let Err(e) = peer.notify_tool_list_changed().await { + tracing::warn!("failed to send tools/list_changed notification: {e}"); + } + }); + } + }; + (notifier, peer_slot) + } + + fn notify_if_visible(&self, name: &str) { + if self.map.contains_key(name) + && let Some(notifier) = &self.notifier + { + notifier(); + } + } + + pub async fn call( + &self, + context: ToolCallContext<'_, S>, + ) -> Result { + let name = context.name(); + if self.disabled.contains(name) { + return Err(crate::ErrorData::invalid_params("tool not found", None)); + } + let item = self + .map + .get(name) + .ok_or_else(|| crate::ErrorData::invalid_params("tool not found", None))?; + + let result = match (item.call)(context).await { + Ok(result) => result, + Err(error) => return into_tool_argument_error(error), + }; + + Ok(result) + } + + pub fn list_all(&self) -> Vec { + let mut tools: Vec<_> = self + .map + .values() + .filter(|item| !self.disabled.contains(&item.attr.name)) + .map(|item| item.attr.clone()) + .collect(); + tools.sort_by(|a, b| a.name.cmp(&b.name)); + tools + } + + /// Get a tool definition by name. + /// + /// Returns the tool if found and enabled, or `None` if the tool does not + /// exist or is disabled. + pub fn get(&self, name: &str) -> Option<&crate::model::Tool> { + if self.disabled.contains(name) { + return None; + } + self.map.get(name).map(|r| &r.attr) + } +} + +impl std::ops::Add> for ToolRouter +where + S: MaybeSend + 'static, +{ + type Output = Self; + + fn add(mut self, other: ToolRouter) -> Self::Output { + self.merge(other); + self + } +} + +impl std::ops::AddAssign> for ToolRouter +where + S: MaybeSend + 'static, +{ + fn add_assign(&mut self, other: ToolRouter) { + self.merge(other); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::{ + RoleServer, + handler::server::wrapper::Parameters, + model::{CallToolRequestParams, ErrorCode, NumberOrString}, + service::{AtomicU32RequestIdProvider, Peer, RequestContext}, + }; + + struct DummyService; + impl crate::handler::server::ServerHandler for DummyService {} + + #[derive(serde::Deserialize, schemars::JsonSchema)] + struct RequiredParams { + project: String, + } + + fn requires_params(Parameters(params): Parameters) -> String { + params.project + } + + #[tokio::test] + async fn test_argument_deserialization_error_returns_tool_error_result() { + let service = DummyService; + let router = ToolRouter::new().with_route(ToolRoute::new( + crate::model::Tool::new( + "requires_params", + "requires params", + Arc::new(Default::default()), + ), + requires_params, + )); + + let id_provider: Arc = + Arc::new(AtomicU32RequestIdProvider::default()); + let (peer, _rx) = Peer::::new(id_provider, None); + let ctx = crate::handler::server::tool::ToolCallContext::new( + &service, + CallToolRequestParams { + meta: None, + name: Cow::Borrowed("requires_params"), + arguments: Some(Default::default()), + input_responses: None, + request_state: None, + }, + RequestContext::new(NumberOrString::Number(1), peer), + ); + + let result = router + .call(ctx) + .await + .expect("argument validation should be a tool result"); + let CallToolResponse::Complete(result) = result else { + panic!("expected complete CallToolResult"); + }; + assert_eq!(result.is_error, Some(true)); + + let text = result + .content + .first() + .and_then(|content| content.as_text()) + .map(|text| text.text.as_str()) + .expect("tool error result should include text"); + assert!(text.contains("failed to deserialize parameters")); + assert!(text.contains("missing field `project`")); + } + + #[tokio::test] + async fn test_call_disabled_tool_returns_error() { + let service = DummyService; + let mut router = ToolRouter::new().with_route(ToolRoute::new_dyn( + crate::model::Tool::new("test_tool", "a test tool", Arc::new(Default::default())), + |_ctx| Box::pin(async { Ok(CallToolResult::default().into()) }), + )); + router.disable_route("test_tool"); + + let id_provider: Arc = + Arc::new(AtomicU32RequestIdProvider::default()); + let (peer, _rx) = Peer::::new(id_provider, None); + let ctx = crate::handler::server::tool::ToolCallContext::new( + &service, + CallToolRequestParams { + meta: None, + name: Cow::Borrowed("test_tool"), + arguments: None, + input_responses: None, + request_state: None, + }, + RequestContext::new(NumberOrString::Number(1), peer), + ); + + let err = router + .call(ctx) + .await + .expect_err("disabled tool should reject"); + assert_eq!(err.code, ErrorCode::INVALID_PARAMS); + assert_eq!(err.message, "tool not found"); + } +} diff --git a/vendor/rmcp/src/handler/server/router/tool/tool_traits.rs b/vendor/rmcp/src/handler/server/router/tool/tool_traits.rs new file mode 100644 index 000000000..bde92147d --- /dev/null +++ b/vendor/rmcp/src/handler/server/router/tool/tool_traits.rs @@ -0,0 +1,339 @@ +use std::{borrow::Cow, future::Future, sync::Arc}; + +use serde::{Deserialize, Serialize}; + +use crate::{ + ErrorData, + handler::server::{ + common::{schema_for_empty_input, schema_for_input}, + tool::schema_for_output, + wrapper::{Json, Parameters}, + }, + model::{Icon, JsonObject, MetaObject, ToolAnnotations}, + schemars::JsonSchema, + service::{MaybeSend, MaybeSendFuture}, +}; + +/// Base trait to define attributes of a tool. +/// +/// Tools implementing [`SyncTool`] or [`AsyncTool`] must implement this trait first. +/// +/// All methods are consistent with fields of [`Tool`][crate::model::Tool]. +pub trait ToolBase { + /// Parameter type, will used in the invoke parameter of [`SyncTool`] or [`AsyncTool`] trait + /// + /// If the tool does not have any parameters, you **MUST** override [`input_schema`][Self::input_schema] + /// method. See its documentation for more details. + type Parameter: for<'de> Deserialize<'de> + JsonSchema + Send + Default + 'static; + /// Output type, will used in the invoke output of [`SyncTool`] or [`AsyncTool`] trait + /// + /// If the tool does not have any output, you **MUST** override [`output_schema`][Self::output_schema] + /// method. See its documentation for more details. + type Output: Serialize + JsonSchema + Send + 'static; + /// Error type, will used in the invoke output of [`SyncTool`] or [`AsyncTool`] trait + type Error: Into + Send + 'static; + + fn name() -> Cow<'static, str>; + + fn title() -> Option { + None + } + fn description() -> Option> { + None + } + + /// Json schema for tool input. + /// + /// The default implementation generates schema based on [`Self::Parameter`] type. + /// + /// If the tool does not have any parameters, you should override this methods to return [`None`], + /// and when invoked, the parameter will get default values. + fn input_schema() -> Option> { + Some( + schema_for_input::>().unwrap_or_else(|e| { + panic!( + "Invalid input schema for ToolBase::Parameter type `{0}`: {e}", + std::any::type_name::(), + ); + }), + ) + } + + /// Json schema for tool output. + /// + /// The default implementation generates schema based on [`Self::Output`] type. + /// + /// If the tool does not have any output, you should override this methods to return [`None`]. + fn output_schema() -> Option> { + Some(schema_for_output::()) + } + + fn annotations() -> Option { + None + } + fn icons() -> Option> { + None + } + fn meta() -> Option { + None + } +} + +/// Synchronous version of a tool. +/// +/// Consider using [`AsyncTool`] if your workflow involves asynchronous operations. +/// Examples are shown in [the module-level documentation][crate::handler::server::router::tool]. +#[allow(private_bounds)] +pub trait SyncTool: ToolBase { + fn invoke(service: &S, param: Self::Parameter) -> Result; +} + +/// Asynchronous version of a tool. +/// +/// Consider using [`SyncTool`] if your workflow does not involve asynchronous operations. +/// Examples are shown in [the module-level documentation][crate::handler::server::router::tool]. +#[allow(private_bounds)] +pub trait AsyncTool: ToolBase { + fn invoke( + service: &S, + param: Self::Parameter, + ) -> impl Future> + MaybeSendFuture; +} + +pub(crate) fn tool_attribute() -> crate::model::Tool { + crate::model::Tool { + name: T::name(), + title: T::title(), + description: T::description(), + input_schema: T::input_schema().unwrap_or_else(schema_for_empty_input), + output_schema: T::output_schema(), + annotations: T::annotations(), + icons: T::icons(), + meta: T::meta(), + } +} + +pub(crate) fn sync_tool_wrapper>( + service: &S, + Parameters(params): Parameters, +) -> Result, ErrorData> { + T::invoke(service, params).map(Json).map_err(Into::into) +} + +pub(crate) fn sync_tool_wrapper_with_empty_params>( + service: &S, +) -> Result, ErrorData> { + T::invoke(service, T::Parameter::default()) + .map(Json) + .map_err(Into::into) +} + +pub(crate) fn async_tool_wrapper>( + service: &S, + Parameters(params): Parameters, +) -> crate::service::MaybeBoxFuture<'_, Result, ErrorData>> { + Box::pin(async move { + T::invoke(service, params) + .await + .map(Json) + .map_err(Into::into) + }) +} + +pub(crate) fn async_tool_wrapper_with_empty_params>( + service: &S, +) -> crate::service::MaybeBoxFuture<'_, Result, ErrorData>> { + Box::pin(async move { + T::invoke(service, T::Parameter::default()) + .await + .map(Json) + .map_err(Into::into) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate as rmcp; + use crate::tool; // workaround for macros + + #[derive(Deserialize, schemars::JsonSchema, Default)] + struct AddParameter { + left: usize, + right: usize, + } + #[derive(Serialize, schemars::JsonSchema, PartialEq, Debug)] + struct AddOutput { + sum: usize, + } + + struct MacroBasedToolServer; + + impl MacroBasedToolServer { + #[expect(unused)] + #[tool(name = "adder", description = "Modular add two integers")] + fn add( + &self, + Parameters(AddParameter { left, right }): Parameters, + ) -> Json { + Json(AddOutput { + sum: left.wrapping_add(right), + }) + } + + #[expect(unused)] + #[tool(name = "empty", description = "Empty tool")] + fn empty(&self) {} + } + + struct AddTool; + impl ToolBase for AddTool { + type Parameter = AddParameter; + type Output = AddOutput; + type Error = ErrorData; + + fn name() -> Cow<'static, str> { + "adder".into() + } + + fn description() -> Option> { + Some("Modular add two integers".into()) + } + } + impl SyncTool for AddTool { + fn invoke( + _service: &TraitBasedToolServer, + AddParameter { left, right }: Self::Parameter, + ) -> Result { + Ok(AddOutput { + sum: left.wrapping_add(right), + }) + } + } + impl AsyncTool for AddTool { + async fn invoke( + _service: &TraitBasedToolServer, + AddParameter { left, right }: Self::Parameter, + ) -> Result { + Ok(AddOutput { + sum: left.wrapping_add(right), + }) + } + } + + enum EmptyToolCustomError { + Internal, + InvalidParams, + } + impl From for ErrorData { + fn from(value: EmptyToolCustomError) -> Self { + match value { + EmptyToolCustomError::Internal => Self::internal_error("internal error", None), + EmptyToolCustomError::InvalidParams => Self::invalid_params("invalid params", None), + } + } + } + + struct EmptyTool; + impl ToolBase for EmptyTool { + type Parameter = (); + type Output = (); + type Error = EmptyToolCustomError; + + fn name() -> Cow<'static, str> { + "empty".into() + } + + fn description() -> Option> { + Some("Empty tool".into()) + } + + fn input_schema() -> Option> { + None + } + + fn output_schema() -> Option> { + None + } + } + impl SyncTool for EmptyTool { + fn invoke( + _service: &TraitBasedToolServer, + _param: Self::Parameter, + ) -> Result { + Err(EmptyToolCustomError::Internal) + } + } + impl AsyncTool for EmptyTool { + async fn invoke( + _service: &TraitBasedToolServer, + _param: Self::Parameter, + ) -> Result { + Err(EmptyToolCustomError::InvalidParams) + } + } + + struct TraitBasedToolServer; + + #[test] + fn test_macro_and_trait_have_same_attrs() { + let macro_attrs = MacroBasedToolServer::add_tool_attr(); + let trait_attrs = tool_attribute::(); + assert_eq!(macro_attrs, trait_attrs); + } + + #[test] + fn test_macro_and_trait_have_same_attrs_for_empty_tool() { + let macro_attrs = MacroBasedToolServer::empty_tool_attr(); + let trait_attrs = tool_attribute::(); + assert_eq!(macro_attrs, trait_attrs); + } + + #[test] + fn test_sync_tool_wrapper_happy_path() { + let left = 1; + let right = 2; + let result = sync_tool_wrapper::<_, AddTool>( + &TraitBasedToolServer, + Parameters(AddParameter { left, right }), + ); + assert!(result.is_ok()); + if let Ok(result) = result { + assert_eq!(result.0, AddOutput { sum: 3 }); + } + } + + #[tokio::test] + async fn test_async_tool_wrapper_happy_path() { + let left = 1; + let right = 2; + let result = async_tool_wrapper::<_, AddTool>( + &TraitBasedToolServer, + Parameters(AddParameter { left, right }), + ) + .await; + assert!(result.is_ok()); + if let Ok(result) = result { + assert_eq!(result.0, AddOutput { sum: 3 }); + } + } + + #[test] + fn test_sync_tool_wrapper_error_conversion() { + let result = sync_tool_wrapper::<_, EmptyTool>(&TraitBasedToolServer, Parameters(())); + assert!(result.is_err()); + if let Err(result) = result { + assert_eq!(result, ErrorData::internal_error("internal error", None)); + } + } + + #[tokio::test] + async fn test_async_tool_wrapper_error_conversion() { + let result = + async_tool_wrapper::<_, EmptyTool>(&TraitBasedToolServer, Parameters(())).await; + assert!(result.is_err()); + if let Err(result) = result { + assert_eq!(result, ErrorData::invalid_params("invalid params", None)); + } + } +} diff --git a/vendor/rmcp/src/handler/server/tool.rs b/vendor/rmcp/src/handler/server/tool.rs new file mode 100644 index 000000000..a90240660 --- /dev/null +++ b/vendor/rmcp/src/handler/server/tool.rs @@ -0,0 +1,367 @@ +use std::{ + borrow::Cow, + future::{Future, Ready}, + marker::PhantomData, +}; + +#[cfg(not(feature = "local"))] +use futures::future::BoxFuture; +use serde::de::DeserializeOwned; + +use super::common::{AsRequestContext, FromContextPart}; +pub use super::{ + common::{Extension, RequestId, schema_for_input, schema_for_output, schema_for_type}, + router::tool::{ToolRoute, ToolRouter}, +}; +use crate::{ + RoleServer, + handler::server::wrapper::Parameters, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, InputRequiredResult, IntoContents, + JsonObject, + }, + service::{MaybeBoxFuture, MaybeSend, MaybeSendFuture, RequestContext}, +}; + +/// Deserialize a JSON object into a type +pub fn parse_json_object(input: JsonObject) -> Result { + serde_json::from_value(serde_json::Value::Object(input)).map_err(|e| { + crate::ErrorData::invalid_params( + format!("failed to deserialize parameters: {error}", error = e), + None, + ) + }) +} +#[non_exhaustive] +pub struct ToolCallContext<'s, S> { + pub request_context: RequestContext, + pub service: &'s S, + pub name: Cow<'static, str>, + pub arguments: Option, +} + +impl<'s, S> ToolCallContext<'s, S> { + pub fn new( + service: &'s S, + CallToolRequestParams { + meta: _, + name, + arguments, + .. + }: CallToolRequestParams, + request_context: RequestContext, + ) -> Self { + Self { + request_context, + service, + name, + arguments, + } + } + pub fn name(&self) -> &str { + &self.name + } + pub fn request_context(&self) -> &RequestContext { + &self.request_context + } +} + +impl AsRequestContext for ToolCallContext<'_, S> { + fn as_request_context(&self) -> &RequestContext { + &self.request_context + } + + fn as_request_context_mut(&mut self) -> &mut RequestContext { + &mut self.request_context + } +} + +pub trait IntoCallToolResult { + fn into_call_tool_result(self) -> Result; +} + +impl IntoCallToolResult for T { + fn into_call_tool_result(self) -> Result { + Ok(CallToolResult::success(self.into_contents()).into()) + } +} + +impl IntoCallToolResult for CallToolResult { + fn into_call_tool_result(self) -> Result { + Ok(self.into()) + } +} + +impl IntoCallToolResult for InputRequiredResult { + fn into_call_tool_result(self) -> Result { + Ok(self.into()) + } +} + +impl IntoCallToolResult for crate::ErrorData { + fn into_call_tool_result(self) -> Result { + Err(self) + } +} + +impl IntoCallToolResult for Result { + fn into_call_tool_result(self) -> Result { + match self { + Ok(value) => value.into_call_tool_result(), + Err(error) => match error.into_call_tool_result() { + Ok(CallToolResponse::Complete(mut result)) => { + result.is_error = Some(true); + Ok(result.into()) + } + Ok(CallToolResponse::InputRequired(_)) => Err(crate::ErrorData::internal_error( + "InputRequiredResult cannot be returned from a tool error branch", + None, + )), + Ok(CallToolResponse::Task(_)) => Err(crate::ErrorData::internal_error( + "CreateTaskResult cannot be returned from a tool error branch", + None, + )), + Err(e) => Err(e), + }, + } + } +} + +pin_project_lite::pin_project! { + #[project = IntoCallToolResultFutProj] + #[non_exhaustive] + pub enum IntoCallToolResultFut { + Pending { + #[pin] + fut: F, + _marker: PhantomData, + }, + Ready { + #[pin] + result: Ready>, + } + } +} + +impl Future for IntoCallToolResultFut +where + F: Future, + R: IntoCallToolResult, +{ + type Output = Result; + + fn poll( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + match self.project() { + IntoCallToolResultFutProj::Pending { fut, _marker } => { + fut.poll(cx).map(IntoCallToolResult::into_call_tool_result) + } + IntoCallToolResultFutProj::Ready { result } => result.poll(cx), + } + } +} + +pub trait CallToolHandler { + fn call( + self, + context: ToolCallContext<'_, S>, + ) -> MaybeBoxFuture<'_, Result>; +} + +#[cfg(not(feature = "local"))] +pub type DynCallToolHandler = dyn for<'s> Fn(ToolCallContext<'s, S>) -> BoxFuture<'s, Result> + + Send + + Sync; + +#[cfg(feature = "local")] +pub type DynCallToolHandler = dyn for<'s> Fn( + ToolCallContext<'s, S>, +) -> futures::future::LocalBoxFuture< + 's, + Result, +>; + +// Tool-specific extractor for tool name +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct ToolName(pub Cow<'static, str>); + +impl FromContextPart> for ToolName { + fn from_context_part(context: &mut ToolCallContext) -> Result { + Ok(Self(context.name.clone())) + } +} + +// Special implementation for Parameters that handles tool arguments +impl FromContextPart> for Parameters

+where + P: DeserializeOwned, +{ + fn from_context_part(context: &mut ToolCallContext) -> Result { + let arguments = context.arguments.take().unwrap_or_default(); + let value: P = + serde_json::from_value(serde_json::Value::Object(arguments)).map_err(|e| { + crate::ErrorData::invalid_params( + format!("failed to deserialize parameters: {error}", error = e), + None, + ) + })?; + Ok(Parameters(value)) + } +} + +// Special implementation for JsonObject that takes tool arguments +impl FromContextPart> for JsonObject { + fn from_context_part(context: &mut ToolCallContext) -> Result { + let object = context.arguments.take().unwrap_or_default(); + Ok(object) + } +} + +impl<'s, S> ToolCallContext<'s, S> { + pub fn invoke( + self, + h: H, + ) -> MaybeBoxFuture<'s, Result> + where + H: CallToolHandler, + { + h.call(self) + } +} +#[allow(clippy::type_complexity)] +pub struct AsyncAdapter(PhantomData fn(Fut) -> R>); +pub struct SyncAdapter(PhantomData R>); +// #[allow(clippy::type_complexity)] +pub struct AsyncMethodAdapter(PhantomData R>); +pub struct SyncMethodAdapter(PhantomData R>); + +macro_rules! impl_for { + ($($T: ident)*) => { + impl_for!([] [$($T)*]); + }; + // finished + ([$($Tn: ident)*] []) => { + impl_for!(@impl $($Tn)*); + }; + ([$($Tn: ident)*] [$Tn_1: ident $($Rest: ident)*]) => { + impl_for!(@impl $($Tn)*); + impl_for!([$($Tn)* $Tn_1] [$($Rest)*]); + }; + (@impl $($Tn: ident)*) => { + impl<$($Tn,)* S, F, R> CallToolHandler> for F + where + $( + $Tn: for<'a> FromContextPart> , + )* + F: FnOnce(&S, $($Tn,)*) -> MaybeBoxFuture<'_, R>, + + // Need RTN support here(I guess), https://github.com/rust-lang/rust/pull/138424 + // Fut: Future + Send + 'a, + R: IntoCallToolResult + MaybeSendFuture + 'static, + S: MaybeSend + 'static, + { + #[allow(unused_variables, non_snake_case, unused_mut)] + fn call( + self, + mut context: ToolCallContext<'_, S>, + ) -> MaybeBoxFuture<'_, Result>{ + $( + let result = $Tn::from_context_part(&mut context); + let $Tn = match result { + Ok(value) => value, + Err(e) => return Box::pin(std::future::ready(Err(e))), + }; + )* + let service = context.service; + let fut = self(service, $($Tn,)*); + Box::pin(async move { + let result = fut.await; + result.into_call_tool_result() + }) + } + } + + impl<$($Tn,)* S, F, Fut, R> CallToolHandler> for F + where + $( + $Tn: for<'a> FromContextPart> , + )* + F: FnOnce($($Tn,)*) -> Fut + MaybeSendFuture, + Fut: Future + MaybeSendFuture + 'static, + R: IntoCallToolResult + MaybeSendFuture + 'static, + S: MaybeSend, + { + #[allow(unused_variables, non_snake_case, unused_mut)] + fn call( + self, + mut context: ToolCallContext, + ) -> MaybeBoxFuture<'static, Result>{ + $( + let result = $Tn::from_context_part(&mut context); + let $Tn = match result { + Ok(value) => value, + Err(e) => return Box::pin(std::future::ready(Err(e))), + }; + )* + let fut = self($($Tn,)*); + Box::pin(async move { + let result = fut.await; + result.into_call_tool_result() + }) + } + } + + impl<$($Tn,)* S, F, R> CallToolHandler> for F + where + $( + $Tn: for<'a> FromContextPart> + , + )* + F: FnOnce(&S, $($Tn,)*) -> R + MaybeSendFuture, + R: IntoCallToolResult + MaybeSendFuture, + S: MaybeSend, + { + #[allow(unused_variables, non_snake_case, unused_mut)] + fn call( + self, + mut context: ToolCallContext, + ) -> MaybeBoxFuture<'static, Result> { + $( + let result = $Tn::from_context_part(&mut context); + let $Tn = match result { + Ok(value) => value, + Err(e) => return Box::pin(std::future::ready(Err(e))), + }; + )* + Box::pin(std::future::ready(self(context.service, $($Tn,)*).into_call_tool_result())) + } + } + + impl<$($Tn,)* S, F, R> CallToolHandler> for F + where + $( + $Tn: for<'a> FromContextPart> + , + )* + F: FnOnce($($Tn,)*) -> R + MaybeSendFuture, + R: IntoCallToolResult + MaybeSendFuture, + S: MaybeSend, + { + #[allow(unused_variables, non_snake_case, unused_mut)] + fn call( + self, + mut context: ToolCallContext, + ) -> MaybeBoxFuture<'static, Result> { + $( + let result = $Tn::from_context_part(&mut context); + let $Tn = match result { + Ok(value) => value, + Err(e) => return Box::pin(std::future::ready(Err(e))), + }; + )* + Box::pin(std::future::ready(self($($Tn,)*).into_call_tool_result())) + } + } + }; +} +impl_for!(T0 T1 T2 T3 T4 T5 T6 T7 T8 T9 T10 T11 T12 T13 T14 T15); diff --git a/vendor/rmcp/src/handler/server/tool_name_validation.rs b/vendor/rmcp/src/handler/server/tool_name_validation.rs new file mode 100644 index 000000000..8ae04b626 --- /dev/null +++ b/vendor/rmcp/src/handler/server/tool_name_validation.rs @@ -0,0 +1,261 @@ +//! Tool name validation utilities according to SEP: Specify Format for Tool Names +//! +//! Tool names SHOULD be between 1 and 128 characters in length (inclusive). +//! Tool names are case-sensitive. +//! Allowed characters: uppercase and lowercase ASCII letters (A-Z, a-z), digits +//! (0-9), underscore (_), dash (-), and dot (.). +//! Tool names SHOULD NOT contain spaces, commas, or other special characters. + +use std::collections::HashSet; + +/// Result of tool name validation containing validation status and warnings. +#[derive(Debug, Clone, PartialEq, Eq)] +struct ToolNameValidationResult { + /// Whether the tool name is valid according to the specification + is_valid: bool, + /// Array of warning messages about non-conforming aspects of the tool name + warnings: Vec, +} + +impl ToolNameValidationResult { + /// Create a new validation result + fn new(is_valid: bool, warnings: Vec) -> Self { + Self { is_valid, warnings } + } +} + +/// Validates a tool name according to the SEP specification. +fn validate_tool_name(name: &str) -> ToolNameValidationResult { + let mut warnings = Vec::new(); + + // Check length + if name.is_empty() { + return ToolNameValidationResult::new(false, vec!["Tool name cannot be empty".to_string()]); + } + + if name.len() > 128 { + return ToolNameValidationResult::new( + false, + vec![format!( + "Tool name exceeds maximum length of 128 characters (current: {})", + name.len() + )], + ); + } + + // Check for specific problematic patterns (these are warnings, not validation failures) + if name.contains(' ') { + warnings.push("Tool name contains spaces, which may cause parsing issues".to_string()); + } + + if name.contains(',') { + warnings.push("Tool name contains commas, which may cause parsing issues".to_string()); + } + + // Check for potentially confusing patterns (leading/trailing dashes, dots, slashes) + if name.starts_with('-') || name.ends_with('-') { + warnings.push( + "Tool name starts or ends with a dash, which may cause parsing issues in some contexts" + .to_string(), + ); + } + + if name.starts_with('.') || name.ends_with('.') { + warnings.push( + "Tool name starts or ends with a dot, which may cause parsing issues in some contexts" + .to_string(), + ); + } + + // Check for invalid characters + let mut invalid_chars = HashSet::new(); + let valid_chars: HashSet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-" + .chars() + .collect(); + + for ch in name.chars() { + if !valid_chars.contains(&ch) { + invalid_chars.insert(ch); + } + } + + if !invalid_chars.is_empty() { + let invalid_chars_list: Vec = + invalid_chars.iter().map(|c| format!("\"{}\"", c)).collect(); + warnings.push(format!( + "Tool name contains invalid characters: {}", + invalid_chars_list.join(", ") + )); + warnings.push( + "Allowed characters are: A-Z, a-z, 0-9, underscore (_), dash (-), and dot (.)" + .to_string(), + ); + + return ToolNameValidationResult::new(false, warnings); + } + + // Verify the pattern matches (double check with character-by-character validation) + // We've already validated characters above, just need to verify length is within bounds + if name.is_empty() || name.len() > 128 { + return ToolNameValidationResult::new( + false, + vec!["Tool name length must be between 1 and 128 characters".to_string()], + ); + } + + ToolNameValidationResult::new(true, warnings) +} + +/// Issues warnings for non-conforming tool names. +fn issue_tool_name_warning(name: &str, warnings: &[String]) { + tracing::warn!("Tool name validation warning for \"{}\":", name); + for warning in warnings { + tracing::warn!(" - {}", warning); + } + tracing::warn!("Tool registration will proceed, but this may cause compatibility issues."); + tracing::warn!("Consider updating the tool name to conform to the MCP tool naming standard."); + tracing::warn!( + "See SEP: Specify Format for Tool Names (https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986) for more details." + ); +} + +/// Validates a tool name and issues warnings for non-conforming names. +pub fn validate_and_warn_tool_name(name: &str) -> bool { + let result = validate_tool_name(name); + + if !result.warnings.is_empty() { + issue_tool_name_warning(name, &result.warnings); + } + + result.is_valid +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_tool_names() { + let max_length_name = "a".repeat(128); + let valid_names = vec![ + "my_tool", + "MyTool", + "my-tool", + "my.tool", + "tool123", + "a", + max_length_name.as_str(), // Maximum length + ]; + + for name in valid_names { + let result = validate_tool_name(name); + assert!(result.is_valid, "Tool name '{}' should be valid", name); + } + } + + #[test] + fn test_empty_tool_name() { + let result = validate_tool_name(""); + assert!(!result.is_valid); + assert!( + result + .warnings + .contains(&"Tool name cannot be empty".to_string()) + ); + } + + #[test] + fn test_too_long_tool_name() { + let name = "a".repeat(129); + let result = validate_tool_name(&name); + assert!(!result.is_valid); + assert!(result.warnings[0].contains("exceeds maximum length")); + } + + #[test] + fn test_tool_name_with_spaces() { + let result = validate_tool_name("my tool"); + assert!(!result.is_valid); + assert!( + result + .warnings + .iter() + .any(|w| w.contains("contains spaces")) + ); + } + + #[test] + fn test_tool_name_with_commas() { + let result = validate_tool_name("my,tool"); + assert!(!result.is_valid); + assert!( + result + .warnings + .iter() + .any(|w| w.contains("contains commas")) + ); + } + + #[test] + fn test_tool_name_starting_with_dash() { + let result = validate_tool_name("-tool"); + assert!(result.is_valid); // Still valid, but has warning + assert!( + result + .warnings + .iter() + .any(|w| w.contains("starts or ends with a dash")) + ); + } + + #[test] + fn test_tool_name_ending_with_dot() { + let result = validate_tool_name("tool."); + assert!(result.is_valid); // Still valid, but has warning + assert!( + result + .warnings + .iter() + .any(|w| w.contains("starts or ends with a dot")) + ); + } + + #[test] + fn test_tool_name_with_invalid_characters() { + let result = validate_tool_name("my@tool"); + assert!(!result.is_valid); + assert!( + result + .warnings + .iter() + .any(|w| w.contains("contains invalid characters")) + ); + } + + #[test] + fn test_tool_name_all_special_characters_allowed() { + let valid_chars = vec!['_', '-', '.']; + for ch in valid_chars { + let name = format!("tool{}", ch); + let result = validate_tool_name(&name); + assert!( + result.is_valid, + "Tool name with character '{}' should be valid", + ch + ); + } + } + + #[test] + fn test_minimum_length() { + let result = validate_tool_name("a"); + assert!(result.is_valid); + } + + #[test] + fn test_maximum_length() { + let name = "a".repeat(128); + let result = validate_tool_name(&name); + assert!(result.is_valid); + } +} diff --git a/vendor/rmcp/src/handler/server/wrapper.rs b/vendor/rmcp/src/handler/server/wrapper.rs new file mode 100644 index 000000000..d9f2c86d9 --- /dev/null +++ b/vendor/rmcp/src/handler/server/wrapper.rs @@ -0,0 +1,4 @@ +mod json; +mod parameters; +pub use json::*; +pub use parameters::*; diff --git a/vendor/rmcp/src/handler/server/wrapper/json.rs b/vendor/rmcp/src/handler/server/wrapper/json.rs new file mode 100644 index 000000000..7c5297963 --- /dev/null +++ b/vendor/rmcp/src/handler/server/wrapper/json.rs @@ -0,0 +1,43 @@ +use std::borrow::Cow; + +use schemars::JsonSchema; +use serde::Serialize; + +use crate::{ + handler::server::tool::IntoCallToolResult, + model::{CallToolResponse, CallToolResult}, +}; + +/// Json wrapper for structured output +/// +/// When used with tools, this wrapper indicates that the value should be +/// serialized as structured JSON content with an associated schema. +/// The framework will place the JSON in the `structured_content` field +/// of the tool result rather than the regular `content` field. +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct Json(pub T); + +// Implement JsonSchema for Json to delegate to T's schema +impl JsonSchema for Json { + fn schema_name() -> Cow<'static, str> { + T::schema_name() + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + T::json_schema(generator) + } +} + +// Implementation for Json to create structured content +impl IntoCallToolResult for Json { + fn into_call_tool_result(self) -> Result { + let value = serde_json::to_value(self.0).map_err(|e| { + crate::ErrorData::internal_error( + format!("Failed to serialize structured content: {}", e), + None, + ) + })?; + + Ok(CallToolResult::structured(value).into()) + } +} diff --git a/vendor/rmcp/src/handler/server/wrapper/parameters.rs b/vendor/rmcp/src/handler/server/wrapper/parameters.rs new file mode 100644 index 000000000..ab3eb0ce1 --- /dev/null +++ b/vendor/rmcp/src/handler/server/wrapper/parameters.rs @@ -0,0 +1,56 @@ +use schemars::JsonSchema; + +/// Parameter extractor for tools and prompts +/// +/// When used in tool and prompt handlers, this wrapper extracts and deserializes +/// parameters from the incoming request. The framework will automatically parse +/// the JSON arguments from tool calls or prompt arguments and deserialize them +/// into the specified type `P`. +/// +/// The `#[serde(transparent)]` attribute ensures that the wrapper doesn't add +/// an extra layer in the JSON structure - it directly delegates serialization +/// and deserialization to the inner type `P`. +/// +/// # Usage +/// +/// Use `Parameters` as a parameter in your tool or prompt handler functions: +/// +/// ```rust +/// # use rmcp::handler::server::wrapper::Parameters; +/// # use schemars::JsonSchema; +/// # use serde::{Deserialize, Serialize}; +/// #[derive(Deserialize, JsonSchema)] +/// struct CalculationRequest { +/// operation: String, +/// a: f64, +/// b: f64, +/// } +/// +/// // In a tool handler +/// async fn calculate(params: Parameters) -> Result { +/// let request = params.0; // Extract the inner value +/// match request.operation.as_str() { +/// "add" => Ok((request.a + request.b).to_string()), +/// _ => Err("Unknown operation".to_string()), +/// } +/// } +/// ``` +/// +/// The framework handles the extraction automatically: +/// - For tools: Parses the `arguments` field from tool call requests +/// - For prompts: Parses the `arguments` field from prompt requests +/// - Returns appropriate error responses if deserialization fails +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(transparent)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct Parameters

(pub P); + +impl JsonSchema for Parameters

{ + fn schema_name() -> std::borrow::Cow<'static, str> { + P::schema_name() + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + P::json_schema(generator) + } +} diff --git a/vendor/rmcp/src/lib.rs b/vendor/rmcp/src/lib.rs new file mode 100644 index 000000000..3be6616ed --- /dev/null +++ b/vendor/rmcp/src/lib.rs @@ -0,0 +1,44 @@ +#![cfg_attr(docsrs, feature(doc_cfg))] +#![cfg_attr(docsrs, allow(unused_attributes))] +#![doc = include_str!("../README.md")] + +mod error; +pub use error::{ErrorData, RmcpError}; + +/// Basic data types in MCP specification +pub mod model; +#[cfg(any(feature = "client", feature = "server"))] +pub mod service; +#[cfg(feature = "client")] +pub use handler::client::ClientHandler; +#[cfg(feature = "server")] +pub use handler::server::ServerHandler; +#[cfg(feature = "server")] +pub use handler::server::wrapper::Json; +#[cfg(feature = "client")] +pub use service::{ + ClientCacheConfig, ClientLifecycleMode, ClientServiceExt, MAX_CLIENT_CACHE_TTL, RoleClient, + select_protocol_version, serve_client, serve_client_with_lifecycle, +}; +#[cfg(any(feature = "client", feature = "server"))] +pub use service::{Peer, Service, ServiceError, ServiceExt}; +#[cfg(feature = "server")] +pub use service::{RoleServer, serve_server}; + +pub mod handler; +#[cfg(feature = "server")] +pub mod task_manager; +#[cfg(any(feature = "client", feature = "server"))] +pub mod transport; + +// re-export +#[cfg(all(feature = "macros", feature = "server"))] +pub use pastey::paste; +#[cfg(all(feature = "macros", feature = "server"))] +pub use rmcp_macros::*; +#[cfg(feature = "schemars")] +pub use schemars; +#[cfg(feature = "macros")] +pub use serde; +#[cfg(feature = "macros")] +pub use serde_json; diff --git a/vendor/rmcp/src/model.rs b/vendor/rmcp/src/model.rs new file mode 100644 index 000000000..cf08d7fbb --- /dev/null +++ b/vendor/rmcp/src/model.rs @@ -0,0 +1,5326 @@ +// Internal references to the SEP-2577-deprecated Roots/Sampling/Logging types +// defined in this module are expected; the deprecation is advisory for downstream users. +#![expect(deprecated)] +use std::{ + borrow::Cow, + collections::hash_map::RandomState, + hash::{BuildHasher, Hasher}, + ops::{Deref, DerefMut}, + sync::{Arc, OnceLock}, +}; +mod annotated; +mod capabilities; +mod content; +mod elicitation_schema; +mod extension; +mod meta; +mod mrtr; +mod prompt; +#[cfg(feature = "request-state")] +mod request_state; +mod resource; +mod serde_impl; +mod task; +mod tool; +pub use annotated::*; +pub use capabilities::*; +pub use content::*; +pub use elicitation_schema::*; +pub use extension::*; +pub use meta::*; +pub use mrtr::*; +pub use prompt::*; +#[cfg(feature = "request-state")] +pub use request_state::*; +pub use resource::*; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde_json::Value; +pub use task::*; +pub use tool::*; + +/// A JSON object type alias for convenient handling of JSON data. +/// +/// You can use [`crate::object!`] or [`crate::model::object`] to create a json object quickly. +/// This is commonly used for storing arbitrary JSON data in MCP messages. +pub type JsonObject = serde_json::Map; + +/// unwrap the JsonObject under [`serde_json::Value`] +/// +/// # Panic +/// This will panic when the value is not a object in debug mode. +pub fn object(value: serde_json::Value) -> JsonObject { + debug_assert!(value.is_object()); + match value { + serde_json::Value::Object(map) => map, + _ => JsonObject::default(), + } +} + +/// Use this macro just like [`serde_json::json!`] +#[cfg(feature = "macros")] +#[macro_export] +macro_rules! object { + ({$($tt:tt)*}) => { + $crate::model::object(serde_json::json! { + {$($tt)*} + }) + }; +} + +/// This is commonly used for representing empty objects in MCP messages. +/// +/// without returning any specific data. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Copy, Eq)] +#[serde(deny_unknown_fields)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct EmptyObject {} + +pub trait ConstString: Default { + const VALUE: &str; + fn as_str(&self) -> &'static str { + Self::VALUE + } +} +#[macro_export] +macro_rules! const_string { + ($name:ident = $value:literal) => { + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] + #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] + pub struct $name; + + impl ConstString for $name { + const VALUE: &str = $value; + } + + impl serde::Serialize for $name { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + $value.serialize(serializer) + } + } + + impl<'de> serde::Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result<$name, D::Error> + where + D: serde::Deserializer<'de>, + { + let s: String = serde::Deserialize::deserialize(deserializer)?; + if s == $value { + Ok($name) + } else { + Err(serde::de::Error::custom(format!(concat!( + "expect const string value \"", + $value, + "\"" + )))) + } + } + } + + #[cfg(feature = "schemars")] + impl schemars::JsonSchema for $name { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed(stringify!($name)) + } + + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + use serde_json::{Map, json}; + + let mut schema_map = Map::new(); + schema_map.insert("type".to_string(), json!("string")); + schema_map.insert("format".to_string(), json!("const")); + schema_map.insert("const".to_string(), json!($value)); + + schemars::Schema::from(schema_map) + } + } + }; +} + +const_string!(JsonRpcVersion2_0 = "2.0"); + +// ============================================================================= +// CORE PROTOCOL TYPES +// ============================================================================= + +/// Represents the MCP protocol version used for communication. +/// +/// This ensures compatibility between clients and servers by specifying +/// which version of the Model Context Protocol is being used. +#[derive(Debug, Clone, Eq, PartialEq, Hash, PartialOrd)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct ProtocolVersion(Cow<'static, str>); + +impl Default for ProtocolVersion { + fn default() -> Self { + Self::LATEST + } +} + +impl std::fmt::Display for ProtocolVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl ProtocolVersion { + pub const V_2026_07_28: Self = Self(Cow::Borrowed("2026-07-28")); + pub const V_2025_11_25: Self = Self(Cow::Borrowed("2025-11-25")); + pub const V_2025_06_18: Self = Self(Cow::Borrowed("2025-06-18")); + pub const V_2025_03_26: Self = Self(Cow::Borrowed("2025-03-26")); + pub const V_2024_11_05: Self = Self(Cow::Borrowed("2024-11-05")); + pub const LATEST: Self = Self::V_2025_11_25; + + /// First protocol version that requires SEP-2243 standard HTTP headers. + pub const STANDARD_HEADERS: Self = Self::V_2026_07_28; + + /// All protocol versions known to this SDK. + pub const KNOWN_VERSIONS: &[Self] = &[ + Self::V_2024_11_05, + Self::V_2025_03_26, + Self::V_2025_06_18, + Self::V_2025_11_25, + Self::V_2026_07_28, + ]; + + /// Returns the string representation of this protocol version. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Serialize for ProtocolVersion { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.0.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ProtocolVersion { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s: String = Deserialize::deserialize(deserializer)?; + #[allow(clippy::single_match)] + match s.as_str() { + "2024-11-05" => return Ok(ProtocolVersion::V_2024_11_05), + "2025-03-26" => return Ok(ProtocolVersion::V_2025_03_26), + "2025-06-18" => return Ok(ProtocolVersion::V_2025_06_18), + "2025-11-25" => return Ok(ProtocolVersion::V_2025_11_25), + "2026-07-28" => return Ok(ProtocolVersion::V_2026_07_28), + _ => {} + } + Ok(ProtocolVersion(Cow::Owned(s))) + } +} + +/// A flexible identifier type that can be either a number or a string. +/// +/// This is commonly used for request IDs and other identifiers in JSON-RPC +/// where the specification allows both numeric and string values. +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] +pub enum NumberOrString { + /// A numeric identifier + Number(i64), + /// A string identifier + String(Arc), +} + +impl NumberOrString { + pub fn into_json_value(self) -> Value { + match self { + NumberOrString::Number(n) => Value::Number(serde_json::Number::from(n)), + NumberOrString::String(s) => Value::String(s.to_string()), + } + } + + pub(crate) fn numeric_string_value(&self) -> Option { + match self { + Self::String(id) => id.parse().ok(), + Self::Number(_) => None, + } + } + + pub(crate) fn matches_response_id(&self, response_id: &Self) -> bool { + self == response_id + || matches!( + self, + Self::Number(request_id) + if response_id.numeric_string_value() == Some(*request_id) + ) + } +} + +impl std::fmt::Display for NumberOrString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + NumberOrString::Number(n) => n.fmt(f), + NumberOrString::String(s) => s.fmt(f), + } + } +} + +impl Serialize for NumberOrString { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + NumberOrString::Number(n) => n.serialize(serializer), + NumberOrString::String(s) => s.serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for NumberOrString { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value: Value = Deserialize::deserialize(deserializer)?; + match value { + Value::Number(n) => { + if let Some(i) = n.as_i64() { + Ok(NumberOrString::Number(i)) + } else if let Some(u) = n.as_u64() { + // Handle large unsigned numbers that fit in i64 + if u <= i64::MAX as u64 { + Ok(NumberOrString::Number(u as i64)) + } else { + Err(serde::de::Error::custom("Number too large for i64")) + } + } else { + Err(serde::de::Error::custom("Expected an integer")) + } + } + Value::String(s) => Ok(NumberOrString::String(s.into())), + _ => Err(serde::de::Error::custom("Expect number or string")), + } + } +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for NumberOrString { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("NumberOrString") + } + + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + use serde_json::{Map, json}; + + let mut number_schema = Map::new(); + number_schema.insert("type".to_string(), json!("number")); + + let mut string_schema = Map::new(); + string_schema.insert("type".to_string(), json!("string")); + + let mut schema_map = Map::new(); + schema_map.insert("oneOf".to_string(), json!([number_schema, string_schema])); + + schemars::Schema::from(schema_map) + } +} + +/// Type alias for request identifiers used in JSON-RPC communication. +pub type RequestId = NumberOrString; + +/// A token used to track the progress of long-running operations. +/// +/// Progress tokens allow clients and servers to associate progress notifications +/// with specific requests, enabling real-time updates on operation status. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Hash, Eq)] +#[serde(transparent)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct ProgressToken(pub NumberOrString); + +// ============================================================================= +// JSON-RPC MESSAGE STRUCTURES +// ============================================================================= + +/// Represents a JSON-RPC request with method, parameters, and extensions. +/// +/// This is the core structure for all MCP requests, containing: +/// - `method`: The name of the method being called +/// - `params`: The parameters for the method +/// - `extensions`: Additional context data (similar to HTTP headers) +#[derive(Debug, Clone, Default)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct Request { + pub method: M, + pub params: P, + /// extensions will carry anything possible in the context, including the metadata + /// ([`RequestMetaObject`] for requests, [`NotificationMetaObject`] for notifications) + /// + /// this is similar with the Extensions in `http` crate + #[cfg_attr(feature = "schemars", schemars(skip))] + pub extensions: Extensions, +} + +impl Request { + pub fn new(params: P) -> Self { + Self { + method: Default::default(), + params, + extensions: Extensions::default(), + } + } +} + +impl GetExtensions for Request { + fn extensions(&self) -> &Extensions { + &self.extensions + } + fn extensions_mut(&mut self) -> &mut Extensions { + &mut self.extensions + } +} + +#[derive(Debug, Clone, Default)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct RequestOptionalParam { + pub method: M, + // #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option

, + /// extensions will carry anything possible in the context, including the metadata + /// ([`RequestMetaObject`] for requests, [`NotificationMetaObject`] for notifications) + /// + /// this is similar with the Extensions in `http` crate + #[cfg_attr(feature = "schemars", schemars(skip))] + pub extensions: Extensions, +} + +impl RequestOptionalParam { + pub fn with_param(params: P) -> Self { + Self { + method: Default::default(), + params: Some(params), + extensions: Extensions::default(), + } + } +} + +#[derive(Debug, Clone, Default)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct RequestNoParam { + pub method: M, + /// extensions will carry anything possible in the context, including the metadata + /// ([`RequestMetaObject`] for requests, [`NotificationMetaObject`] for notifications) + /// + /// this is similar with the Extensions in `http` crate + #[cfg_attr(feature = "schemars", schemars(skip))] + pub extensions: Extensions, +} + +impl GetExtensions for RequestNoParam { + fn extensions(&self) -> &Extensions { + &self.extensions + } + fn extensions_mut(&mut self) -> &mut Extensions { + &mut self.extensions + } +} +#[derive(Debug, Clone, Default)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct Notification { + pub method: M, + pub params: P, + /// extensions will carry anything possible in the context, including the metadata + /// ([`RequestMetaObject`] for requests, [`NotificationMetaObject`] for notifications) + /// + /// this is similar with the Extensions in `http` crate + #[cfg_attr(feature = "schemars", schemars(skip))] + pub extensions: Extensions, +} + +impl Notification { + pub fn new(params: P) -> Self { + Self { + method: Default::default(), + params, + extensions: Extensions::default(), + } + } +} + +#[derive(Debug, Clone, Default)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct NotificationNoParam { + pub method: M, + /// extensions will carry anything possible in the context, including the metadata + /// ([`RequestMetaObject`] for requests, [`NotificationMetaObject`] for notifications) + /// + /// this is similar with the Extensions in `http` crate + #[cfg_attr(feature = "schemars", schemars(skip))] + pub extensions: Extensions, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct JsonRpcRequest { + pub jsonrpc: JsonRpcVersion2_0, + pub id: RequestId, + #[serde(flatten)] + pub request: R, +} + +impl JsonRpcRequest { + /// Create a new JsonRpcRequest. + pub fn new(id: RequestId, request: R) -> Self { + Self { + jsonrpc: JsonRpcVersion2_0, + id, + request, + } + } +} + +type DefaultResponse = JsonObject; +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct JsonRpcResponse { + pub jsonrpc: JsonRpcVersion2_0, + pub id: RequestId, + pub result: R, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct JsonRpcError { + pub jsonrpc: JsonRpcVersion2_0, + // MCP 2026-07-28 §Error Responses: `id` is optional and omitted when the + // server cannot read the request id (e.g. parse error / invalid request). + // https://modelcontextprotocol.io/specification/2026-07-28/basic#error-responses + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + pub error: ErrorData, +} + +impl JsonRpcError { + /// Create a new JsonRpcError. + pub fn new(id: Option, error: ErrorData) -> Self { + Self { + jsonrpc: JsonRpcVersion2_0, + id, + error, + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct JsonRpcNotification { + pub jsonrpc: JsonRpcVersion2_0, + #[serde(flatten)] + pub notification: N, +} + +/// Standard JSON-RPC error codes used throughout the MCP protocol. +/// +/// These codes follow the JSON-RPC 2.0 specification and provide +/// standardized error reporting across all MCP implementations. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(transparent)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct ErrorCode(pub i32); + +impl ErrorCode { + /// The request used a protocol version the server does not support. + pub const UNSUPPORTED_PROTOCOL_VERSION: Self = Self(-32022); + /// Processing the request requires a client capability that was not declared. + pub const MISSING_REQUIRED_CLIENT_CAPABILITY: Self = Self(-32021); + pub const HEADER_MISMATCH: Self = Self(-32020); + pub const RESOURCE_NOT_FOUND: Self = Self(-32002); + pub const INVALID_REQUEST: Self = Self(-32600); + pub const METHOD_NOT_FOUND: Self = Self(-32601); + pub const INVALID_PARAMS: Self = Self(-32602); + pub const INTERNAL_ERROR: Self = Self(-32603); + pub const PARSE_ERROR: Self = Self(-32700); +} + +/// Error information for JSON-RPC error responses. +/// +/// This structure follows the JSON-RPC 2.0 specification for error reporting, +/// providing a standardized way to communicate errors between clients and servers. +#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct ErrorData { + /// The error type that occurred (using standard JSON-RPC error codes) + pub code: ErrorCode, + + /// A short description of the error. The message SHOULD be limited to a concise single sentence. + pub message: Cow<'static, str>, + + /// Additional information about the error. The value of this member is defined by the + /// sender (e.g. detailed error information, nested errors etc.). + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl ErrorData { + const TRANSPORT_CLOSED_MARKER: &str = "io.modelcontextprotocol/transportClosed"; + + pub fn new( + code: ErrorCode, + message: impl Into>, + data: Option, + ) -> Self { + Self { + code, + message: message.into(), + data, + } + } + /// Resource-not-found error (`-32002`). The server upgrades this to `INVALID_PARAMS` + /// (`-32602`) for peers negotiating protocol `2026-07-28` or newer (SEP-2164). + pub fn resource_not_found(message: impl Into>, data: Option) -> Self { + Self::new(ErrorCode::RESOURCE_NOT_FOUND, message, data) + } + pub fn header_mismatch(message: impl Into>, data: Option) -> Self { + Self::new(ErrorCode::HEADER_MISMATCH, message, data) + } + /// Create an unsupported-protocol-version error. + pub fn unsupported_protocol_version( + requested: ProtocolVersion, + supported: &[ProtocolVersion], + ) -> Self { + Self::new( + ErrorCode::UNSUPPORTED_PROTOCOL_VERSION, + "Unsupported protocol version", + Some(serde_json::json!({ + "requested": requested, + "supported": supported, + })), + ) + } + /// Create a missing-required-capability error. + pub fn missing_required_client_capability(required: ClientCapabilities) -> Self { + Self::new( + ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY, + "Missing required client capability", + Some(serde_json::json!({ + "requiredCapabilities": required, + })), + ) + } + pub fn parse_error(message: impl Into>, data: Option) -> Self { + Self::new(ErrorCode::PARSE_ERROR, message, data) + } + pub fn invalid_request(message: impl Into>, data: Option) -> Self { + Self::new(ErrorCode::INVALID_REQUEST, message, data) + } + pub fn method_not_found() -> Self { + Self::new(ErrorCode::METHOD_NOT_FOUND, M::VALUE, None) + } + pub fn invalid_params(message: impl Into>, data: Option) -> Self { + Self::new(ErrorCode::INVALID_PARAMS, message, data) + } + pub fn internal_error(message: impl Into>, data: Option) -> Self { + Self::new(ErrorCode::INTERNAL_ERROR, message, data) + } + + #[cfg(feature = "transport-streamable-http-client")] + pub(crate) fn transport_closed(message: impl Into>) -> Self { + let mut data = JsonObject::new(); + data.insert( + Self::TRANSPORT_CLOSED_MARKER.to_owned(), + Value::from(Self::transport_closed_token()), + ); + Self::internal_error(message, Some(Value::Object(data))) + } + + pub(crate) fn is_transport_closed(&self) -> bool { + self.data + .as_ref() + .and_then(|data| data.get(Self::TRANSPORT_CLOSED_MARKER)) + .and_then(Value::as_u64) + == Some(Self::transport_closed_token()) + } + + fn transport_closed_token() -> u64 { + static TOKEN: OnceLock = OnceLock::new(); + *TOKEN.get_or_init(|| { + let mut hasher = RandomState::new().build_hasher(); + hasher.write(b"rmcp transport-closed marker"); + hasher.finish() + }) + } +} + +/// Represents any JSON-RPC message that can be sent or received. +/// +/// This enum covers all possible message types in the JSON-RPC protocol: +/// individual requests/responses, notifications, and errors. +/// It serves as the top-level message container for MCP communication. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(untagged)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] +pub enum JsonRpcMessage { + /// A single request expecting a response + Request(JsonRpcRequest), + /// A response to a previous request + Response(JsonRpcResponse), + /// A one-way notification (no response expected) + Notification(JsonRpcNotification), + /// An error response + Error(JsonRpcError), +} + +impl JsonRpcMessage { + #[inline] + pub const fn request(request: Req, id: RequestId) -> Self { + JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: JsonRpcVersion2_0, + id, + request, + }) + } + #[inline] + pub const fn response(response: Resp, id: RequestId) -> Self { + JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: JsonRpcVersion2_0, + id, + result: response, + }) + } + #[inline] + pub const fn error(error: ErrorData, id: Option) -> Self { + JsonRpcMessage::Error(JsonRpcError { + jsonrpc: JsonRpcVersion2_0, + id, + error, + }) + } + #[inline] + pub const fn notification(notification: Not) -> Self { + JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: JsonRpcVersion2_0, + notification, + }) + } + pub fn into_request(self) -> Option<(Req, RequestId)> { + match self { + JsonRpcMessage::Request(r) => Some((r.request, r.id)), + _ => None, + } + } + pub fn into_response(self) -> Option<(Resp, RequestId)> { + match self { + JsonRpcMessage::Response(r) => Some((r.result, r.id)), + _ => None, + } + } + pub fn into_notification(self) -> Option { + match self { + JsonRpcMessage::Notification(n) => Some(n.notification), + _ => None, + } + } + pub fn into_error(self) -> Option<(ErrorData, Option)> { + match self { + JsonRpcMessage::Error(e) => Some((e.error, e.id)), + _ => None, + } + } + pub fn into_result(self) -> Option<(Result, Option)> { + match self { + JsonRpcMessage::Response(r) => Some((Ok(r.result), Some(r.id))), + JsonRpcMessage::Error(e) => Some((Err(e.error), e.id)), + + _ => None, + } + } +} + +// ============================================================================= +// INITIALIZATION AND CONNECTION SETUP +// ============================================================================= + +/// # Empty result +/// A response that indicates success but carries no data. +pub type EmptyResult = EmptyObject; + +impl From<()> for EmptyResult { + fn from(_value: ()) -> Self { + EmptyResult {} + } +} + +impl From for () { + fn from(_value: EmptyResult) {} +} + +/// Indicates the type of a result object, allowing the client to +/// determine how to parse the response. +/// +/// The spec defines this as an open string (`"complete" | "input_required" | string`), +/// so unknown values are preserved rather than rejected. Servers implementing this +/// protocol version MUST include `resultType` in every result. For backward +/// compatibility, clients MUST treat an absent field as `"complete"`. +/// +/// Ordinary results model the field as `Option`: `None` means the +/// field is absent on the wire. Constructors default to `Some(COMPLETE)`, and +/// the server handler strips the `"complete"` discriminator before responding +/// to peers that negotiated a protocol version older than `2026-07-28`, so +/// legacy sessions keep their historical wire shape (see +/// [`ServerResult::strip_result_type_for_legacy_peer`]). +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct ResultType(Cow<'static, str>); + +impl ResultType { + pub const COMPLETE: Self = Self(Cow::Borrowed("complete")); + pub const INPUT_REQUIRED: Self = Self(Cow::Borrowed("input_required")); + /// SEP-2663 Tasks extension: the result is a task handle ([`CreateTaskResult`]). + pub const TASK: Self = Self(Cow::Borrowed("task")); + + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Returns `true` if this is `"input_required"`. + pub fn is_input_required(&self) -> bool { + self.0 == "input_required" + } + + /// Returns `true` if this is `"complete"`. + pub fn is_complete(&self) -> bool { + self.0 == "complete" + } + + /// Returns `true` if this is `"task"` (SEP-2663 Tasks extension). + pub fn is_task(&self) -> bool { + self.0 == "task" + } +} + +impl Default for ResultType { + fn default() -> Self { + Self::COMPLETE + } +} + +impl Serialize for ResultType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.0.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ResultType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s: String = Deserialize::deserialize(deserializer)?; + match s.as_str() { + "complete" => Ok(Self::COMPLETE), + "input_required" => Ok(Self::INPUT_REQUIRED), + _ => Ok(Self(Cow::Owned(s))), + } + } +} + +impl std::fmt::Display for ResultType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +/// A catch-all response either side can use for custom requests. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(transparent)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct CustomResult(pub Value); + +impl CustomResult { + pub fn new(result: Value) -> Self { + Self(result) + } + + /// Deserialize the result into a strongly-typed structure. + pub fn result_as(&self) -> Result { + serde_json::from_value(self.0.clone()) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct CancelledNotificationParam { + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl CancelledNotificationParam { + pub fn new(request_id: Option, reason: Option) -> Self { + Self { + request_id, + reason, + meta: None, + } + } +} + +const_string!(CancelledNotificationMethod = "notifications/cancelled"); + +/// # Cancellation +/// This notification can be sent by either side to indicate that it is cancelling a previously-issued request. +/// +/// The request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished. +/// +/// This notification indicates that the result will be unused, so any associated processing SHOULD cease. +/// +/// A client MUST NOT attempt to cancel its `initialize` request. +pub type CancelledNotification = + Notification; + +/// A catch-all notification either side can use to send custom messages to its peer. +/// +/// This preserves the raw `method` name and `params` payload so handlers can +/// deserialize them into domain-specific types. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct CustomNotification { + pub method: String, + pub params: Option, + /// extensions will carry anything possible in the context, including the metadata + /// ([`RequestMetaObject`] for requests, [`NotificationMetaObject`] for notifications) + /// + /// this is similar with the Extensions in `http` crate + #[cfg_attr(feature = "schemars", schemars(skip))] + pub extensions: Extensions, +} + +impl CustomNotification { + pub fn new(method: impl Into, params: Option) -> Self { + Self { + method: method.into(), + params, + extensions: Extensions::default(), + } + } + + /// Deserialize `params` into a strongly-typed structure. + pub fn params_as(&self) -> Result, serde_json::Error> { + self.params + .as_ref() + .map(|params| serde_json::from_value(params.clone())) + .transpose() + } +} + +/// A catch-all request either side can use to send custom messages to its peer. +/// +/// This preserves the raw `method` name and `params` payload so handlers can +/// deserialize them into domain-specific types. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct CustomRequest { + pub method: String, + pub params: Option, + /// extensions will carry anything possible in the context, including the metadata + /// ([`RequestMetaObject`] for requests, [`NotificationMetaObject`] for notifications) + /// + /// this is similar with the Extensions in `http` crate + #[cfg_attr(feature = "schemars", schemars(skip))] + pub extensions: Extensions, +} + +impl CustomRequest { + pub fn new(method: impl Into, params: Option) -> Self { + Self { + method: method.into(), + params, + extensions: Extensions::default(), + } + } + + /// Deserialize `params` into a strongly-typed structure. + pub fn params_as(&self) -> Result, serde_json::Error> { + self.params + .as_ref() + .map(|params| serde_json::from_value(params.clone())) + .transpose() + } +} + +const_string!(InitializeResultMethod = "initialize"); +/// # Initialization +/// This request is sent from the client to the server when it first connects, asking it to begin initialization. +pub type InitializeRequest = Request; + +const_string!(InitializedNotificationMethod = "notifications/initialized"); +/// This notification is sent from the client to the server after initialization has finished. +pub type InitializedNotification = NotificationNoParam; + +/// Parameters sent by a client when initializing a connection to an MCP server. +/// +/// This contains the client's protocol version, capabilities, and implementation +/// information, allowing the server to understand what the client supports. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct InitializeRequestParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// The MCP protocol version this client supports + pub protocol_version: ProtocolVersion, + /// The capabilities this client supports (sampling, roots, etc.) + pub capabilities: ClientCapabilities, + /// Information about the client implementation + pub client_info: Implementation, +} + +impl InitializeRequestParams { + /// Create a new InitializeRequestParams. + pub fn new(capabilities: ClientCapabilities, client_info: Implementation) -> Self { + Self { + meta: None, + protocol_version: ProtocolVersion::default(), + capabilities, + client_info, + } + } + + pub fn with_protocol_version(mut self, protocol_version: ProtocolVersion) -> Self { + self.protocol_version = protocol_version; + self + } +} + +impl RequestParamsMeta for InitializeRequestParams { + fn meta(&self) -> Option<&RequestMetaObject> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// The server's response to an initialization request. +/// +/// Contains the server's protocol version, capabilities, and implementation +/// information, along with optional instructions for the client. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct InitializeResult { + /// The MCP protocol version this server supports + pub protocol_version: ProtocolVersion, + /// The capabilities this server provides (tools, resources, prompts, etc.) + pub capabilities: ServerCapabilities, + /// Information about the server implementation + pub server_info: Implementation, + /// Optional human-readable instructions about using this server + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl InitializeResult { + /// Create a new `InitializeResult` with default protocol version and the given capabilities. + pub fn new(capabilities: ServerCapabilities) -> Self { + Self { + protocol_version: ProtocolVersion::default(), + capabilities, + server_info: Implementation::from_build_env(), + instructions: None, + meta: None, + } + } + + /// Set instructions on this result. + pub fn with_instructions(mut self, instructions: impl Into) -> Self { + self.instructions = Some(instructions.into()); + self + } + + /// Set the server info on this result. + pub fn with_server_info(mut self, server_info: Implementation) -> Self { + self.server_info = server_info; + self + } + + /// Set the protocol version on this result. + pub fn with_protocol_version(mut self, protocol_version: ProtocolVersion) -> Self { + self.protocol_version = protocol_version; + self + } +} + +pub type ServerInfo = InitializeResult; +pub type ClientInfo = InitializeRequestParams; + +/// Information negotiated about a server peer. +/// +/// Unlike [`InitializeResult`], the server implementation identity is optional +/// because discovery responses are not required to provide it. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct ServerPeerInfo { + /// The negotiated MCP protocol version. + pub protocol_version: ProtocolVersion, + /// The capabilities this server provides. + pub capabilities: ServerCapabilities, + /// Information about the server implementation, when provided. + #[serde(skip_serializing_if = "Option::is_none")] + pub server_info: Option, + /// Optional human-readable instructions about using this server. + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, + /// Protocol-level response metadata. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl ServerPeerInfo { + /// Create peer information without a server implementation identity. + pub fn new(protocol_version: ProtocolVersion, capabilities: ServerCapabilities) -> Self { + Self { + protocol_version, + capabilities, + server_info: None, + instructions: None, + meta: None, + } + } + + /// Set the server implementation identity. + pub fn with_server_info(mut self, server_info: Implementation) -> Self { + self.server_info = Some(server_info); + self + } + + /// Set instructions supplied by the server. + pub fn with_instructions(mut self, instructions: impl Into) -> Self { + self.instructions = Some(instructions.into()); + self + } +} + +impl From for ServerPeerInfo { + fn from(result: InitializeResult) -> Self { + Self { + protocol_version: result.protocol_version, + capabilities: result.capabilities, + server_info: Some(result.server_info), + instructions: result.instructions, + meta: result.meta, + } + } +} + +const_string!(DiscoverRequestMethod = "server/discover"); + +/// Parameters for [`DiscoverRequest`]. +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)] +#[serde(deny_unknown_fields)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct DiscoverRequestParams {} + +#[cfg(feature = "schemars")] +#[derive(schemars::JsonSchema)] +#[expect(dead_code, reason = "schema-only representation of request parameters")] +struct DiscoverRequestParamsSchema { + #[schemars(rename = "_meta")] + meta: RequestMetaObject, +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for DiscoverRequestParams { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("DiscoverRequestParams") + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + DiscoverRequestParamsSchema::json_schema(generator) + } +} + +/// A request for the server's supported protocol versions and capabilities. +pub type DiscoverRequest = Request; + +/// The server's response to a [`DiscoverRequest`]. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct DiscoverResult { + /// Identifies how the result should be parsed. + pub result_type: ResultType, + /// Protocol versions implemented by this server. + pub supported_versions: Vec, + /// Capabilities provided by this server. + pub capabilities: ServerCapabilities, + /// Optional guidance for using the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, + /// How long clients may consider this response fresh, in milliseconds. + pub ttl_ms: u64, + /// Whether the cached result may be shared across authorization contexts. + pub cache_scope: CacheScope, + /// Protocol-level response metadata. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +const SERVER_INFO_META_KEY: &str = "io.modelcontextprotocol/serverInfo"; + +fn server_info_from_meta(meta: &MetaObject) -> Option { + meta.get(SERVER_INFO_META_KEY) + .and_then(|value| serde_json::from_value(value.clone()).ok()) +} + +fn set_server_info_on_meta(meta: &mut MetaObject, server_info: Implementation) { + let server_info = + serde_json::to_value(server_info).expect("Implementation serialization cannot fail"); + meta.insert(SERVER_INFO_META_KEY.to_owned(), server_info); +} + +impl DiscoverResult { + /// Create a non-cacheable private discovery result. + pub fn new(supported_versions: Vec, capabilities: ServerCapabilities) -> Self { + Self { + result_type: ResultType::COMPLETE, + supported_versions, + capabilities, + instructions: None, + ttl_ms: 0, + cache_scope: CacheScope::Private, + meta: None, + } + } + + /// Return the server implementation information stored in result metadata. + pub fn server_info(&self) -> Option { + server_info_from_meta(self.meta.as_ref()?) + } + + /// Store server implementation information in result metadata. + pub fn set_server_info(&mut self, server_info: Implementation) { + set_server_info_on_meta(self.meta.get_or_insert_default(), server_info); + } + + /// Store server implementation information in result metadata. + pub fn with_server_info(mut self, server_info: Implementation) -> Self { + self.set_server_info(server_info); + self + } + + /// Create a discovery result from the server's initialization information. + pub fn from_server_info( + supported_versions: Vec, + server_info: ServerInfo, + ) -> Self { + let ServerInfo { + capabilities, + server_info, + instructions, + meta, + .. + } = server_info; + let mut result = Self { + result_type: ResultType::COMPLETE, + supported_versions, + capabilities, + instructions, + ttl_ms: 0, + cache_scope: CacheScope::Private, + meta, + }; + result.set_server_info(server_info); + result + } + + /// Set the cache lifetime hint in milliseconds. + pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self { + self.ttl_ms = ttl_ms; + self + } + + /// Set the cache scope. + pub fn with_cache_scope(mut self, cache_scope: CacheScope) -> Self { + self.cache_scope = cache_scope; + self + } +} + +impl ServerPeerInfo { + /// Create peer information from a discovery result and the selected version. + pub fn from_discover_result(protocol_version: ProtocolVersion, result: DiscoverResult) -> Self { + let server_info = result.server_info(); + Self { + protocol_version, + capabilities: result.capabilities, + server_info, + instructions: result.instructions, + meta: result.meta, + } + } +} + +#[allow(clippy::derivable_impls)] +impl Default for ServerInfo { + fn default() -> Self { + ServerInfo { + protocol_version: ProtocolVersion::default(), + capabilities: ServerCapabilities::default(), + server_info: Implementation::from_build_env(), + instructions: None, + meta: None, + } + } +} + +#[allow(clippy::derivable_impls)] +impl Default for ClientInfo { + fn default() -> Self { + ClientInfo { + meta: None, + protocol_version: ProtocolVersion::default(), + capabilities: ClientCapabilities::default(), + client_info: Implementation::from_build_env(), + } + } +} + +/// Icon themes supported by the MCP specification +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash, Copy)] +#[serde(rename_all = "lowercase")] //match spec +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub enum IconTheme { + /// Indicates the icon is designed to be used with a light background + Light, + /// Indicates the icon is designed to be used with a dark background + Dark, +} + +/// A URL pointing to an icon resource or a base64-encoded data URI. +/// +/// Clients that support rendering icons MUST support at least the following MIME types: +/// - image/png - PNG images (safe, universal compatibility) +/// - image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility) +/// +/// Clients that support rendering icons SHOULD also support: +/// - image/svg+xml - SVG images (scalable but requires security precautions) +/// - image/webp - WebP images (modern, efficient format) +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct Icon { + /// A standard URI pointing to an icon resource + pub src: String, + /// Optional override if the server's MIME type is missing or generic + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + /// Size specification, each string should be in WxH format (e.g., `\"48x48\"`, `\"96x96\"`) or `\"any\"` for scalable formats like SVG + #[serde(skip_serializing_if = "Option::is_none")] + pub sizes: Option>, + /// Optional specifier for the theme this icon is designed for + /// If not provided, the client should assume the icon can be used with any theme. + #[serde(skip_serializing_if = "Option::is_none")] + pub theme: Option, +} + +impl Icon { + /// Create a new Icon with the given source URL. + pub fn new(src: impl Into) -> Self { + Self { + src: src.into(), + mime_type: None, + sizes: None, + theme: None, + } + } + + /// Set the MIME type. + pub fn with_mime_type(mut self, mime_type: impl Into) -> Self { + self.mime_type = Some(mime_type.into()); + self + } + + /// Set the sizes. + pub fn with_sizes(mut self, sizes: Vec) -> Self { + self.sizes = Some(sizes); + self + } + + /// Set the theme. + pub fn with_theme(mut self, theme: IconTheme) -> Self { + self.theme = Some(theme); + self + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct Implementation { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + pub version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub icons: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub website_url: Option, +} + +impl Default for Implementation { + fn default() -> Self { + Self::from_build_env() + } +} + +impl Implementation { + /// Create a new Implementation. + pub fn new(name: impl Into, version: impl Into) -> Self { + Self { + name: name.into(), + title: None, + version: version.into(), + description: None, + icons: None, + website_url: None, + } + } + + pub fn from_build_env() -> Self { + Implementation { + name: env!("CARGO_CRATE_NAME").to_owned(), + title: None, + version: env!("CARGO_PKG_VERSION").to_owned(), + description: None, + icons: None, + website_url: None, + } + } + + /// Set the human-readable title. + pub fn with_title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + /// Set the description. + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + /// Set the icons. + pub fn with_icons(mut self, icons: Vec) -> Self { + self.icons = Some(icons); + self + } + + /// Set the website URL. + pub fn with_website_url(mut self, website_url: impl Into) -> Self { + self.website_url = Some(website_url.into()); + self + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct PaginatedRequestParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +impl PaginatedRequestParams { + pub fn with_cursor(mut self, cursor: Option) -> Self { + self.cursor = cursor; + self + } +} + +impl RequestParamsMeta for PaginatedRequestParams { + fn meta(&self) -> Option<&RequestMetaObject> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +// ============================================================================= +// PROGRESS AND PAGINATION +// ============================================================================= + +const_string!(PingRequestMethod = "ping"); +pub type PingRequest = RequestNoParam; + +const_string!(ProgressNotificationMethod = "notifications/progress"); +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct ProgressNotificationParam { + pub progress_token: ProgressToken, + /// The progress thus far. This should increase every time progress is made, even if the total is unknown. + pub progress: f64, + /// Total number of items to process (or total progress required), if known + #[serde(skip_serializing_if = "Option::is_none")] + pub total: Option, + /// An optional message describing the current progress. + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl ProgressNotificationParam { + /// Create a new ProgressNotificationParam with required fields. + pub fn new(progress_token: ProgressToken, progress: f64) -> Self { + Self { + progress_token, + progress, + total: None, + message: None, + meta: None, + } + } + + /// Set the total number of items to process. + pub fn with_total(mut self, total: f64) -> Self { + self.total = Some(total); + self + } + + /// Set a message describing the current progress. + pub fn with_message(mut self, message: impl Into) -> Self { + self.message = Some(message.into()); + self + } +} + +pub type ProgressNotification = Notification; + +pub type Cursor = String; + +/// Scope describing who may cache cacheable list/read results (SEP-2549). +/// +/// Defaults to [`CacheScope::Public`] when absent from the wire. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum CacheScope { + /// Any client or intermediary may cache and serve the response to any user. + #[default] + Public, + /// Only the requesting user's client may cache the response. + Private, +} + +/// Normalize a `ttlMs` value during deserialization. +/// +/// Per SEP-2549, `ttlMs` MUST be `>= 0`; if a server returns a negative value, +/// clients SHOULD treat it as `0` (immediately stale). This tolerates that case +/// rather than erroring, while still accepting an absent field as `None`. +fn deserialize_ttl_ms<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(value.map(|ttl_ms| ttl_ms.max(0) as u64)) +} + +macro_rules! paginated_result { + ($t:ident { + $i_item: ident: $t_item: ty + }) => { + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] + #[serde(rename_all = "camelCase")] + #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] + #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] + pub struct $t { + /// Result type discriminator (SEP-2322). Required by the [spec schema] + /// for servers implementing protocol version `2026-07-28`, but optional + /// here because this type also models results from older protocol + /// versions, which do not carry the field: `None` means absent on the + /// wire, and per the spec "the client MUST treat the absent field as + /// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`; + /// the server handler clears the field when responding to peers that + /// negotiated an older version. + /// + /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_type: Option, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + /// Time, in milliseconds, that this result may be treated as fresh (SEP-2549). + /// Required by spec version 2026-07-28, but optional here to maintain compatibility + /// with older spec versions. + #[serde( + default, + deserialize_with = "deserialize_ttl_ms", + skip_serializing_if = "Option::is_none" + )] + pub ttl_ms: Option, + /// Scope describing who may cache this result (SEP-2549). + /// Required by spec version 2026-07-28, but optional here to maintain compatibility + /// with older spec versions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_scope: Option, + pub $i_item: $t_item, + } + + impl Default for $t { + fn default() -> Self { + Self::with_all_items(Default::default()) + } + } + + impl $t { + pub fn with_all_items(items: $t_item) -> Self { + Self { + result_type: Some(ResultType::COMPLETE), + meta: None, + next_cursor: None, + ttl_ms: None, + cache_scope: None, + $i_item: items, + } + } + + /// Set the time, in milliseconds, that this result may be treated as fresh. + pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self { + self.ttl_ms = Some(ttl_ms); + self + } + + /// Set the cache scope for this result. + pub fn with_cache_scope(mut self, cache_scope: CacheScope) -> Self { + self.cache_scope = Some(cache_scope); + self + } + } + }; +} + +// ============================================================================= +// RESOURCE MANAGEMENT +// ============================================================================= + +const_string!(ListResourcesRequestMethod = "resources/list"); +/// Request to list all available resources from a server +pub type ListResourcesRequest = + RequestOptionalParam; + +paginated_result!(ListResourcesResult { + resources: Vec +}); + +const_string!(ListResourceTemplatesRequestMethod = "resources/templates/list"); +/// Request to list all available resource templates from a server +pub type ListResourceTemplatesRequest = + RequestOptionalParam; + +paginated_result!(ListResourceTemplatesResult { + resource_templates: Vec +}); + +const_string!(ReadResourceRequestMethod = "resources/read"); +/// Parameters for reading a specific resource +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct ReadResourceRequestParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// The URI of the resource to read + pub uri: String, + /// Client responses to server-initiated input requests from a previous + /// [`InputRequiredResult`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub input_responses: Option, + /// Opaque request state echoed back from a previous [`InputRequiredResult`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_state: Option, +} + +impl ReadResourceRequestParams { + /// Create a new ReadResourceRequestParams with the given URI. + pub fn new(uri: impl Into) -> Self { + Self { + meta: None, + uri: uri.into(), + input_responses: None, + request_state: None, + } + } + + /// Set the metadata for this request. + pub fn with_meta(mut self, meta: RequestMetaObject) -> Self { + self.meta = Some(meta); + self + } + + /// Sets the input responses for an MRTR retry. + pub fn with_input_responses(mut self, input_responses: InputResponses) -> Self { + self.input_responses = Some(input_responses); + self + } + + /// Sets the request state for an MRTR retry. + pub fn with_request_state(mut self, request_state: impl Into) -> Self { + self.request_state = Some(request_state.into()); + self + } +} + +impl RequestParamsMeta for ReadResourceRequestParams { + fn meta(&self) -> Option<&RequestMetaObject> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// Result containing the contents of a read resource +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct ReadResourceResult { + /// Result type discriminator (SEP-2322). Required by the [spec schema] + /// for servers implementing protocol version `2026-07-28`, but optional + /// here because this type also models results from older protocol + /// versions, which do not carry the field: `None` means absent on the + /// wire, and per the spec "the client MUST treat the absent field as + /// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`; + /// the server handler clears the field when responding to peers that + /// negotiated an older version. + /// + /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_type: Option, + /// Time, in milliseconds, that this result may be treated as fresh (SEP-2549). + /// Required by spec version 2026-07-28, but optional here to maintain compatibility + /// with older spec versions. + #[serde( + default, + deserialize_with = "deserialize_ttl_ms", + skip_serializing_if = "Option::is_none" + )] + pub ttl_ms: Option, + /// Scope describing who may cache this result (SEP-2549). + /// Required by spec version 2026-07-28, but optional here to maintain compatibility + /// with older spec versions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_scope: Option, + /// The actual content of the resource + pub contents: Vec, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl ReadResourceResult { + /// Create a new ReadResourceResult with the given contents. + pub fn new(contents: Vec) -> Self { + Self { + result_type: Some(ResultType::COMPLETE), + ttl_ms: None, + cache_scope: None, + contents, + meta: None, + } + } + + /// Set the time, in milliseconds, that this result may be treated as fresh. + pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self { + self.ttl_ms = Some(ttl_ms); + self + } + + /// Set the cache scope for this result. + pub fn with_cache_scope(mut self, cache_scope: CacheScope) -> Self { + self.cache_scope = Some(cache_scope); + self + } +} + +/// Request to read a specific resource +pub type ReadResourceRequest = Request; + +const_string!(ResourceListChangedNotificationMethod = "notifications/resources/list_changed"); +/// Notification sent when the list of available resources changes +pub type ResourceListChangedNotification = + NotificationNoParam; + +const_string!(SubscribeRequestMethod = "resources/subscribe"); +/// Parameters for subscribing to resource updates +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct SubscribeRequestParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// The URI of the resource to subscribe to + pub uri: String, +} + +impl SubscribeRequestParams { + /// Create a new SubscribeRequestParams. + pub fn new(uri: impl Into) -> Self { + Self { + meta: None, + uri: uri.into(), + } + } +} + +impl RequestParamsMeta for SubscribeRequestParams { + fn meta(&self) -> Option<&RequestMetaObject> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// Request to subscribe to resource updates +#[deprecated( + note = "resources/subscribe is legacy-only; use subscriptions/listen for protocol version 2026-07-28" +)] +pub type SubscribeRequest = Request; + +const_string!(UnsubscribeRequestMethod = "resources/unsubscribe"); +/// Parameters for unsubscribing from resource updates +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct UnsubscribeRequestParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// The URI of the resource to unsubscribe from + pub uri: String, +} + +impl UnsubscribeRequestParams { + /// Creates a new `UnsubscribeRequestParams` for the given URI. + pub fn new(uri: impl Into) -> Self { + Self { + meta: None, + uri: uri.into(), + } + } +} + +impl RequestParamsMeta for UnsubscribeRequestParams { + fn meta(&self) -> Option<&RequestMetaObject> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// Request to unsubscribe from resource updates +#[deprecated( + note = "resources/unsubscribe is legacy-only; cancel the subscriptions/listen request for protocol version 2026-07-28" +)] +pub type UnsubscribeRequest = Request; + +const_string!(ResourceUpdatedNotificationMethod = "notifications/resources/updated"); +/// Parameters for a resource update notification +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct ResourceUpdatedNotificationParam { + /// The URI of the resource that was updated + pub uri: String, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl ResourceUpdatedNotificationParam { + /// Create a new ResourceUpdatedNotificationParam. + pub fn new(uri: impl Into) -> Self { + Self { + uri: uri.into(), + meta: None, + } + } +} + +/// Notification sent when a subscribed resource is updated +pub type ResourceUpdatedNotification = + Notification; + +// ============================================================================= +// SUBSCRIPTIONS +// ============================================================================= + +/// Notification categories a client opts in to on a `subscriptions/listen` stream. +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct SubscriptionFilter { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "schemars", schemars(with = "bool"))] + pub tools_list_changed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "schemars", schemars(with = "bool"))] + pub prompts_list_changed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "schemars", schemars(with = "bool"))] + pub resources_list_changed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "schemars", schemars(with = "Vec"))] + pub resource_subscriptions: Option>, +} + +impl SubscriptionFilter { + /// Create an empty filter that opts in to no notifications. + pub fn new() -> Self { + Self::default() + } + + /// Create a builder for a subscription filter. + pub fn builder() -> SubscriptionFilterBuilder { + SubscriptionFilterBuilder::default() + } + + /// Return the subset present in both filters. + pub fn intersection(&self, other: &Self) -> Self { + let resource_subscriptions = self + .resource_subscriptions + .as_ref() + .and_then(|requested| { + other.resource_subscriptions.as_ref().map(|accepted| { + requested + .iter() + .filter(|uri| accepted.contains(uri)) + .cloned() + .collect() + }) + }) + .filter(|uris: &Vec| !uris.is_empty()); + Self { + tools_list_changed: (self.tools_list_changed == Some(true) + && other.tools_list_changed == Some(true)) + .then_some(true), + prompts_list_changed: (self.prompts_list_changed == Some(true) + && other.prompts_list_changed == Some(true)) + .then_some(true), + resources_list_changed: (self.resources_list_changed == Some(true) + && other.resources_list_changed == Some(true)) + .then_some(true), + resource_subscriptions, + } + } + + /// Return whether this filter accepts only notifications requested by `other`. + pub fn is_subset_of(&self, other: &Self) -> bool { + let booleans_are_subset = [ + (self.tools_list_changed, other.tools_list_changed), + (self.prompts_list_changed, other.prompts_list_changed), + (self.resources_list_changed, other.resources_list_changed), + ] + .into_iter() + .all(|(accepted, requested)| accepted != Some(true) || requested == Some(true)); + let resources_are_subset = self.resource_subscriptions.as_ref().is_none_or(|accepted| { + accepted.iter().all(|uri| { + other + .resource_subscriptions + .as_ref() + .is_some_and(|requested| requested.contains(uri)) + }) + }); + booleans_are_subset && resources_are_subset + } + + /// Return the requested notification types advertised by server capabilities. + pub fn supported_by(&self, capabilities: &ServerCapabilities) -> Self { + Self { + tools_list_changed: (self.tools_list_changed == Some(true) + && capabilities + .tools + .as_ref() + .is_some_and(|tools| tools.list_changed == Some(true))) + .then_some(true), + prompts_list_changed: (self.prompts_list_changed == Some(true) + && capabilities + .prompts + .as_ref() + .is_some_and(|prompts| prompts.list_changed == Some(true))) + .then_some(true), + resources_list_changed: (self.resources_list_changed == Some(true) + && capabilities + .resources + .as_ref() + .is_some_and(|resources| resources.list_changed == Some(true))) + .then_some(true), + resource_subscriptions: capabilities + .resources + .as_ref() + .is_some_and(|resources| resources.subscribe == Some(true)) + .then(|| self.resource_subscriptions.clone()) + .flatten(), + } + } +} + +/// Builder for [`SubscriptionFilter`]. +#[derive(Debug, Default)] +#[non_exhaustive] +pub struct SubscriptionFilterBuilder { + filter: SubscriptionFilter, +} + +impl SubscriptionFilterBuilder { + /// Opt in to `notifications/tools/list_changed`. + pub fn tools_list_changed(mut self) -> Self { + self.filter.tools_list_changed = Some(true); + self + } + + /// Opt in to `notifications/prompts/list_changed`. + pub fn prompts_list_changed(mut self) -> Self { + self.filter.prompts_list_changed = Some(true); + self + } + + /// Opt in to `notifications/resources/list_changed`. + pub fn resources_list_changed(mut self) -> Self { + self.filter.resources_list_changed = Some(true); + self + } + + /// Opt in to updates for all supplied resource URIs. + pub fn resource_subscriptions( + mut self, + uris: impl IntoIterator>, + ) -> Self { + self.filter.resource_subscriptions = Some(uris.into_iter().map(Into::into).collect()); + self + } + + /// Add one resource URI to the update subscription set. + pub fn resource_subscription(mut self, uri: impl Into) -> Self { + self.filter + .resource_subscriptions + .get_or_insert_default() + .push(uri.into()); + self + } + + /// Build the filter. + pub fn build(self) -> SubscriptionFilter { + self.filter + } +} + +const_string!(SubscriptionsListenRequestMethod = "subscriptions/listen"); + +#[cfg(feature = "schemars")] +fn subscriptions_listen_request_meta_schema( + generator: &mut schemars::SchemaGenerator, +) -> schemars::Schema { + let progress_token = generator.subschema_for::(); + let client_info = generator.subschema_for::(); + let client_capabilities = generator.subschema_for::(); + let log_level = generator.subschema_for::(); + schemars::json_schema!({ + "type": "object", + "properties": { + "progressToken": progress_token, + "io.modelcontextprotocol/protocolVersion": { + "type": "string", + }, + "io.modelcontextprotocol/clientInfo": client_info, + "io.modelcontextprotocol/clientCapabilities": client_capabilities, + "io.modelcontextprotocol/logLevel": log_level, + }, + "required": RequestMetaObject::DRAFT_REQUIRED_KEYS, + "additionalProperties": true, + }) +} + +/// Parameters for opening a long-lived notification subscription. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct SubscriptionsListenRequestParams { + /// Protocol-level metadata. Required by the 2026-07-28 wire schema. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + #[cfg_attr( + feature = "schemars", + schemars(required, schema_with = "subscriptions_listen_request_meta_schema") + )] + pub meta: Option, + /// Notification categories requested for this stream. + pub notifications: SubscriptionFilter, +} + +impl SubscriptionsListenRequestParams { + /// Create listen parameters for a notification filter. + pub fn new(notifications: SubscriptionFilter) -> Self { + Self { + meta: None, + notifications, + } + } + + /// Set protocol-level request metadata. + pub fn with_meta(mut self, meta: RequestMetaObject) -> Self { + self.meta = Some(meta); + self + } +} + +impl RequestParamsMeta for SubscriptionsListenRequestParams { + fn meta(&self) -> Option<&RequestMetaObject> { + self.meta.as_ref() + } + + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// Request that opens a long-lived notification subscription. +pub type SubscriptionsListenRequest = + Request; + +const SUBSCRIPTION_ID_META_KEY: &str = "io.modelcontextprotocol/subscriptionId"; + +/// Metadata on the final result of a `subscriptions/listen` request. +#[derive(Debug, Serialize, Clone, PartialEq)] +#[serde(transparent)] +#[non_exhaustive] +pub struct SubscriptionsListenResultMeta(MetaObject); + +impl SubscriptionsListenResultMeta { + /// Create result metadata for the originating listen request. + pub fn new(subscription_id: RequestId) -> Self { + let mut meta = MetaObject::new(); + meta.insert( + SUBSCRIPTION_ID_META_KEY.to_owned(), + subscription_id.into_json_value(), + ); + Self(meta) + } + + /// Return the originating listen request ID, if the metadata remains valid. + pub fn subscription_id(&self) -> Option { + self.0 + .get(SUBSCRIPTION_ID_META_KEY) + .and_then(|value| RequestId::deserialize(value).ok()) + } + + /// Replace the originating listen request ID. + pub fn set_subscription_id(&mut self, subscription_id: RequestId) { + self.0.insert( + SUBSCRIPTION_ID_META_KEY.to_owned(), + subscription_id.into_json_value(), + ); + } + + /// Return the server implementation information stored in result metadata. + pub fn server_info(&self) -> Option { + server_info_from_meta(&self.0) + } + + /// Store server implementation information in result metadata. + pub fn set_server_info(&mut self, server_info: Implementation) { + set_server_info_on_meta(&mut self.0, server_info); + } +} + +impl<'de> Deserialize<'de> for SubscriptionsListenResultMeta { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let meta = MetaObject::deserialize(deserializer)?; + let Some(value) = meta.get(SUBSCRIPTION_ID_META_KEY) else { + return Err(serde::de::Error::missing_field(SUBSCRIPTION_ID_META_KEY)); + }; + RequestId::deserialize(value).map_err(serde::de::Error::custom)?; + Ok(Self(meta)) + } +} + +impl std::ops::Deref for SubscriptionsListenResultMeta { + type Target = MetaObject; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for SubscriptionsListenResultMeta { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for SubscriptionsListenResultMeta { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("SubscriptionsListenResultMeta") + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + let subscription_id = generator.subschema_for::(); + let server_info = generator.subschema_for::(); + schemars::json_schema!({ + "type": "object", + "properties": { + "io.modelcontextprotocol/serverInfo": { + "description": "Identifies the server software producing the response. Servers SHOULD include this field on every response unless specifically configured not to do so.", + "allOf": [server_info], + }, + "io.modelcontextprotocol/subscriptionId": subscription_id, + }, + "required": ["io.modelcontextprotocol/subscriptionId"], + "additionalProperties": true, + }) + } +} + +/// Final response indicating that a subscription ended gracefully. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct SubscriptionsListenResult { + pub result_type: ResultType, + #[serde(rename = "_meta")] + pub meta: SubscriptionsListenResultMeta, +} + +impl SubscriptionsListenResult { + /// Create a completed subscription result. + pub fn new(meta: SubscriptionsListenResultMeta) -> Self { + Self { + result_type: ResultType::COMPLETE, + meta, + } + } + + /// Create a completed result for the originating listen request. + pub fn complete(subscription_id: RequestId) -> Self { + Self::new(SubscriptionsListenResultMeta::new(subscription_id)) + } +} + +const_string!( + SubscriptionsAcknowledgedNotificationMethod = "notifications/subscriptions/acknowledged" +); + +/// Parameters reporting the accepted subset of a subscription filter. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct SubscriptionsAcknowledgedNotificationParams { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "schemars", schemars(with = "NotificationMetaObject"))] + pub meta: Option, + pub notifications: SubscriptionFilter, +} + +impl SubscriptionsAcknowledgedNotificationParams { + /// Create acknowledgment parameters for the accepted filter. + pub fn new(notifications: SubscriptionFilter) -> Self { + Self { + meta: None, + notifications, + } + } + + /// Set notification metadata. + pub fn with_meta(mut self, meta: NotificationMetaObject) -> Self { + self.meta = Some(meta); + self + } +} + +/// First notification sent on an established subscription stream. +pub type SubscriptionsAcknowledgedNotification = Notification< + SubscriptionsAcknowledgedNotificationMethod, + SubscriptionsAcknowledgedNotificationParams, +>; + +// ============================================================================= +// PROMPT MANAGEMENT +// ============================================================================= + +const_string!(ListPromptsRequestMethod = "prompts/list"); +/// Request to list all available prompts from a server +pub type ListPromptsRequest = + RequestOptionalParam; + +paginated_result!(ListPromptsResult { + prompts: Vec +}); + +const_string!(GetPromptRequestMethod = "prompts/get"); +/// Parameters for retrieving a specific prompt +#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct GetPromptRequestParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub arguments: Option, + /// Client responses to server-initiated input requests from a previous + /// [`InputRequiredResult`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub input_responses: Option, + /// Opaque request state echoed back from a previous [`InputRequiredResult`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_state: Option, +} + +impl GetPromptRequestParams { + /// Create a new `GetPromptRequestParams` with the given prompt name. + pub fn new(name: impl Into) -> Self { + Self { + meta: None, + name: name.into(), + arguments: None, + input_responses: None, + request_state: None, + } + } + + /// Set the arguments for this prompt request. + pub fn with_arguments(mut self, arguments: JsonObject) -> Self { + self.arguments = Some(arguments); + self + } + + /// Set the metadata for this request. + pub fn with_meta(mut self, meta: RequestMetaObject) -> Self { + self.meta = Some(meta); + self + } + + /// Sets the input responses for an MRTR retry. + pub fn with_input_responses(mut self, input_responses: InputResponses) -> Self { + self.input_responses = Some(input_responses); + self + } + + /// Sets the request state for an MRTR retry. + pub fn with_request_state(mut self, request_state: impl Into) -> Self { + self.request_state = Some(request_state.into()); + self + } +} + +impl RequestParamsMeta for GetPromptRequestParams { + fn meta(&self) -> Option<&RequestMetaObject> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// Request to get a specific prompt +pub type GetPromptRequest = Request; + +const_string!(PromptListChangedNotificationMethod = "notifications/prompts/list_changed"); +/// Notification sent when the list of available prompts changes +pub type PromptListChangedNotification = NotificationNoParam; + +const_string!(ToolListChangedNotificationMethod = "notifications/tools/list_changed"); +/// Notification sent when the list of available tools changes +pub type ToolListChangedNotification = NotificationNoParam; + +// ============================================================================= +// LOGGING +// ============================================================================= + +/// Logging levels supported by the MCP protocol +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Copy)] +#[serde(rename_all = "lowercase")] //match spec +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] +#[deprecated( + since = "2.0.0", + note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] +pub enum LoggingLevel { + Debug, + Info, + Notice, + Warning, + Error, + Critical, + Alert, + Emergency, +} + +const_string!(SetLevelRequestMethod = "logging/setLevel"); +/// Parameters for setting the logging level +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] +pub struct SetLevelRequestParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// The desired logging level + pub level: LoggingLevel, +} + +impl SetLevelRequestParams { + /// Create a new SetLevelRequestParams with the given logging level. + pub fn new(level: LoggingLevel) -> Self { + Self { meta: None, level } + } +} + +impl RequestParamsMeta for SetLevelRequestParams { + fn meta(&self) -> Option<&RequestMetaObject> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// Request to set the logging level +#[deprecated( + since = "2.0.0", + note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] +pub type SetLevelRequest = Request; + +const_string!(LoggingMessageNotificationMethod = "notifications/message"); +/// Parameters for a logging message notification +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] +pub struct LoggingMessageNotificationParam { + /// The severity level of this log message + pub level: LoggingLevel, + /// Optional logger name that generated this message + #[serde(skip_serializing_if = "Option::is_none")] + pub logger: Option, + /// The actual log data + pub data: Value, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl LoggingMessageNotificationParam { + /// Create a new LoggingMessageNotificationParam. + pub fn new(level: LoggingLevel, data: Value) -> Self { + Self { + level, + logger: None, + data, + meta: None, + } + } + + /// Set the logger name. + pub fn with_logger(mut self, logger: impl Into) -> Self { + self.logger = Some(logger.into()); + self + } +} + +/// Notification containing a log message +#[deprecated( + since = "2.0.0", + note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] +pub type LoggingMessageNotification = + Notification; + +// ============================================================================= +// SAMPLING (LLM INTERACTION) +// ============================================================================= + +const_string!(CreateMessageRequestMethod = "sampling/createMessage"); +#[deprecated( + since = "2.0.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] +pub type CreateMessageRequest = Request; + +/// Represents the role of a participant in a conversation or message exchange. +/// +/// Used in sampling and chat contexts to distinguish between different +/// types of message senders in the conversation flow. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] +pub enum Role { + /// A human user or client making a request + User, + /// An AI assistant or server providing a response + Assistant, +} + +/// Tool selection mode (SEP-1577). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub enum ToolChoiceMode { + /// Model decides whether to use tools + #[default] + Auto, + /// Model must use at least one tool + Required, + /// Model must not use tools + None, +} + +/// Tool choice configuration (SEP-1577). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] +pub struct ToolChoice { + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, +} + +impl ToolChoice { + pub fn auto() -> Self { + Self { + mode: Some(ToolChoiceMode::Auto), + } + } + + pub fn required() -> Self { + Self { + mode: Some(ToolChoiceMode::Required), + } + } + + pub fn none() -> Self { + Self { + mode: Some(ToolChoiceMode::None), + } + } +} + +/// Single or array content wrapper (SEP-1577). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] +pub enum SamplingContent { + Single(T), + Multiple(Vec), +} + +impl SamplingContent { + /// Convert to a Vec regardless of whether it's single or multiple + pub fn into_vec(self) -> Vec { + match self { + SamplingContent::Single(item) => vec![item], + SamplingContent::Multiple(items) => items, + } + } + + /// Check if the content is empty + pub fn is_empty(&self) -> bool { + match self { + SamplingContent::Single(_) => false, + SamplingContent::Multiple(items) => items.is_empty(), + } + } + + /// Get the number of content items + pub fn len(&self) -> usize { + match self { + SamplingContent::Single(_) => 1, + SamplingContent::Multiple(items) => items.len(), + } + } +} + +impl Default for SamplingContent { + fn default() -> Self { + SamplingContent::Multiple(Vec::new()) + } +} + +impl SamplingContent { + /// Get the first item if present + pub fn first(&self) -> Option<&T> { + match self { + SamplingContent::Single(item) => Some(item), + SamplingContent::Multiple(items) => items.first(), + } + } + + /// Iterate over all content items + pub fn iter(&self) -> impl Iterator { + let items: Vec<&T> = match self { + SamplingContent::Single(item) => vec![item], + SamplingContent::Multiple(items) => items.iter().collect(), + }; + items.into_iter() + } +} + +impl SamplingMessageContentBlock { + /// Get the text content if this is a Text variant + pub fn as_text(&self) -> Option<&TextContent> { + match self { + SamplingMessageContentBlock::Text(text) => Some(text), + _ => None, + } + } + + /// Get the tool use content if this is a ToolUse variant + pub fn as_tool_use(&self) -> Option<&ToolUseContent> { + match self { + SamplingMessageContentBlock::ToolUse(tool_use) => Some(tool_use), + _ => None, + } + } + + /// Get the tool result content if this is a ToolResult variant + pub fn as_tool_result(&self) -> Option<&ToolResultContent> { + match self { + SamplingMessageContentBlock::ToolResult(tool_result) => Some(tool_result), + _ => None, + } + } +} + +impl From for SamplingContent { + fn from(item: T) -> Self { + SamplingContent::Single(item) + } +} + +impl From> for SamplingContent { + fn from(items: Vec) -> Self { + SamplingContent::Multiple(items) + } +} + +/// A message in a sampling conversation, containing a role and content. +/// +/// This represents a single message in a conversation flow, used primarily +/// in LLM sampling requests where the conversation history is important +/// for generating appropriate responses. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] +pub struct SamplingMessage { + /// The role of the message sender (User or Assistant) + pub role: Role, + /// The actual content of the message (text, image, audio, tool use, or tool result) + pub content: SamplingContent, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// Content types for sampling messages (SEP-1577). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] +pub enum SamplingMessageContentBlock { + Text(TextContent), + Image(ImageContent), + Audio(AudioContent), + /// Assistant only + ToolUse(ToolUseContent), + /// User only + ToolResult(ToolResultContent), +} + +impl SamplingMessageContentBlock { + /// Create a text content + pub fn text(text: impl Into) -> Self { + Self::Text(TextContent::new(text)) + } + + pub fn tool_use(id: impl Into, name: impl Into, input: JsonObject) -> Self { + Self::ToolUse(ToolUseContent::new(id, name, input)) + } + + pub fn tool_result(tool_use_id: impl Into, content: Vec) -> Self { + Self::ToolResult(ToolResultContent::new(tool_use_id, content)) + } +} + +impl SamplingMessage { + pub fn new(role: Role, content: impl Into) -> Self { + Self { + role, + content: SamplingContent::Single(content.into()), + meta: None, + } + } + + pub fn new_multiple(role: Role, contents: Vec) -> Self { + Self { + role, + content: SamplingContent::Multiple(contents), + meta: None, + } + } + + pub fn user_text(text: impl Into) -> Self { + Self::new(Role::User, SamplingMessageContentBlock::text(text)) + } + + pub fn assistant_text(text: impl Into) -> Self { + Self::new(Role::Assistant, SamplingMessageContentBlock::text(text)) + } + + pub fn user_tool_result(tool_use_id: impl Into, content: Vec) -> Self { + Self::new( + Role::User, + SamplingMessageContentBlock::tool_result(tool_use_id, content), + ) + } + + pub fn assistant_tool_use( + id: impl Into, + name: impl Into, + input: JsonObject, + ) -> Self { + Self::new( + Role::Assistant, + SamplingMessageContentBlock::tool_use(id, name, input), + ) + } +} + +impl From for SamplingMessageContentBlock { + fn from(text: TextContent) -> Self { + SamplingMessageContentBlock::Text(text) + } +} + +// Conversion from String to SamplingMessageContentBlock (as text) +impl From for SamplingMessageContentBlock { + fn from(text: String) -> Self { + SamplingMessageContentBlock::text(text) + } +} + +impl From<&str> for SamplingMessageContentBlock { + fn from(text: &str) -> Self { + SamplingMessageContentBlock::text(text) + } +} + +impl TryFrom for SamplingMessageContentBlock { + type Error = &'static str; + + fn try_from(content: ContentBlock) -> Result { + match content { + ContentBlock::Text(text) => Ok(SamplingMessageContentBlock::Text(text)), + ContentBlock::Image(image) => Ok(SamplingMessageContentBlock::Image(image)), + ContentBlock::Audio(audio) => Ok(SamplingMessageContentBlock::Audio(audio)), + ContentBlock::Resource(_) => { + Err("Resource content is not supported in sampling messages") + } + ContentBlock::ResourceLink(_) => { + Err("ResourceLink content is not supported in sampling messages") + } + } + } +} + +impl TryFrom for SamplingContent { + type Error = &'static str; + + fn try_from(content: ContentBlock) -> Result { + Ok(SamplingContent::Single(content.try_into()?)) + } +} + +/// Specifies how much context should be included in sampling requests. +/// +/// This allows clients to control what additional context information +/// should be provided to the LLM when processing sampling requests. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub enum ContextInclusion { + /// Include context from all connected MCP servers + #[serde(rename = "allServers")] + AllServers, + /// Include no additional context + #[serde(rename = "none")] + None, + /// Include context only from the requesting server + #[serde(rename = "thisServer")] + ThisServer, +} + +/// Parameters for creating a message through LLM sampling. +/// +/// This structure contains all the necessary information for a client to +/// generate an LLM response, including conversation history, model preferences, +/// and generation parameters. +/// +/// This implements `TaskAugmentedRequestParamsMeta` as sampling requests can be +/// long-running and may benefit from task-based execution. +#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] +pub struct CreateMessageRequestParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// The conversation history and current messages + pub messages: Vec, + /// Preferences for model selection and behavior + #[serde(skip_serializing_if = "Option::is_none")] + pub model_preferences: Option, + /// System prompt to guide the model's behavior + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + /// How much context to include from MCP servers + #[serde(skip_serializing_if = "Option::is_none")] + pub include_context: Option, + /// Temperature for controlling randomness (0.0 to 1.0) + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + /// Maximum number of tokens to generate + pub max_tokens: u32, + /// Sequences that should stop generation + #[serde(skip_serializing_if = "Option::is_none")] + pub stop_sequences: Option>, + /// Additional metadata for the request + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + /// Tools available for the model to call (SEP-1577) + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + /// Tool selection behavior (SEP-1577) + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, +} + +impl RequestParamsMeta for CreateMessageRequestParams { + fn meta(&self) -> Option<&RequestMetaObject> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +impl CreateMessageRequestParams { + /// Create a new CreateMessageRequestParams with required fields. + pub fn new(messages: Vec, max_tokens: u32) -> Self { + Self { + meta: None, + messages, + model_preferences: None, + system_prompt: None, + include_context: None, + temperature: None, + max_tokens, + stop_sequences: None, + metadata: None, + tools: None, + tool_choice: None, + } + } + + /// Set model preferences. + pub fn with_model_preferences(mut self, model_preferences: ModelPreferences) -> Self { + self.model_preferences = Some(model_preferences); + self + } + + /// Set system prompt. + pub fn with_system_prompt(mut self, system_prompt: impl Into) -> Self { + self.system_prompt = Some(system_prompt.into()); + self + } + + /// Set include context. + pub fn with_include_context(mut self, include_context: ContextInclusion) -> Self { + self.include_context = Some(include_context); + self + } + + /// Set temperature. + pub fn with_temperature(mut self, temperature: f32) -> Self { + self.temperature = Some(temperature); + self + } + + /// Set stop sequences. + pub fn with_stop_sequences(mut self, stop_sequences: Vec) -> Self { + self.stop_sequences = Some(stop_sequences); + self + } + + /// Set metadata. + pub fn with_metadata(mut self, metadata: Value) -> Self { + self.metadata = Some(metadata); + self + } + + /// Set tools. + pub fn with_tools(mut self, tools: Vec) -> Self { + self.tools = Some(tools); + self + } + + /// Set tool choice. + pub fn with_tool_choice(mut self, tool_choice: ToolChoice) -> Self { + self.tool_choice = Some(tool_choice); + self + } + + /// Validate the sampling request parameters per SEP-1577 spec requirements. + /// + /// Checks: + /// - ToolUse content is only allowed in assistant messages + /// - ToolResult content is only allowed in user messages + /// - Messages with tool result content MUST NOT contain other content types + /// - Every assistant ToolUse must be balanced with a corresponding user ToolResult + pub fn validate(&self) -> Result<(), String> { + for msg in &self.messages { + for content in msg.content.iter() { + // ToolUse only in assistant messages, ToolResult only in user messages + match content { + SamplingMessageContentBlock::ToolUse(_) if msg.role != Role::Assistant => { + return Err("ToolUse content is only allowed in assistant messages".into()); + } + SamplingMessageContentBlock::ToolResult(_) if msg.role != Role::User => { + return Err("ToolResult content is only allowed in user messages".into()); + } + _ => {} + } + } + + // Tool result messages MUST NOT contain other content types + let contents: Vec<_> = msg.content.iter().collect(); + let has_tool_result = contents + .iter() + .any(|c| matches!(c, SamplingMessageContentBlock::ToolResult(_))); + if has_tool_result + && contents + .iter() + .any(|c| !matches!(c, SamplingMessageContentBlock::ToolResult(_))) + { + return Err( + "SamplingMessage with tool result content MUST NOT contain other content types" + .into(), + ); + } + } + + // Every assistant ToolUse must be balanced with a user ToolResult + self.validate_tool_use_result_balance()?; + + Ok(()) + } + + fn validate_tool_use_result_balance(&self) -> Result<(), String> { + let mut pending_tool_use_ids: Vec = Vec::new(); + for msg in &self.messages { + if msg.role == Role::Assistant { + for content in msg.content.iter() { + if let SamplingMessageContentBlock::ToolUse(tu) = content { + pending_tool_use_ids.push(tu.id.clone()); + } + } + } else if msg.role == Role::User { + for content in msg.content.iter() { + if let SamplingMessageContentBlock::ToolResult(tr) = content { + if !pending_tool_use_ids.contains(&tr.tool_use_id) { + return Err(format!( + "ToolResult with toolUseId '{}' has no matching ToolUse", + tr.tool_use_id + )); + } + pending_tool_use_ids.retain(|id| id != &tr.tool_use_id); + } + } + } + } + if !pending_tool_use_ids.is_empty() { + return Err(format!( + "ToolUse with id(s) {:?} not balanced with ToolResult", + pending_tool_use_ids + )); + } + Ok(()) + } +} + +/// Preferences for model selection and behavior in sampling requests. +/// +/// This allows servers to express their preferences for which model to use +/// and how to balance different priorities when the client has multiple +/// model options available. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] +pub struct ModelPreferences { + /// Specific model names or families to prefer (e.g., "claude", "gpt") + #[serde(skip_serializing_if = "Option::is_none")] + pub hints: Option>, + /// Priority for cost optimization (0.0 to 1.0, higher = prefer cheaper models) + #[serde(skip_serializing_if = "Option::is_none")] + pub cost_priority: Option, + /// Priority for speed/latency (0.0 to 1.0, higher = prefer faster models) + #[serde(skip_serializing_if = "Option::is_none")] + pub speed_priority: Option, + /// Priority for intelligence/capability (0.0 to 1.0, higher = prefer more capable models) + #[serde(skip_serializing_if = "Option::is_none")] + pub intelligence_priority: Option, +} + +impl ModelPreferences { + /// Create a new default ModelPreferences. + pub fn new() -> Self { + Self { + hints: None, + cost_priority: None, + speed_priority: None, + intelligence_priority: None, + } + } + + /// Set hints for model selection. + pub fn with_hints(mut self, hints: Vec) -> Self { + self.hints = Some(hints); + self + } + + /// Set cost priority (0.0 to 1.0). + pub fn with_cost_priority(mut self, cost_priority: f32) -> Self { + self.cost_priority = Some(cost_priority); + self + } + + /// Set speed priority (0.0 to 1.0). + pub fn with_speed_priority(mut self, speed_priority: f32) -> Self { + self.speed_priority = Some(speed_priority); + self + } + + /// Set intelligence priority (0.0 to 1.0). + pub fn with_intelligence_priority(mut self, intelligence_priority: f32) -> Self { + self.intelligence_priority = Some(intelligence_priority); + self + } +} + +impl Default for ModelPreferences { + fn default() -> Self { + Self::new() + } +} + +/// A hint suggesting a preferred model name or family. +/// +/// Model hints are advisory suggestions that help clients choose appropriate +/// models. They can be specific model names or general families like "claude" or "gpt". +#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] +pub struct ModelHint { + /// The suggested model name or family identifier + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +impl ModelHint { + /// Create a new ModelHint with a name. + pub fn new(name: impl Into) -> Self { + Self { + name: Some(name.into()), + } + } +} + +// ============================================================================= +// COMPLETION AND AUTOCOMPLETE +// ============================================================================= + +/// Context for completion requests providing previously resolved arguments. +/// +/// This enables context-aware completion where subsequent argument completions +/// can take into account the values of previously resolved arguments. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct CompletionContext { + /// Previously resolved argument values that can inform completion suggestions + #[serde(skip_serializing_if = "Option::is_none")] + pub arguments: Option>, +} + +impl CompletionContext { + /// Create a new empty completion context + pub fn new() -> Self { + Self::default() + } + + /// Create a completion context with the given arguments + pub fn with_arguments(arguments: std::collections::HashMap) -> Self { + Self { + arguments: Some(arguments), + } + } + + /// Get a specific argument value by name + pub fn get_argument(&self, name: &str) -> Option<&String> { + self.arguments.as_ref()?.get(name) + } + + /// Check if the context has any arguments + pub fn has_arguments(&self) -> bool { + self.arguments.as_ref().is_some_and(|args| !args.is_empty()) + } + + /// Get all argument names + pub fn argument_names(&self) -> impl Iterator { + self.arguments + .as_ref() + .into_iter() + .flat_map(|args| args.keys()) + .map(|k| k.as_str()) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct CompleteRequestParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + pub r#ref: Reference, + pub argument: ArgumentInfo, + /// Optional context containing previously resolved argument values + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, +} + +impl CompleteRequestParams { + /// Create a new CompleteRequestParams with required fields. + pub fn new(r#ref: Reference, argument: ArgumentInfo) -> Self { + Self { + meta: None, + r#ref, + argument, + context: None, + } + } + + /// Set the completion context + pub fn with_context(mut self, context: CompletionContext) -> Self { + self.context = Some(context); + self + } +} + +impl RequestParamsMeta for CompleteRequestParams { + fn meta(&self) -> Option<&RequestMetaObject> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +pub type CompleteRequest = Request; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct CompletionInfo { + pub values: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub total: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub has_more: Option, +} + +impl CompletionInfo { + /// Maximum number of completion values allowed per response according to MCP specification + pub const MAX_VALUES: usize = 100; + + /// Create a new CompletionInfo with validation for maximum values + pub fn new(values: Vec) -> Result { + if values.len() > Self::MAX_VALUES { + return Err(format!( + "Too many completion values: {} (max: {})", + values.len(), + Self::MAX_VALUES + )); + } + Ok(Self { + values, + total: None, + has_more: None, + }) + } + + /// Create CompletionInfo with all values and no pagination + pub fn with_all_values(values: Vec) -> Result { + let completion = Self::new(values)?; + Ok(Self { + total: Some(completion.values.len() as u32), + has_more: Some(false), + ..completion + }) + } + + /// Create CompletionInfo with pagination information + pub fn with_pagination( + values: Vec, + total: Option, + has_more: bool, + ) -> Result { + let completion = Self::new(values)?; + Ok(Self { + total, + has_more: Some(has_more), + ..completion + }) + } + + /// Check if this completion response indicates more results are available + pub fn has_more_results(&self) -> bool { + self.has_more.unwrap_or(false) + } + + /// Get the total number of available completions, if known + pub fn total_available(&self) -> Option { + self.total + } + + /// Validate that the completion info complies with MCP specification + pub fn validate(&self) -> Result<(), String> { + if self.values.len() > Self::MAX_VALUES { + return Err(format!( + "Too many completion values: {} (max: {})", + self.values.len(), + Self::MAX_VALUES + )); + } + Ok(()) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct CompleteResult { + /// Result type discriminator (SEP-2322). Required by the [spec schema] + /// for servers implementing protocol version `2026-07-28`, but optional + /// here because this type also models results from older protocol + /// versions, which do not carry the field: `None` means absent on the + /// wire, and per the spec "the client MUST treat the absent field as + /// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`; + /// the server handler clears the field when responding to peers that + /// negotiated an older version. + /// + /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_type: Option, + pub completion: CompletionInfo, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl Default for CompleteResult { + fn default() -> Self { + Self::new(CompletionInfo::default()) + } +} + +impl CompleteResult { + /// Create a new CompleteResult with the given completion info. + pub fn new(completion: CompletionInfo) -> Self { + Self { + result_type: Some(ResultType::COMPLETE), + completion, + meta: None, + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(tag = "type")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub enum Reference { + #[serde(rename = "ref/resource")] + Resource(ResourceTemplateReference), + #[serde(rename = "ref/prompt")] + Prompt(PromptReference), +} + +impl Reference { + /// Create a prompt reference + pub fn for_prompt(name: impl Into) -> Self { + // Not accepting `title` currently as it'll break the API + // Until further decision, keep it `None`, modify later + // if required, add `title` to the API + Self::Prompt(PromptReference { + name: name.into(), + title: None, + }) + } + + /// Create a resource reference + pub fn for_resource(uri: impl Into) -> Self { + Self::Resource(ResourceTemplateReference { uri: uri.into() }) + } + + /// Get the reference type as a string + pub fn reference_type(&self) -> &'static str { + match self { + Self::Prompt(_) => "ref/prompt", + Self::Resource(_) => "ref/resource", + } + } + + /// Extract prompt name if this is a prompt reference + pub fn as_prompt_name(&self) -> Option<&str> { + match self { + Self::Prompt(prompt_ref) => Some(&prompt_ref.name), + _ => None, + } + } + + /// Extract resource URI if this is a resource reference + pub fn as_resource_uri(&self) -> Option<&str> { + match self { + Self::Resource(resource_ref) => Some(&resource_ref.uri), + _ => None, + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct ResourceTemplateReference { + pub uri: String, +} + +impl ResourceTemplateReference { + pub fn new(uri: impl Into) -> Self { + Self { uri: uri.into() } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct PromptReference { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, +} + +impl PromptReference { + /// Creates a new `PromptReference` with the given name. `title` defaults to `None`. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + title: None, + } + } + + /// Sets the human-readable title for this prompt reference. + pub fn with_title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } +} + +const_string!(CompleteRequestMethod = "completion/complete"); +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct ArgumentInfo { + pub name: String, + pub value: String, +} + +impl ArgumentInfo { + pub fn new(name: impl Into, value: impl Into) -> Self { + Self { + name: name.into(), + value: value.into(), + } + } +} + +// ============================================================================= +// ROOTS AND WORKSPACE MANAGEMENT +// ============================================================================= + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] +pub struct Root { + pub uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl Root { + /// Creates a new `Root` with the given URI. `name` defaults to `None`. + pub fn new(uri: impl Into) -> Self { + Self { + uri: uri.into(), + name: None, + meta: None, + } + } + + /// Sets the human-readable name for this root. + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + + /// Sets the protocol-level metadata for this root. + pub fn with_meta(mut self, meta: MetaObject) -> Self { + self.meta = Some(meta); + self + } +} + +const_string!(ListRootsRequestMethod = "roots/list"); +#[deprecated( + since = "2.0.0", + note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] +pub type ListRootsRequest = RequestNoParam; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] +pub struct ListRootsResult { + pub roots: Vec, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl ListRootsResult { + /// Creates a new `ListRootsResult` with the given roots. + pub fn new(roots: Vec) -> Self { + Self { roots, meta: None } + } + + /// Sets the protocol-level metadata for this result. + pub fn with_meta(mut self, meta: MetaObject) -> Self { + self.meta = Some(meta); + self + } +} + +const_string!(RootsListChangedNotificationMethod = "notifications/roots/list_changed"); +pub type RootsListChangedNotification = NotificationNoParam; + +// ============================================================================= +// ELICITATION (INTERACTIVE USER INPUT) +// ============================================================================= + +// Method constants for elicitation operations. +// Elicitation allows servers to request interactive input from users during tool execution. +const_string!(ElicitationCreateRequestMethod = "elicitation/create"); +const_string!(ElicitationResponseNotificationMethod = "notifications/elicitation/response"); + +/// Represents the possible actions a user can take in response to an elicitation request. +/// +/// When a server requests user input through elicitation, the user can: +/// - Accept: Provide the requested information and continue +/// - Decline: Refuse to provide the information but continue the operation +/// - Cancel: Stop the entire operation +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub enum ElicitationAction { + /// User accepts the request and provides the requested information + Accept, + /// User declines to provide the information but allows the operation to continue + Decline, + /// User cancels the entire operation + Cancel, +} + +/// Wire representation for tagged elicitation parameters and legacy forms without `mode`. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(tag = "mode")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +enum ElicitRequestParamsWire { + #[serde(rename = "form", rename_all = "camelCase")] + Form { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + meta: Option, + message: String, + requested_schema: ElicitationSchema, + }, + #[serde(rename = "url", rename_all = "camelCase")] + Url { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + meta: Option, + message: String, + url: String, + elicitation_id: String, + }, + #[serde(untagged, rename_all = "camelCase")] + LegacyForm { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + meta: Option, + message: String, + requested_schema: ElicitationSchema, + }, +} + +impl TryFrom for ElicitRequestParams { + type Error = serde_json::Error; + + fn try_from(value: ElicitRequestParamsWire) -> Result { + match value { + ElicitRequestParamsWire::Form { + meta, + message, + requested_schema, + } + | ElicitRequestParamsWire::LegacyForm { + meta, + message, + requested_schema, + } => Ok(ElicitRequestParams::FormElicitationParams { + meta, + message, + requested_schema, + }), + ElicitRequestParamsWire::Url { + meta, + message, + url, + elicitation_id, + } => Ok(ElicitRequestParams::UrlElicitationParams { + meta, + message, + url, + elicitation_id, + }), + } + } +} + +/// Parameters for creating an elicitation request to gather user input. +/// +/// This structure contains everything needed to request interactive input from a user: +/// - A human-readable message explaining what information is needed +/// - A type-safe schema defining the expected structure of the response +/// +/// # Example +/// 1. Form-based elicitation request +/// ```rust +/// use rmcp::model::*; +/// +/// let params = ElicitRequestParams::FormElicitationParams { +/// meta: None, +/// message: "Please provide your email".to_string(), +/// requested_schema: ElicitationSchema::builder() +/// .required_email("email") +/// .build() +/// .unwrap(), +/// }; +/// ``` +/// 2. URL-based elicitation request +/// ```rust +/// use rmcp::model::*; +/// let params = ElicitRequestParams::UrlElicitationParams { +/// meta: None, +/// message: "Please provide your feedback at the following URL".to_string(), +/// url: "https://example.com/feedback".to_string(), +/// elicitation_id: "unique-id-123".to_string(), +/// }; +/// ``` +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(tag = "mode", try_from = "ElicitRequestParamsWire")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub enum ElicitRequestParams { + #[serde(rename = "form", rename_all = "camelCase")] + FormElicitationParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + meta: Option, + /// Human-readable message explaining what input is needed from the user. + /// This should be clear and provide sufficient context for the user to understand + /// what information they need to provide. + message: String, + + /// Type-safe schema defining the expected structure and validation rules for the user's response. + /// This enforces the MCP 2025-06-18 specification that elicitation schemas must be objects + /// with primitive-typed properties. + requested_schema: ElicitationSchema, + }, + #[serde(rename = "url", rename_all = "camelCase")] + UrlElicitationParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + meta: Option, + /// Human-readable message explaining what input is needed from the user. + /// This should be clear and provide sufficient context for the user to understand + /// what information they need to provide. + message: String, + + /// The URL where the user can provide the requested information. + /// The client should direct the user to this URL to complete the elicitation. + url: String, + /// The unique identifier for this elicitation request. + elicitation_id: String, + }, +} + +impl RequestParamsMeta for ElicitRequestParams { + fn meta(&self) -> Option<&RequestMetaObject> { + match self { + ElicitRequestParams::FormElicitationParams { meta, .. } => meta.as_ref(), + ElicitRequestParams::UrlElicitationParams { meta, .. } => meta.as_ref(), + } + } + fn meta_mut(&mut self) -> &mut Option { + match self { + ElicitRequestParams::FormElicitationParams { meta, .. } => meta, + ElicitRequestParams::UrlElicitationParams { meta, .. } => meta, + } + } +} + +/// The result returned by a client in response to an elicitation request. +/// +/// Contains the user's decision (accept/decline/cancel) and optionally their input data +/// if they chose to accept the request. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct ElicitResult { + /// The user's decision on how to handle the elicitation request + pub action: ElicitationAction, + + /// The actual data provided by the user, if they accepted the request. + /// Must conform to the JSON schema specified in the original request. + /// Only present when action is Accept. + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + + /// Optional protocol-level metadata for this result. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl ElicitResult { + /// Create a new ElicitResult. + pub fn new(action: ElicitationAction) -> Self { + Self { + action, + content: None, + meta: None, + } + } + + /// Set the content on this result. + pub fn with_content(mut self, content: Value) -> Self { + self.content = Some(content); + self + } + + /// Set the metadata on this result. + pub fn with_meta(mut self, meta: MetaObject) -> Self { + self.meta = Some(meta); + self + } +} + +/// Request type for creating an elicitation to gather user input +pub type ElicitRequest = Request; + +// ============================================================================= +// TOOL EXECUTION RESULTS +// ============================================================================= + +/// The result of a tool call operation. +/// +/// Contains the content returned by the tool execution and an optional +/// flag indicating whether the operation resulted in an error. +#[derive(Debug, Serialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct CallToolResult { + /// Result type discriminator (SEP-2322). Required by the [spec schema] + /// for servers implementing protocol version `2026-07-28`, but optional + /// here because this type also models results from older protocol + /// versions, which do not carry the field: `None` means absent on the + /// wire, and per the spec "the client MUST treat the absent field as + /// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`; + /// the server handler clears the field when responding to peers that + /// negotiated an older version. + /// + /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_type: Option, + /// The content returned by the tool (text, images, etc.) + #[serde(default)] + pub content: Vec, + /// An optional JSON object that represents the structured result of the tool call + #[serde(skip_serializing_if = "Option::is_none")] + pub structured_content: Option, + /// Whether this result represents an error condition + #[serde(skip_serializing_if = "Option::is_none")] + pub is_error: Option, + /// Optional protocol-level metadata for this result + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +// Custom Deserialize implementation that: +// 1. Defaults `content` to `[]` when the field is missing (lenient per Postel's law) +// 2. Requires at least one tool-specific field to be present, so protocol-generic +// fields such as `resultType` and `_meta` do not make `CallToolResult` greedily +// match custom-extension responses inside `#[serde(untagged)]` enums (e.g. +// `ServerResult`), which would shadow `CustomResult`. +// 3. Rejects `resultType: "input_required"` so untagged `ServerResult` +// decoding selects `InputRequiredResult` and preserves input requests and state. +impl<'de> Deserialize<'de> for CallToolResult { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Helper { + #[serde(default)] + result_type: Option, + content: Option>, + structured_content: Option, + is_error: Option, + #[serde(rename = "_meta")] + meta: Option, + } + + let helper = Helper::deserialize(deserializer)?; + + if helper + .result_type + .as_ref() + .is_some_and(ResultType::is_input_required) + { + return Err(serde::de::Error::custom( + "CallToolResult cannot use resultType \"input_required\"", + )); + } + + if helper.content.is_none() + && helper.structured_content.is_none() + && helper.is_error.is_none() + { + return Err(serde::de::Error::custom( + "expected at least one tool-specific CallToolResult field \ + (content, structuredContent, or isError)", + )); + } + + Ok(CallToolResult { + result_type: helper.result_type, + content: helper.content.unwrap_or_default(), + structured_content: helper.structured_content, + is_error: helper.is_error, + meta: helper.meta, + }) + } +} + +impl Default for CallToolResult { + fn default() -> Self { + CallToolResult { + result_type: Some(ResultType::COMPLETE), + content: Vec::new(), + structured_content: None, + is_error: None, + meta: None, + } + } +} + +impl CallToolResult { + /// Create a successful tool result with unstructured content + pub fn success(content: Vec) -> Self { + CallToolResult { + result_type: Some(ResultType::COMPLETE), + content, + structured_content: None, + is_error: Some(false), + meta: None, + } + } + + /// Create a tool-level error result with caller-visible content. + /// + /// # When to use this vs `Err(ErrorData)` + /// + /// MCP distinguishes two failure modes for a `call_tool` invocation, and + /// the right one to use depends on **whose problem it is**: + /// + /// - **Tool-level error** — `Ok(CallToolResult::error(...))`. + /// The request was valid and routed to your tool, but executing the + /// tool failed in a way the caller should see (a query returned no + /// rows, an external API returned 500, the user's input is plausible + /// but produced no result, etc.). The caller's MCP client renders the + /// `content` you provide; your message reaches the user. **This is the + /// right choice for almost every "the tool ran and didn't work" case.** + /// + /// - **Protocol error** — `Err(ErrorData)` with a JSON-RPC code. + /// The server cannot route the request at all, or an infrastructure + /// error makes the server itself unusable + /// ([`ErrorCode::INTERNAL_ERROR`], `-32603`). MCP clients typically + /// render protocol errors opaquely (e.g. "Tool result missing due to + /// internal error") — the caller does **not** see your message. + /// + /// # Example + /// + /// ```rust,ignore + /// use rmcp::model::{CallToolResult, Content, ErrorData}; + /// + /// async fn lookup(query: &str) -> Result { + /// // Caller passed a malformed query — the server can't run anything. + /// // This is a protocol error, the caller's client will render it + /// // as -32602 invalid_params: + /// if query.is_empty() { + /// return Err(ErrorData::invalid_params("query must be non-empty", None)); + /// } + /// + /// // Tool ran, no result. Caller should see the explanation: + /// let rows = run_query(query).await; + /// if rows.is_empty() { + /// return Ok(CallToolResult::error(vec![ContentBlock::text( + /// format!("no rows matched '{query}'"), + /// )])); + /// } + /// + /// Ok(CallToolResult::success(vec![ContentBlock::text(format_rows(&rows))])) + /// } + /// # async fn run_query(_: &str) -> Vec<&'static str> { vec![] } + /// # fn format_rows(_: &[&str]) -> String { String::new() } + /// ``` + pub fn error(content: Vec) -> Self { + CallToolResult { + result_type: Some(ResultType::COMPLETE), + content, + structured_content: None, + is_error: Some(true), + meta: None, + } + } + /// Create a successful tool result with structured content + /// + /// # Example + /// + /// ```rust,ignore + /// use rmcp::model::CallToolResult; + /// use serde_json::json; + /// + /// let result = CallToolResult::structured(json!({ + /// "temperature": 22.5, + /// "humidity": 65, + /// "description": "Partly cloudy" + /// })); + /// ``` + pub fn structured(value: Value) -> Self { + CallToolResult { + result_type: Some(ResultType::COMPLETE), + content: vec![ContentBlock::text(value.to_string())], + structured_content: Some(value), + is_error: Some(false), + meta: None, + } + } + /// Create an error tool result with structured content + /// + /// # Example + /// + /// ```rust,ignore + /// use rmcp::model::CallToolResult; + /// use serde_json::json; + /// + /// let result = CallToolResult::structured_error(json!({ + /// "error_code": "INVALID_INPUT", + /// "message": "Temperature value out of range", + /// "details": { + /// "min": -50, + /// "max": 50, + /// "provided": 100 + /// } + /// })); + /// ``` + pub fn structured_error(value: Value) -> Self { + CallToolResult { + result_type: Some(ResultType::COMPLETE), + content: vec![ContentBlock::text(value.to_string())], + structured_content: Some(value), + is_error: Some(true), + meta: None, + } + } + + /// Set the metadata on this result + pub fn with_meta(mut self, meta: Option) -> Self { + self.meta = meta; + self + } + + /// Convert the `structured_content` part of response into a certain type. + /// + /// # About json schema validation + /// Since rust is a strong type language, we don't need to do json schema validation here. + /// + /// But if you do have to validate the response data, you can use [`jsonschema`](https://crates.io/crates/jsonschema) crate. + pub fn into_typed(self) -> Result + where + T: DeserializeOwned, + { + let raw_text = match (self.structured_content, &self.content.first()) { + (Some(value), _) => return serde_json::from_value(value), + (None, Some(contents)) => { + if let Some(text) = contents.as_text() { + let text = &text.text; + Some(text) + } else { + None + } + } + (None, None) => None, + }; + if let Some(text) = raw_text { + return serde_json::from_str(text); + } + serde_json::from_value(serde_json::Value::Null) + } +} + +const_string!(ListToolsRequestMethod = "tools/list"); +/// Request to list all available tools from a server +pub type ListToolsRequest = RequestOptionalParam; + +paginated_result!( + ListToolsResult { + tools: Vec + } +); + +const_string!(CallToolRequestMethod = "tools/call"); +/// Parameters for calling a tool provided by an MCP server. +/// +/// Contains the tool name and optional arguments needed to execute +/// the tool operation. +#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct CallToolRequestParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// The name of the tool to call + pub name: Cow<'static, str>, + /// Arguments to pass to the tool (must match the tool's input schema) + #[serde(skip_serializing_if = "Option::is_none")] + pub arguments: Option, + /// Client responses to server-initiated input requests from a previous + /// [`InputRequiredResult`]. Present only when retrying after an incomplete result. + #[serde(skip_serializing_if = "Option::is_none")] + pub input_responses: Option, + /// Opaque request state echoed back from a previous [`InputRequiredResult`]. + /// Clients MUST return this value exactly as received. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_state: Option, +} + +impl CallToolRequestParams { + /// Creates a new `CallToolRequestParams` with the given tool name. + pub fn new(name: impl Into>) -> Self { + Self { + meta: None, + name: name.into(), + arguments: None, + input_responses: None, + request_state: None, + } + } + + /// Sets the arguments for this tool call. + pub fn with_arguments(mut self, arguments: JsonObject) -> Self { + self.arguments = Some(arguments); + self + } + + /// Sets the input responses for an MRTR retry. + pub fn with_input_responses(mut self, input_responses: InputResponses) -> Self { + self.input_responses = Some(input_responses); + self + } + + /// Sets the request state for an MRTR retry. + pub fn with_request_state(mut self, request_state: impl Into) -> Self { + self.request_state = Some(request_state.into()); + self + } +} + +impl RequestParamsMeta for CallToolRequestParams { + fn meta(&self) -> Option<&RequestMetaObject> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// Request to call a specific tool +pub type CallToolRequest = Request; + +/// Result of sampling/createMessage (SEP-1577). +/// The result of a sampling/createMessage request containing the generated response. +/// +/// This structure contains the generated message along with metadata about +/// how the generation was performed and why it stopped. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] +pub struct CreateMessageResult { + /// The identifier of the model that generated the response + pub model: String, + /// The reason why generation stopped (e.g., "endTurn", "maxTokens") + #[serde(skip_serializing_if = "Option::is_none")] + pub stop_reason: Option, + /// The generated message with role and content + #[serde(flatten)] + pub message: SamplingMessage, +} + +impl CreateMessageResult { + /// Create a new CreateMessageResult with required fields. + pub fn new(message: SamplingMessage, model: String) -> Self { + Self { + message, + model, + stop_reason: None, + } + } + + pub const STOP_REASON_END_TURN: &str = "endTurn"; + pub const STOP_REASON_END_SEQUENCE: &str = "stopSequence"; + pub const STOP_REASON_END_MAX_TOKEN: &str = "maxTokens"; + pub const STOP_REASON_TOOL_USE: &str = "toolUse"; + + /// Set the stop reason. + pub fn with_stop_reason(mut self, stop_reason: impl Into) -> Self { + self.stop_reason = Some(stop_reason.into()); + self + } + + /// Set the model identifier. + pub fn with_model(mut self, model: impl Into) -> Self { + self.model = model.into(); + self + } + + /// Validate the result per SEP-1577: role must be "assistant". + pub fn validate(&self) -> Result<(), String> { + if self.message.role != Role::Assistant { + return Err("CreateMessageResult role must be 'assistant'".into()); + } + Ok(()) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct GetPromptResult { + /// Result type discriminator (SEP-2322). Required by the [spec schema] + /// for servers implementing protocol version `2026-07-28`, but optional + /// here because this type also models results from older protocol + /// versions, which do not carry the field: `None` means absent on the + /// wire, and per the spec "the client MUST treat the absent field as + /// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`; + /// the server handler clears the field when responding to peers that + /// negotiated an older version. + /// + /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub messages: Vec, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl Default for GetPromptResult { + fn default() -> Self { + Self::new(Vec::new()) + } +} + +impl GetPromptResult { + /// Create a new GetPromptResult with required fields. + pub fn new(messages: Vec) -> Self { + Self { + result_type: Some(ResultType::COMPLETE), + description: None, + messages, + meta: None, + } + } + + /// Set the description + pub fn with_description>(mut self, description: D) -> Self { + self.description = Some(description.into()); + self + } +} + +// ============================================================================= +// TASK MANAGEMENT (SEP-2663 Tasks extension: `io.modelcontextprotocol/tasks`) +// ============================================================================= + +const_string!(GetTaskMethod = "tasks/get"); +pub type GetTaskRequest = Request; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct GetTaskParams { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Identifier of the task to query. + pub task_id: String, +} + +impl GetTaskParams { + pub fn new(task_id: impl Into) -> Self { + Self { + meta: None, + task_id: task_id.into(), + } + } +} + +impl RequestParamsMeta for GetTaskParams { + fn meta(&self) -> Option<&RequestMetaObject> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +const_string!(UpdateTaskMethod = "tasks/update"); +pub type UpdateTaskRequest = Request; + +/// Parameters for `tasks/update` (SEP-2663): deliver responses to outstanding +/// in-task server-to-client requests surfaced via `tasks/get` `inputRequests`. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct UpdateTaskParams { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Identifier of the task to update. + pub task_id: String, + /// Responses to outstanding `inputRequests` previously surfaced by the + /// server. Each key MUST correspond to a currently-outstanding + /// `inputRequests` key. + pub input_responses: InputResponses, +} + +impl UpdateTaskParams { + pub fn new(task_id: impl Into, input_responses: InputResponses) -> Self { + Self { + meta: None, + task_id: task_id.into(), + input_responses, + } + } +} + +impl RequestParamsMeta for UpdateTaskParams { + fn meta(&self) -> Option<&RequestMetaObject> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +const_string!(CancelTaskMethod = "tasks/cancel"); +pub type CancelTaskRequest = Request; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct CancelTaskParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + pub task_id: String, +} + +impl CancelTaskParams { + pub fn new(task_id: impl Into) -> Self { + Self { + meta: None, + task_id: task_id.into(), + } + } +} + +impl RequestParamsMeta for CancelTaskParams { + fn meta(&self) -> Option<&RequestMetaObject> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +// --------------------------------------------------------------------------- +// Task status notification (SEP-2663 `notifications/tasks`) +// --------------------------------------------------------------------------- +const_string!(TaskStatusNotificationMethod = "notifications/tasks"); + +/// Parameters for a task status notification (spec `TaskStatusNotificationParams`). +/// +/// Carries a complete [`DetailedTask`] for the current status, identical to +/// what `tasks/get` would have returned at that moment. The task fields are +/// flattened at the top level: `NotificationParams & Task`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct TaskStatusNotificationParams { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + #[serde(flatten)] + pub task: crate::model::DetailedTask, +} + +impl TaskStatusNotificationParams { + pub fn new(task: crate::model::DetailedTask) -> Self { + Self { meta: None, task } + } + + pub fn with_meta(mut self, meta: NotificationMetaObject) -> Self { + self.meta = Some(meta); + self + } +} + +impl From for TaskStatusNotificationParams { + fn from(task: crate::model::DetailedTask) -> Self { + Self::new(task) + } +} + +impl Deref for TaskStatusNotificationParams { + type Target = crate::model::DetailedTask; + + fn deref(&self) -> &Self::Target { + &self.task + } +} + +impl DerefMut for TaskStatusNotificationParams { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.task + } +} + +pub type TaskStatusNotification = + Notification; + +// ============================================================================= +// MESSAGE TYPE UNIONS +// ============================================================================= + +macro_rules! ts_union { + ( + export type $U:ident = + $($rest:tt)* + ) => { + ts_union!(@declare $U { $($rest)* }); + ts_union!(@impl_from $U { $($rest)* }); + }; + (@declare $U:ident { $($variant:tt)* }) => { + ts_union!(@declare_variant $U { } {$($variant)*} ); + }; + (@declare_variant $U:ident { $($declared:tt)* } {$(|)? box $V:ident $($rest:tt)*}) => { + ts_union!(@declare_variant $U { $($declared)* $V(Box<$V>), } {$($rest)*}); + }; + (@declare_variant $U:ident { $($declared:tt)* } {$(|)? $V:ident $($rest:tt)*}) => { + ts_union!(@declare_variant $U { $($declared)* $V($V), } {$($rest)*}); + }; + (@declare_variant $U:ident { $($declared:tt)* } { ; }) => { + ts_union!(@declare_end $U { $($declared)* } ); + }; + (@declare_end $U:ident { $($declared:tt)* }) => { + #[derive(Debug, Serialize, Deserialize, Clone)] + #[serde(untagged)] + #[allow(clippy::large_enum_variant)] + #[non_exhaustive] + #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] + pub enum $U { + $($declared)* + } + }; + (@impl_from $U: ident {$(|)? box $V:ident $($rest:tt)*}) => { + impl From<$V> for $U { + fn from(value: $V) -> Self { + $U::$V(Box::new(value)) + } + } + ts_union!(@impl_from $U {$($rest)*}); + }; + (@impl_from $U: ident {$(|)? $V:ident $($rest:tt)*}) => { + impl From<$V> for $U { + fn from(value: $V) -> Self { + $U::$V(value) + } + } + ts_union!(@impl_from $U {$($rest)*}); + }; + (@impl_from $U: ident { ; }) => {}; + (@impl_from $U: ident { }) => {}; +} + +ts_union!( + export type ClientRequest = + | PingRequest + | InitializeRequest + | DiscoverRequest + | CompleteRequest + | SetLevelRequest + | GetPromptRequest + | ListPromptsRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | SubscriptionsListenRequest + | SubscribeRequest + | UnsubscribeRequest + | CallToolRequest + | ListToolsRequest + | GetTaskRequest + | UpdateTaskRequest + | CancelTaskRequest + | CustomRequest; +); + +impl ClientRequest { + pub fn method(&self) -> &str { + match &self { + ClientRequest::PingRequest(r) => r.method.as_str(), + ClientRequest::InitializeRequest(r) => r.method.as_str(), + ClientRequest::DiscoverRequest(r) => r.method.as_str(), + ClientRequest::CompleteRequest(r) => r.method.as_str(), + ClientRequest::SetLevelRequest(r) => r.method.as_str(), + ClientRequest::GetPromptRequest(r) => r.method.as_str(), + ClientRequest::ListPromptsRequest(r) => r.method.as_str(), + ClientRequest::ListResourcesRequest(r) => r.method.as_str(), + ClientRequest::ListResourceTemplatesRequest(r) => r.method.as_str(), + ClientRequest::ReadResourceRequest(r) => r.method.as_str(), + ClientRequest::SubscriptionsListenRequest(r) => r.method.as_str(), + ClientRequest::SubscribeRequest(r) => r.method.as_str(), + ClientRequest::UnsubscribeRequest(r) => r.method.as_str(), + ClientRequest::CallToolRequest(r) => r.method.as_str(), + ClientRequest::ListToolsRequest(r) => r.method.as_str(), + ClientRequest::GetTaskRequest(r) => r.method.as_str(), + ClientRequest::UpdateTaskRequest(r) => r.method.as_str(), + ClientRequest::CancelTaskRequest(r) => r.method.as_str(), + ClientRequest::CustomRequest(r) => r.method.as_str(), + } + } +} + +ts_union!( + export type ClientNotification = + | CancelledNotification + | ProgressNotification + | InitializedNotification + | RootsListChangedNotification + | CustomNotification; +); + +ts_union!( + export type ClientResult = + box CreateMessageResult + | ListRootsResult + | ElicitResult + | EmptyResult + | CustomResult; +); + +impl ClientResult { + pub fn empty(_: ()) -> ClientResult { + ClientResult::EmptyResult(EmptyResult {}) + } +} + +pub type ClientJsonRpcMessage = JsonRpcMessage; + +ts_union!( + export type ServerRequest = + | PingRequest + | CreateMessageRequest + | ListRootsRequest + | ElicitRequest + | CustomRequest; +); + +ts_union!( + export type ServerNotification = + | CancelledNotification + | ProgressNotification + | LoggingMessageNotification + | ResourceUpdatedNotification + | ResourceListChangedNotification + | ToolListChangedNotification + | PromptListChangedNotification + | SubscriptionsAcknowledgedNotification + | TaskStatusNotification + | CustomNotification; +); + +ts_union!( + export type ServerResult = + | DiscoverResult + | InitializeResult + | CompleteResult + | GetPromptResult + | ListPromptsResult + | ListResourcesResult + | ListResourceTemplatesResult + | ReadResourceResult + | SubscriptionsListenResult + | ListToolsResult + | ElicitResult + | CreateTaskResult + | GetTaskResult + | CallToolResult + | InputRequiredResult + // TaskAckResult must come after CallToolResult/InputRequiredResult in this + // untagged union: it only carries `resultType`, so it would otherwise + // shadow any result that includes `resultType: "complete"`. + | TaskAckResult + | EmptyResult + | CustomResult + ; +); + +impl ServerResult { + pub fn empty(_: ()) -> ServerResult { + ServerResult::EmptyResult(EmptyResult {}) + } + + /// Empty `tasks/update` / `tasks/cancel` acknowledgement carrying the + /// SEP-2322 `resultType: "complete"` discriminator (SEP-2663). + pub fn task_ack(_: ()) -> ServerResult { + ServerResult::TaskAckResult(TaskAckResult::new()) + } + + /// Strip the SEP-2322 `resultType: "complete"` discriminator so the result + /// keeps the wire shape that predates protocol version `2026-07-28`. + /// + /// The server handler calls this before responding to a peer that + /// negotiated an older protocol version, where the field did not exist and + /// strict peers may reject it. Only the `"complete"` value is stripped: + /// results whose discriminator carries meaning (`"input_required"`, + /// `"task"`) are already gated to `2026-07-28`+ sessions, and custom + /// extension values are preserved. + /// + /// # Examples + /// + /// ``` + /// use rmcp::model::{CallToolResult, ServerResult}; + /// + /// let mut result = ServerResult::CallToolResult(CallToolResult::success(vec![])); + /// result.strip_result_type_for_legacy_peer(); + /// + /// let json = serde_json::to_value(&result).unwrap(); + /// assert!(json.get("resultType").is_none()); + /// ``` + pub fn strip_result_type_for_legacy_peer(&mut self) { + let result_type = match self { + ServerResult::CompleteResult(r) => &mut r.result_type, + ServerResult::GetPromptResult(r) => &mut r.result_type, + ServerResult::ListPromptsResult(r) => &mut r.result_type, + ServerResult::ListResourcesResult(r) => &mut r.result_type, + ServerResult::ListResourceTemplatesResult(r) => &mut r.result_type, + ServerResult::ReadResourceResult(r) => &mut r.result_type, + ServerResult::ListToolsResult(r) => &mut r.result_type, + ServerResult::CallToolResult(r) => &mut r.result_type, + _ => return, + }; + result_type.take_if(|result_type| result_type.is_complete()); + } +} + +pub type ServerJsonRpcMessage = JsonRpcMessage; + +impl TryInto for ServerNotification { + type Error = ServerNotification; + fn try_into(self) -> Result { + if let ServerNotification::CancelledNotification(t) = self { + Ok(t) + } else { + Err(self) + } + } +} + +impl TryInto for ClientNotification { + type Error = ClientNotification; + fn try_into(self) -> Result { + if let ClientNotification::CancelledNotification(t) = self { + Ok(t) + } else { + Err(self) + } + } +} + +// ============================================================================= +// TESTS +// ============================================================================= + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[cfg(feature = "transport-streamable-http-client")] + #[test] + fn transport_closed_marker_accepts_only_the_process_local_token() { + let local = ErrorData::transport_closed("closed"); + let spoofed = ErrorData::internal_error( + "spoofed", + Some(json!({ "io.modelcontextprotocol/transportClosed": true })), + ); + + assert!(local.is_transport_closed()); + assert!(!spoofed.is_transport_closed()); + } + + #[test] + fn cancelled_notification_request_id_is_optional_on_wire() { + // None → requestId 생략 + let p = CancelledNotificationParam::new(None, Some("user cancelled".into())); + let v = serde_json::to_value(&p).unwrap(); + assert!(v.get("requestId").is_none()); + + // Some → requestId 방출 + 라운드트립 + let p = CancelledNotificationParam::new(Some(RequestId::Number(1)), None); + let v = serde_json::to_value(&p).unwrap(); + assert_eq!(v["requestId"], json!(1)); + let back: CancelledNotificationParam = serde_json::from_value(v).unwrap(); + assert_eq!(back.request_id, Some(RequestId::Number(1))); + } + + #[test] + fn test_notification_serde() { + let raw = json!( { + "jsonrpc": JsonRpcVersion2_0, + "method": InitializedNotificationMethod, + }); + let message: ClientJsonRpcMessage = + serde_json::from_value(raw.clone()).expect("invalid notification"); + match &message { + ClientJsonRpcMessage::Notification(JsonRpcNotification { + notification: ClientNotification::InitializedNotification(_n), + .. + }) => {} + _ => panic!("Expected Notification"), + } + let json = serde_json::to_value(message).expect("valid json"); + assert_eq!(json, raw); + } + + #[test] + fn test_custom_client_notification_roundtrip() { + let raw = json!( { + "jsonrpc": JsonRpcVersion2_0, + "method": "notifications/custom", + "params": {"foo": "bar"}, + }); + + let message: ClientJsonRpcMessage = + serde_json::from_value(raw.clone()).expect("invalid notification"); + match &message { + ClientJsonRpcMessage::Notification(JsonRpcNotification { + notification: ClientNotification::CustomNotification(notification), + .. + }) => { + assert_eq!(notification.method, "notifications/custom"); + assert_eq!( + notification + .params + .as_ref() + .and_then(|p| p.get("foo")) + .expect("foo present"), + "bar" + ); + } + _ => panic!("Expected custom client notification"), + } + + let json = serde_json::to_value(message).expect("valid json"); + assert_eq!(json, raw); + } + + #[test] + fn test_custom_server_notification_roundtrip() { + let raw = json!( { + "jsonrpc": JsonRpcVersion2_0, + "method": "notifications/custom-server", + "params": {"hello": "world"}, + }); + + let message: ServerJsonRpcMessage = + serde_json::from_value(raw.clone()).expect("invalid notification"); + match &message { + ServerJsonRpcMessage::Notification(JsonRpcNotification { + notification: ServerNotification::CustomNotification(notification), + .. + }) => { + assert_eq!(notification.method, "notifications/custom-server"); + assert_eq!( + notification + .params + .as_ref() + .and_then(|p| p.get("hello")) + .expect("hello present"), + "world" + ); + } + _ => panic!("Expected custom server notification"), + } + + let json = serde_json::to_value(message).expect("valid json"); + assert_eq!(json, raw); + } + + #[test] + fn test_custom_request_roundtrip() { + let raw = json!( { + "jsonrpc": JsonRpcVersion2_0, + "id": 42, + "method": "requests/custom", + "params": {"foo": "bar"}, + }); + + let message: ClientJsonRpcMessage = + serde_json::from_value(raw.clone()).expect("invalid request"); + match &message { + ClientJsonRpcMessage::Request(JsonRpcRequest { id, request, .. }) => { + assert_eq!(id, &RequestId::Number(42)); + match request { + ClientRequest::CustomRequest(custom) => { + let expected_request = json!({ + "method": "requests/custom", + "params": {"foo": "bar"}, + }); + let actual_request = + serde_json::to_value(custom).expect("serialize custom request"); + assert_eq!(actual_request, expected_request); + } + other => panic!("Expected custom request, got: {other:?}"), + } + } + other => panic!("Expected request, got: {other:?}"), + } + + let json = serde_json::to_value(message).expect("valid json"); + assert_eq!(json, raw); + } + + #[test] + fn custom_server_result_with_generic_meta_is_not_a_tool_result() { + let raw = json!({ + "resultType": "complete", + "skills": [{ + "uri": "skill://example/SKILL.md", + "frontmatter": {"name": "example", "description": "example skill"} + }], + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "skills-server", + "version": "1.0.0" + } + } + }); + + let result: ServerResult = serde_json::from_value(raw.clone()).expect("custom result"); + match result { + ServerResult::CustomResult(CustomResult(value)) => assert_eq!(value, raw), + other => panic!("expected CustomResult, got {other:?}"), + } + } + + #[test] + fn call_tool_result_with_meta_and_tool_content_remains_a_tool_result() { + let raw = json!({ + "resultType": "complete", + "content": [], + "_meta": {"trace": "present"} + }); + + let result: ServerResult = serde_json::from_value(raw).expect("tool result"); + assert!(matches!(result, ServerResult::CallToolResult(_))); + } + + #[test] + fn test_request_conversion() { + let raw = json!( { + "jsonrpc": JsonRpcVersion2_0, + "id": 1, + "method": "request", + "params": {"key": "value"}, + }); + let message: JsonRpcMessage = serde_json::from_value(raw.clone()).expect("invalid request"); + + match &message { + JsonRpcMessage::Request(r) => { + assert_eq!(r.id, RequestId::Number(1)); + assert_eq!(r.request.method, "request"); + assert_eq!( + &r.request.params, + json!({"key": "value"}) + .as_object() + .expect("should be an object") + ); + } + _ => panic!("Expected Request"), + } + let json = serde_json::to_value(&message).expect("valid json"); + assert_eq!(json, raw); + } + + #[test] + fn test_initial_request_response_serde() { + let request = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": { + "roots": { + "listChanged": true + }, + "sampling": {} + }, + "clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + } + } + }); + let raw_response_json = json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": { + "logging": {}, + "prompts": { + "listChanged": true + }, + "resources": { + "subscribe": true, + "listChanged": true + }, + "tools": { + "listChanged": true + } + }, + "serverInfo": { + "name": "ExampleServer", + "version": "1.0.0" + } + } + }); + let request: ClientJsonRpcMessage = + serde_json::from_value(request.clone()).expect("invalid request"); + let (request, id) = request.into_request().expect("should be a request"); + assert_eq!(id, RequestId::Number(1)); + match request { + ClientRequest::InitializeRequest(Request { + method: _, + params: + InitializeRequestParams { + meta: _, + protocol_version: _, + capabilities, + client_info, + }, + .. + }) => { + assert_eq!(capabilities.roots.unwrap().list_changed, Some(true)); + let sampling = capabilities.sampling.unwrap(); + assert_eq!(sampling.tools, None); + assert_eq!(sampling.context, None); + assert_eq!(client_info.name, "ExampleClient"); + assert_eq!(client_info.version, "1.0.0"); + } + _ => panic!("Expected InitializeRequest"), + } + let server_response: ServerJsonRpcMessage = + serde_json::from_value(raw_response_json.clone()).expect("invalid response"); + let (response, id) = server_response + .clone() + .into_response() + .expect("expect response"); + assert_eq!(id, RequestId::Number(1)); + match response { + ServerResult::InitializeResult(InitializeResult { + protocol_version: _, + capabilities, + server_info, + instructions, + .. + }) => { + assert_eq!(capabilities.logging.unwrap().len(), 0); + assert_eq!(capabilities.prompts.unwrap().list_changed, Some(true)); + assert_eq!( + capabilities.resources.as_ref().unwrap().subscribe, + Some(true) + ); + assert_eq!(capabilities.resources.unwrap().list_changed, Some(true)); + assert_eq!(capabilities.tools.unwrap().list_changed, Some(true)); + assert_eq!(server_info.name, "ExampleServer"); + assert_eq!(server_info.version, "1.0.0"); + assert_eq!(server_info.icons, None); + assert_eq!(instructions, None); + } + other => panic!("Expected InitializeResult, got {other:?}"), + } + + let server_response_json: Value = serde_json::to_value(&server_response).expect("msg"); + + assert_eq!(server_response_json, raw_response_json); + } + + #[test] + fn test_negative_and_large_request_ids() { + // Test negative ID + let negative_id_json = json!({ + "jsonrpc": "2.0", + "id": -1, + "method": "test", + "params": {} + }); + + let message: JsonRpcMessage = + serde_json::from_value(negative_id_json.clone()).expect("Should parse negative ID"); + + match &message { + JsonRpcMessage::Request(r) => { + assert_eq!(r.id, RequestId::Number(-1)); + } + _ => panic!("Expected Request"), + } + + // Test roundtrip serialization + let serialized = serde_json::to_value(&message).expect("Should serialize"); + assert_eq!(serialized, negative_id_json); + + // Test large negative ID + let large_negative_json = json!({ + "jsonrpc": "2.0", + "id": -9007199254740991i64, // JavaScript's MIN_SAFE_INTEGER + "method": "test", + "params": {} + }); + + let message: JsonRpcMessage = serde_json::from_value(large_negative_json.clone()) + .expect("Should parse large negative ID"); + + match &message { + JsonRpcMessage::Request(r) => { + assert_eq!(r.id, RequestId::Number(-9007199254740991i64)); + } + _ => panic!("Expected Request"), + } + + // Test large positive ID (JavaScript's MAX_SAFE_INTEGER) + let large_positive_json = json!({ + "jsonrpc": "2.0", + "id": 9007199254740991i64, + "method": "test", + "params": {} + }); + + let message: JsonRpcMessage = serde_json::from_value(large_positive_json.clone()) + .expect("Should parse large positive ID"); + + match &message { + JsonRpcMessage::Request(r) => { + assert_eq!(r.id, RequestId::Number(9007199254740991i64)); + } + _ => panic!("Expected Request"), + } + + // Test zero ID + let zero_id_json = json!({ + "jsonrpc": "2.0", + "id": 0, + "method": "test", + "params": {} + }); + + let message: JsonRpcMessage = + serde_json::from_value(zero_id_json.clone()).expect("Should parse zero ID"); + + match &message { + JsonRpcMessage::Request(r) => { + assert_eq!(r.id, RequestId::Number(0)); + } + _ => panic!("Expected Request"), + } + } + + #[test] + fn test_protocol_version_order() { + let v1 = ProtocolVersion::V_2024_11_05; + let v2 = ProtocolVersion::V_2025_03_26; + let v3 = ProtocolVersion::V_2025_06_18; + let v4 = ProtocolVersion::V_2025_11_25; + assert!(v1 < v2); + assert!(v2 < v3); + assert!(v3 < v4); + } + + #[test] + fn test_icon_serialization() { + let icon = Icon { + src: "https://example.com/icon.png".to_string(), + mime_type: Some("image/png".to_string()), + sizes: Some(vec!["48x48".to_string()]), + theme: Some(IconTheme::Light), + }; + + let json = serde_json::to_value(&icon).unwrap(); + assert_eq!(json["src"], "https://example.com/icon.png"); + assert_eq!(json["mimeType"], "image/png"); + assert_eq!(json["sizes"][0], "48x48"); + assert_eq!(json["theme"], "light"); + + // Test deserialization + let deserialized: Icon = serde_json::from_value(json).unwrap(); + assert_eq!(deserialized, icon); + } + + #[test] + fn test_icon_minimal() { + let icon = Icon { + src: "data:image/svg+xml;base64,PHN2Zy8+".to_string(), + mime_type: None, + sizes: None, + theme: None, + }; + + let json = serde_json::to_value(&icon).unwrap(); + assert_eq!(json["src"], "data:image/svg+xml;base64,PHN2Zy8+"); + assert!(json.get("mimeType").is_none()); + assert!(json.get("sizes").is_none()); + assert!(json.get("theme").is_none()); + } + + #[test] + fn test_implementation_with_icons() { + let implementation = Implementation { + name: "test-server".to_string(), + title: Some("Test Server".to_string()), + version: "1.0.0".to_string(), + description: Some("A test server for unit testing".to_string()), + icons: Some(vec![ + Icon { + src: "https://example.com/icon.png".to_string(), + mime_type: Some("image/png".to_string()), + sizes: Some(vec!["48x48".to_string()]), + theme: Some(IconTheme::Dark), + }, + Icon { + src: "https://example.com/icon.svg".to_string(), + mime_type: Some("image/svg+xml".to_string()), + sizes: Some(vec!["any".to_string()]), + theme: Some(IconTheme::Light), + }, + ]), + website_url: Some("https://example.com".to_string()), + }; + + let json = serde_json::to_value(&implementation).unwrap(); + assert_eq!(json["name"], "test-server"); + assert_eq!(json["description"], "A test server for unit testing"); + assert_eq!(json["websiteUrl"], "https://example.com"); + assert!(json["icons"].is_array()); + assert_eq!(json["icons"][0]["src"], "https://example.com/icon.png"); + assert_eq!(json["icons"][0]["sizes"][0], "48x48"); + assert_eq!(json["icons"][1]["mimeType"], "image/svg+xml"); + assert_eq!(json["icons"][1]["sizes"][0], "any"); + assert_eq!(json["icons"][0]["theme"], "dark"); + assert_eq!(json["icons"][1]["theme"], "light"); + } + + #[test] + fn test_backward_compatibility() { + // Test that old JSON without icons still deserializes correctly + let old_json = json!({ + "name": "legacy-server", + "version": "0.9.0" + }); + + let implementation: Implementation = serde_json::from_value(old_json).unwrap(); + assert_eq!(implementation.name, "legacy-server"); + assert_eq!(implementation.version, "0.9.0"); + assert_eq!(implementation.description, None); + assert_eq!(implementation.icons, None); + assert_eq!(implementation.website_url, None); + } + + #[test] + fn test_initialize_with_icons() { + let init_result = InitializeResult { + protocol_version: ProtocolVersion::default(), + capabilities: ServerCapabilities::default(), + server_info: Implementation { + name: "icon-server".to_string(), + title: None, + version: "2.0.0".to_string(), + description: None, + icons: Some(vec![Icon { + src: "https://example.com/server.png".to_string(), + mime_type: Some("image/png".to_string()), + sizes: Some(vec!["48x48".to_string()]), + theme: Some(IconTheme::Light), + }]), + website_url: Some("https://docs.example.com".to_string()), + }, + instructions: None, + meta: None, + }; + + let json = serde_json::to_value(&init_result).unwrap(); + assert!(json["serverInfo"]["icons"].is_array()); + assert_eq!( + json["serverInfo"]["icons"][0]["src"], + "https://example.com/server.png" + ); + assert_eq!(json["serverInfo"]["icons"][0]["sizes"][0], "48x48"); + assert_eq!(json["serverInfo"]["icons"][0]["theme"], "light"); + assert_eq!(json["serverInfo"]["websiteUrl"], "https://docs.example.com"); + } + + #[test] + fn elicitation_without_mode_deserializes_as_form() { + let json_data_without_tag = json!({ + "message": "Please provide more details.", + "requestedSchema": { + "title": "User Details", + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "integer" } + }, + "required": ["name", "age"] + } + }); + let elicitation: ElicitRequestParams = + serde_json::from_value(json_data_without_tag).expect("Deserialization failed"); + if let ElicitRequestParams::FormElicitationParams { + meta, + message, + requested_schema, + } = elicitation + { + assert_eq!(meta, None); + assert_eq!(message, "Please provide more details."); + assert_eq!(requested_schema.title, Some(Cow::from("User Details"))); + assert_eq!(requested_schema.type_, ObjectTypeConst); + } else { + panic!("Expected FormElicitationParams"); + } + } + + #[test] + fn test_elicitation_deserialization() { + let json_data_form = json!({ + "_meta": { "meta_form_key_1": "meta form value 1" }, + "mode": "form", + "message": "Please provide more details.", + "requestedSchema": { + "title": "User Details", + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "integer" } + }, + "required": ["name", "age"] + } + }); + let elicitation_form: ElicitRequestParams = + serde_json::from_value(json_data_form).expect("Deserialization failed"); + if let ElicitRequestParams::FormElicitationParams { + meta, + message, + requested_schema, + } = elicitation_form + { + assert_eq!( + meta, + Some(RequestMetaObject(MetaObject( + object!({ "meta_form_key_1": "meta form value 1" }) + ))) + ); + assert_eq!(message, "Please provide more details."); + assert_eq!(requested_schema.title, Some(Cow::from("User Details"))); + assert_eq!(requested_schema.type_, ObjectTypeConst); + } else { + panic!("Expected FormElicitationParams"); + } + + let json_data_url = json!({ + "_meta": { "meta_url_key_1": "meta url value 1" }, + "mode": "url", + "message": "Please fill out the form at the following URL.", + "url": "https://example.com/form", + "elicitationId": "elicitation-123" + }); + let elicitation_url: ElicitRequestParams = + serde_json::from_value(json_data_url).expect("Deserialization failed"); + if let ElicitRequestParams::UrlElicitationParams { + meta, + message, + url, + elicitation_id, + } = elicitation_url + { + assert_eq!( + meta, + Some(RequestMetaObject(MetaObject( + object!({ "meta_url_key_1": "meta url value 1" }) + ))) + ); + assert_eq!(message, "Please fill out the form at the following URL."); + assert_eq!(url, "https://example.com/form"); + assert_eq!(elicitation_id, "elicitation-123"); + } else { + panic!("Expected UrlElicitationParams"); + } + } + + #[test] + fn test_elicitation_serialization() { + let form_elicitation = ElicitRequestParams::FormElicitationParams { + meta: Some(RequestMetaObject(MetaObject( + object!({ "meta_form_key_1": "meta form value 1" }), + ))), + message: "Please provide more details.".to_string(), + requested_schema: ElicitationSchema::builder() + .title("User Details") + .string_property("name", |s| s) + .build() + .expect("Valid schema"), + }; + let json_form = serde_json::to_value(&form_elicitation).expect("Serialization failed"); + let expected_form_json = json!({ + "_meta": { "meta_form_key_1": "meta form value 1" }, + "mode": "form", + "message": "Please provide more details.", + "requestedSchema": { + "title":"User Details", + "type":"object", + "properties":{ + "name": { "type": "string" }, + }, + } + }); + assert_eq!(json_form, expected_form_json); + + let url_elicitation = ElicitRequestParams::UrlElicitationParams { + meta: Some(RequestMetaObject(MetaObject( + object!({ "meta_url_key_1": "meta url value 1" }), + ))), + message: "Please fill out the form at the following URL.".to_string(), + url: "https://example.com/form".to_string(), + elicitation_id: "elicitation-123".to_string(), + }; + let json_url = serde_json::to_value(&url_elicitation).expect("Serialization failed"); + let expected_url_json = json!({ + "_meta": { "meta_url_key_1": "meta url value 1" }, + "mode": "url", + "message": "Please fill out the form at the following URL.", + "url": "https://example.com/form", + "elicitationId": "elicitation-123" + }); + assert_eq!(json_url, expected_url_json); + } + + #[test] + fn notification_without_params_should_deserialize_as_bare_jsonrpc_message() { + let payload = b"{\"method\":\"notifications/initialized\",\"jsonrpc\":\"2.0\"}"; + let result: Result = serde_json::from_slice(payload); + assert!( + matches!(result, Ok(JsonRpcMessage::Notification(_))), + "Expected Ok(Notification), got: {:?}", + result + ); + } +} diff --git a/vendor/rmcp/src/model/annotated.rs b/vendor/rmcp/src/model/annotated.rs new file mode 100644 index 000000000..ac3dbd3ed --- /dev/null +++ b/vendor/rmcp/src/model/annotated.rs @@ -0,0 +1,119 @@ +//! Annotations for content blocks and resources. +//! +//! The `Annotations` struct carries optional hints about audience, priority, and freshness. +//! Individual content/resource types embed `annotations: Option` directly. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use super::Role; + +/// Optional annotations for the client. The client can use annotations to inform how objects are +/// used or displayed. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct Annotations { + #[serde(skip_serializing_if = "Option::is_none")] + pub audience: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub priority: Option, + #[serde(skip_serializing_if = "Option::is_none", rename = "lastModified")] + pub last_modified: Option, +} + +impl Annotations { + /// Creates annotations for a resource with priority and an RFC 3339 timestamp. + /// + /// # Panics + /// + /// Panics if `priority` is not in the inclusive range `0.0..=1.0`. + pub fn for_resource(priority: f32, timestamp: DateTime) -> Self { + assert!( + (0.0..=1.0).contains(&priority), + "Priority {priority} must be between 0.0 and 1.0" + ); + Self { + priority: Some(priority), + last_modified: Some(timestamp.to_rfc3339()), + audience: None, + } + } + + pub fn with_audience(mut self, audience: Vec) -> Self { + self.audience = Some(audience); + self + } + + pub fn with_priority(mut self, priority: f32) -> Self { + self.priority = Some(priority); + self + } + + /// Sets `lastModified` from a typed timestamp, serialized as RFC 3339. + pub fn with_timestamp(mut self, timestamp: DateTime) -> Self { + self.last_modified = Some(timestamp.to_rfc3339()); + self + } + + /// Sets `lastModified` to the current time, serialized as RFC 3339. + pub fn with_timestamp_now(self) -> Self { + self.with_timestamp(Utc::now()) + } +} + +#[cfg(test)] +mod tests { + use chrono::TimeZone; + use serde_json::json; + + use super::*; + + fn annotations_of(value: serde_json::Value) -> Annotations { + serde_json::from_value::(value).unwrap() + } + + #[test] + fn preserves_date_only_last_modified_verbatim() { + let annotations = annotations_of(json!({ "lastModified": "2025-01-12" })); + assert_eq!(annotations.last_modified.as_deref(), Some("2025-01-12")); + } + + #[test] + fn preserves_rfc3339_last_modified_verbatim() { + let value = "2025-01-12T15:00:58Z"; + let annotations = annotations_of(json!({ "lastModified": value })); + assert_eq!(annotations.last_modified.as_deref(), Some(value)); + } + + #[test] + fn missing_last_modified_is_none() { + let annotations = annotations_of(json!({})); + assert_eq!(annotations.last_modified, None); + } + + #[test] + fn null_last_modified_is_none() { + let annotations = annotations_of(json!({ "lastModified": null })); + assert_eq!(annotations.last_modified, None); + } + + #[test] + fn invalid_last_modified_string_is_preserved() { + let annotations = annotations_of(json!({ "lastModified": "not-a-date" })); + assert_eq!(annotations.last_modified.as_deref(), Some("not-a-date")); + } + + #[test] + fn timestamp_round_trips_as_rfc3339_string() { + let timestamp = Utc.with_ymd_and_hms(2025, 1, 12, 15, 0, 58).unwrap(); + let annotations = Annotations::default().with_timestamp(timestamp); + + let value = serde_json::to_value(&annotations).unwrap(); + assert_eq!(value["lastModified"], json!(timestamp.to_rfc3339())); + + let round_tripped: Annotations = serde_json::from_value(value).unwrap(); + assert_eq!(round_tripped, annotations); + } +} diff --git a/vendor/rmcp/src/model/capabilities.rs b/vendor/rmcp/src/model/capabilities.rs new file mode 100644 index 000000000..f014f569f --- /dev/null +++ b/vendor/rmcp/src/model/capabilities.rs @@ -0,0 +1,680 @@ +use std::collections::BTreeMap; +#[cfg(any(feature = "server", feature = "macros"))] +use std::marker::PhantomData; + +#[cfg(any(feature = "server", feature = "macros"))] +use pastey::paste; +use serde::{Deserialize, Serialize}; + +use super::JsonObject; +pub type ExperimentalCapabilities = BTreeMap; + +/// MCP extension capabilities map. +/// +/// Keys are extension identifiers in the format `{vendor-prefix}/{extension-name}` +/// (e.g., `io.modelcontextprotocol/ui`, `io.modelcontextprotocol/oauth-client-credentials`). +/// Values are per-extension settings objects. An empty object indicates support with no settings. +/// +/// # Example +/// +/// ```rust +/// use rmcp::model::ExtensionCapabilities; +/// use serde_json::json; +/// +/// let mut extensions = ExtensionCapabilities::new(); +/// extensions.insert( +/// "io.modelcontextprotocol/ui".to_string(), +/// serde_json::from_value(json!({ +/// "mimeTypes": ["text/html;profile=mcp-app"] +/// })).unwrap() +/// ); +/// ``` +pub type ExtensionCapabilities = BTreeMap; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct PromptsCapability { + #[serde(skip_serializing_if = "Option::is_none")] + pub list_changed: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct ResourcesCapability { + #[serde(skip_serializing_if = "Option::is_none")] + pub subscribe: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub list_changed: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct ToolsCapability { + #[serde(skip_serializing_if = "Option::is_none")] + pub list_changed: Option, +} + +/// Roots capability. Deprecated by SEP-2577; remains functional and will be +/// removed in a future release. +/// See . +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct RootsCapabilities { + #[serde(skip_serializing_if = "Option::is_none")] + pub list_changed: Option, +} + +/// Capability for handling elicitation requests from servers. +/// Elicitation allows servers to request interactive input from users during tool execution. +/// This capability indicates that a client can handle elicitation requests and present +/// appropriate UI to users for collecting the requested information. +/// +/// Capability for form mode elicitation. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct FormElicitationCapability { + /// Whether the client supports JSON Schema validation for elicitation responses. + /// When true, the client will validate user input against the requested_schema + /// before sending the response back to the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub schema_validation: Option, +} + +impl FormElicitationCapability { + pub fn new() -> Self { + Self::default() + } + + pub fn with_schema_validation(mut self, enabled: bool) -> Self { + self.schema_validation = Some(enabled); + self + } +} + +/// Capability for URL mode elicitation. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct UrlElicitationCapability {} + +impl UrlElicitationCapability { + pub fn new() -> Self { + Self::default() + } +} + +/// Elicitation allows servers to request interactive input from users during tool execution. +/// This capability indicates that a client can handle elicitation requests and present +/// appropriate UI to users for collecting the requested information. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct ElicitationCapability { + /// Whether client supports form-based elicitation. + #[serde(skip_serializing_if = "Option::is_none")] + pub form: Option, + /// Whether client supports URL-based elicitation. + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +impl ElicitationCapability { + pub fn new() -> Self { + Self::default() + } + + pub fn with_form(mut self, form: FormElicitationCapability) -> Self { + self.form = Some(form); + self + } + + pub fn with_url(mut self, url: UrlElicitationCapability) -> Self { + self.url = Some(url); + self + } +} + +/// Sampling capability with optional sub-capabilities (SEP-1577). +/// +/// Deprecated by SEP-2577; remains functional and will be removed in a future +/// release. +/// See . +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct SamplingCapability { + /// Support for `tools` and `toolChoice` parameters + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option, + /// Support for `includeContext` (soft-deprecated) + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, +} + +/// +/// # Builder +/// ```rust +/// # use rmcp::model::ClientCapabilities; +/// let cap = ClientCapabilities::builder() +/// .enable_experimental() +/// .build(); +/// ``` +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct ClientCapabilities { + #[serde(skip_serializing_if = "Option::is_none")] + pub experimental: Option, + /// Optional MCP extensions that the client supports (SEP-1724). + /// Keys are extension identifiers (e.g., `"io.modelcontextprotocol/ui"`), + /// values are per-extension settings objects. An empty object indicates + /// support with no settings. + #[serde(skip_serializing_if = "Option::is_none")] + pub extensions: Option, + /// Capability for filesystem roots (deprecated by SEP-2577). + #[serde(skip_serializing_if = "Option::is_none")] + pub roots: Option, + /// Capability for LLM sampling requests (SEP-1577, deprecated by SEP-2577). + #[serde(skip_serializing_if = "Option::is_none")] + pub sampling: Option, + /// Capability to handle elicitation requests from servers for interactive user input + #[serde(skip_serializing_if = "Option::is_none")] + pub elicitation: Option, +} + +impl ClientCapabilities { + /// Returns `true` if the `io.modelcontextprotocol/tasks` extension + /// (SEP-2663) is declared in [`Self::extensions`]. + pub fn supports_tasks(&self) -> bool { + self.extensions + .as_ref() + .is_some_and(|e| e.contains_key(super::TASKS_EXTENSION_ID)) + } +} + +/// +/// ## Builder +/// ```rust +/// # use rmcp::model::ServerCapabilities; +/// let cap = ServerCapabilities::builder() +/// .enable_experimental() +/// .enable_prompts() +/// .enable_resources() +/// .enable_tools() +/// .enable_tool_list_changed() +/// .build(); +/// ``` +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct ServerCapabilities { + #[serde(skip_serializing_if = "Option::is_none")] + pub experimental: Option, + /// Optional MCP extensions that the server supports (SEP-1724). + /// Keys are extension identifiers (e.g., `"io.modelcontextprotocol/apps"`), + /// values are per-extension settings objects. An empty object indicates + /// support with no settings. + #[serde(skip_serializing_if = "Option::is_none")] + pub extensions: Option, + /// Capability for server log message notifications (deprecated by SEP-2577). + #[serde(skip_serializing_if = "Option::is_none")] + pub logging: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub completions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resources: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option, +} + +impl ServerCapabilities { + /// Returns `true` if the `io.modelcontextprotocol/tasks` extension + /// (SEP-2663) is declared in [`Self::extensions`]. + pub fn supports_tasks(&self) -> bool { + self.extensions + .as_ref() + .is_some_and(|e| e.contains_key(super::TASKS_EXTENSION_ID)) + } +} + +#[cfg(any(feature = "server", feature = "macros"))] +macro_rules! builder { + ($Target: ident {$($(#[$fa:meta])* $f: ident: $T: ty),* $(,)?}) => { + paste! { + #[derive(Default, Clone, Copy, Debug)] + #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] + pub struct [<$Target BuilderState>]< + $(const [<$f:upper>]: bool = false,)* + >; + #[derive(Debug, Default)] + #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] + pub struct [<$Target Builder>]]> { + $(pub $f: Option<$T>,)* + pub state: PhantomData + } + impl $Target { + #[doc = "Create a new [`" $Target "`] builder."] + pub fn builder() -> [<$Target Builder>] { + <[<$Target Builder>]>::default() + } + } + impl [<$Target Builder>] { + pub fn build(self) -> $Target { + $Target { + $( $f: self.$f, )* + } + } + } + impl From<[<$Target Builder>]> for $Target { + fn from(builder: [<$Target Builder>]) -> Self { + builder.build() + } + } + } + builder!($Target @toggle $($(#[$fa])* $f: $T,)*); + + }; + ($Target: ident @toggle $(#[$fa0:meta])* $f0: ident: $T0: ty, $($(#[$fa:meta])* $f: ident: $T: ty,)*) => { + builder!($Target @toggle [][$(#[$fa0])* $f0: $T0][$($(#[$fa])* $f: $T,)*]); + }; + ($Target: ident @toggle [$($ff: ident: $Tf: ty,)*][$(#[$fna:meta])* $fn: ident: $TN: ty][$(#[$fn1a:meta])* $fn_1: ident: $Tn_1: ty, $($(#[$fta:meta])* $ft: ident: $Tt: ty,)*]) => { + builder!($Target @impl_toggle [$($ff: $Tf,)*][$(#[$fna])* $fn: $TN][$fn_1: $Tn_1, $($ft:$Tt,)*]); + builder!($Target @toggle [$($ff: $Tf,)* $fn: $TN,][$(#[$fn1a])* $fn_1: $Tn_1][$($(#[$fta])* $ft: $Tt,)*]); + }; + ($Target: ident @toggle [$($ff: ident: $Tf: ty,)*][$(#[$fna:meta])* $fn: ident: $TN: ty][]) => { + builder!($Target @impl_toggle [$($ff: $Tf,)*][$(#[$fna])* $fn: $TN][]); + }; + ($Target: ident @impl_toggle [$($ff: ident: $Tf: ty,)*][$(#[$fna:meta])* $fn: ident: $TN: ty][$($ft: ident: $Tt: ty,)*]) => { + paste! { + impl< + $(const [<$ff:upper>]: bool,)* + $(const [<$ft:upper>]: bool,)* + > [<$Target Builder>]<[<$Target BuilderState>]< + $([<$ff:upper>],)* + false, + $([<$ft:upper>],)* + >> { + $(#[$fna])* + pub fn [](self) -> [<$Target Builder>]<[<$Target BuilderState>]< + $([<$ff:upper>],)* + true, + $([<$ft:upper>],)* + >> { + [<$Target Builder>] { + $( $ff: self.$ff, )* + $fn: Some($TN::default()), + $( $ft: self.$ft, )* + state: PhantomData + } + } + $(#[$fna])* + pub fn [](self, $fn: $TN) -> [<$Target Builder>]<[<$Target BuilderState>]< + $([<$ff:upper>],)* + true, + $([<$ft:upper>],)* + >> { + [<$Target Builder>] { + $( $ff: self.$ff, )* + $fn: Some($fn), + $( $ft: self.$ft, )* + state: PhantomData + } + } + } + // do we really need to disable some thing in builder? + // impl< + // $(const [<$ff:upper>]: bool,)* + // $(const [<$ft:upper>]: bool,)* + // > [<$Target Builder>]<[<$Target BuilderState>]< + // $([<$ff:upper>],)* + // true, + // $([<$ft:upper>],)* + // >> { + // pub fn [](self) -> [<$Target Builder>]<[<$Target BuilderState>]< + // $([<$ff:upper>],)* + // false, + // $([<$ft:upper>],)* + // >> { + // [<$Target Builder>] { + // $( $ff: self.$ff, )* + // $fn: None, + // $( $ft: self.$ft, )* + // state: PhantomData + // } + // } + // } + } + } +} + +#[cfg(any(feature = "server", feature = "macros"))] +builder! { + ServerCapabilities { + experimental: ExperimentalCapabilities, + extensions: ExtensionCapabilities, + #[deprecated( + since = "1.8.0", + note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" + )] + logging: JsonObject, + completions: JsonObject, + prompts: PromptsCapability, + resources: ResourcesCapability, + tools: ToolsCapability, + } +} + +#[cfg(any(feature = "server", feature = "macros"))] +impl + ServerCapabilitiesBuilder> +{ + pub fn enable_tool_list_changed(mut self) -> Self { + if let Some(c) = self.tools.as_mut() { + c.list_changed = Some(true); + } + self + } +} + +#[cfg(any(feature = "server", feature = "macros"))] +impl + ServerCapabilitiesBuilder> +{ + pub fn enable_prompts_list_changed(mut self) -> Self { + if let Some(c) = self.prompts.as_mut() { + c.list_changed = Some(true); + } + self + } +} + +#[cfg(any(feature = "server", feature = "macros"))] +impl + ServerCapabilitiesBuilder> +{ + pub fn enable_resources_list_changed(mut self) -> Self { + if let Some(c) = self.resources.as_mut() { + c.list_changed = Some(true); + } + self + } + + pub fn enable_resources_subscribe(mut self) -> Self { + if let Some(c) = self.resources.as_mut() { + c.subscribe = Some(true); + } + self + } +} + +#[cfg(any(feature = "server", feature = "macros"))] +impl ServerCapabilitiesBuilder { + /// Declare support for the `io.modelcontextprotocol/tasks` extension + /// (SEP-2663) in the `extensions` capability map. + pub fn enable_tasks(mut self) -> Self { + self.extensions + .get_or_insert_with(ExtensionCapabilities::new) + .insert(super::TASKS_EXTENSION_ID.to_string(), JsonObject::new()); + self + } +} + +#[cfg(any(feature = "server", feature = "macros"))] +builder! { + ClientCapabilities{ + experimental: ExperimentalCapabilities, + extensions: ExtensionCapabilities, + #[deprecated( + since = "1.8.0", + note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" + )] + roots: RootsCapabilities, + #[deprecated( + since = "1.8.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" + )] + sampling: SamplingCapability, + elicitation: ElicitationCapability, + } +} + +#[cfg(any(feature = "server", feature = "macros"))] +impl ClientCapabilitiesBuilder { + /// Declare support for the `io.modelcontextprotocol/tasks` extension + /// (SEP-2663) in the `extensions` capability map. + pub fn enable_tasks(mut self) -> Self { + self.extensions + .get_or_insert_with(ExtensionCapabilities::new) + .insert(super::TASKS_EXTENSION_ID.to_string(), JsonObject::new()); + self + } +} + +#[cfg(any(feature = "server", feature = "macros"))] +impl + ClientCapabilitiesBuilder> +{ + #[deprecated( + since = "1.8.0", + note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" + )] + pub fn enable_roots_list_changed(mut self) -> Self { + if let Some(c) = self.roots.as_mut() { + c.list_changed = Some(true); + } + self + } +} + +#[cfg(any(feature = "server", feature = "macros"))] +impl + ClientCapabilitiesBuilder> +{ + /// Enable tool calling in sampling requests + #[deprecated( + since = "1.8.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" + )] + pub fn enable_sampling_tools(mut self) -> Self { + if let Some(c) = self.sampling.as_mut() { + c.tools = Some(JsonObject::default()); + } + self + } + + /// Enable context inclusion in sampling (soft-deprecated) + #[deprecated( + since = "1.8.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" + )] + pub fn enable_sampling_context(mut self) -> Self { + if let Some(c) = self.sampling.as_mut() { + c.context = Some(JsonObject::default()); + } + self + } +} + +#[cfg(all(feature = "elicitation", any(feature = "server", feature = "macros")))] +impl + ClientCapabilitiesBuilder> +{ + /// Enable JSON Schema validation for elicitation responses in form mode. + /// When enabled, the client will validate user input against the requested_schema + /// before sending responses back to the server. + pub fn enable_elicitation_schema_validation(mut self) -> Self { + if let Some(c) = self.elicitation.as_mut() { + c.form = Some(FormElicitationCapability { + schema_validation: Some(true), + }); + } + self + } +} + +#[cfg(test)] +#[cfg(any(feature = "server", feature = "macros"))] +mod test { + use super::*; + #[test] + #[allow(deprecated)] + fn test_builder() { + let builder = ::default() + .enable_logging() + .enable_experimental() + .enable_prompts() + .enable_resources() + .enable_tools() + .enable_tool_list_changed(); + assert_eq!(builder.logging, Some(JsonObject::default())); + assert_eq!(builder.prompts, Some(PromptsCapability::default())); + assert_eq!(builder.resources, Some(ResourcesCapability::default())); + assert_eq!( + builder.tools, + Some(ToolsCapability { + list_changed: Some(true), + }) + ); + assert_eq!( + builder.experimental, + Some(ExperimentalCapabilities::default()) + ); + let client_builder = ::default() + .enable_experimental() + .enable_roots() + .enable_roots_list_changed() + .enable_sampling(); + assert_eq!( + client_builder.experimental, + Some(ExperimentalCapabilities::default()) + ); + assert_eq!( + client_builder.roots, + Some(RootsCapabilities { + list_changed: Some(true), + }) + ); + } + + #[test] + fn test_tasks_extension_capability() { + // SEP-2663: tasks are declared via the extensions map. + let capabilities = ClientCapabilities::builder().enable_tasks().build(); + assert!(capabilities.supports_tasks()); + let json = serde_json::to_value(&capabilities).unwrap(); + assert_eq!( + json["extensions"][crate::model::TASKS_EXTENSION_ID], + serde_json::json!({}) + ); + + let server = ServerCapabilities::builder().enable_tasks().build(); + assert!(server.supports_tasks()); + let json = serde_json::to_value(&server).unwrap(); + assert_eq!( + json["extensions"][crate::model::TASKS_EXTENSION_ID], + serde_json::json!({}) + ); + } + + #[test] + #[allow(deprecated)] + fn test_client_extensions_capability() { + // Test building ClientCapabilities with extensions (MCP Apps support) + let mut extensions = ExtensionCapabilities::new(); + extensions.insert( + "io.modelcontextprotocol/ui".to_string(), + serde_json::from_value(serde_json::json!({ + "mimeTypes": ["text/html;profile=mcp-app"] + })) + .unwrap(), + ); + + let capabilities = ClientCapabilities::builder() + .enable_extensions_with(extensions) + .enable_sampling() + .build(); + + // Verify serialization matches MCP Apps spec format + let json = serde_json::to_value(&capabilities).unwrap(); + assert_eq!( + json["extensions"]["io.modelcontextprotocol/ui"]["mimeTypes"], + serde_json::json!(["text/html;profile=mcp-app"]) + ); + assert!(json["sampling"].is_object()); + } + + #[test] + fn test_server_extensions_capability() { + // Test building ServerCapabilities with extensions + let mut extensions = ExtensionCapabilities::new(); + extensions.insert( + "io.modelcontextprotocol/apps".to_string(), + serde_json::from_value(serde_json::json!({})).unwrap(), + ); + + let capabilities = ServerCapabilities::builder() + .enable_extensions_with(extensions) + .enable_tools() + .build(); + + // Verify serialization + let json = serde_json::to_value(&capabilities).unwrap(); + assert!(json["extensions"]["io.modelcontextprotocol/apps"].is_object()); + assert!(json["tools"].is_object()); + } + + #[test] + fn test_extensions_deserialization() { + // Test deserializing capabilities with extensions from JSON + let json = serde_json::json!({ + "extensions": { + "io.modelcontextprotocol/ui": { + "mimeTypes": ["text/html;profile=mcp-app"] + } + }, + "sampling": {} + }); + + let capabilities: ClientCapabilities = serde_json::from_value(json).unwrap(); + assert!(capabilities.extensions.is_some()); + let extensions = capabilities.extensions.unwrap(); + assert!(extensions.contains_key("io.modelcontextprotocol/ui")); + let ui_ext = extensions.get("io.modelcontextprotocol/ui").unwrap(); + assert!(ui_ext.contains_key("mimeTypes")); + } + + #[test] + fn test_extensions_empty_settings() { + // Test that empty extension settings work (indicates support with no settings) + let mut extensions = ExtensionCapabilities::new(); + extensions.insert( + "io.modelcontextprotocol/oauth-client-credentials".to_string(), + JsonObject::new(), + ); + + let capabilities = ClientCapabilities::builder() + .enable_extensions_with(extensions) + .build(); + + let json = serde_json::to_value(&capabilities).unwrap(); + assert_eq!( + json["extensions"]["io.modelcontextprotocol/oauth-client-credentials"], + serde_json::json!({}) + ); + } +} diff --git a/vendor/rmcp/src/model/content.rs b/vendor/rmcp/src/model/content.rs new file mode 100644 index 000000000..11b2f0e53 --- /dev/null +++ b/vendor/rmcp/src/model/content.rs @@ -0,0 +1,464 @@ +//! Content types that flow between agents, tools, prompts, and LLMs. +//! +//! The core union is [`ContentBlock`] (text | image | audio | resource_link | resource), +//! matching the MCP 2025-11-25 `ContentBlock` definition. Each variant carries optional +//! [`Annotations`] and `_meta` inline. +//! +//! [`SamplingMessageContentBlock`] extends the union with `tool_use` and `tool_result` +//! variants for sampling messages (SEP-1577). + +// ToolUseContent/ToolResultContent are SEP-2577-deprecated; internal references are expected. +#![expect(deprecated)] +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use super::{Annotations, MetaObject, resource::ResourceContents}; + +// --------------------------------------------------------------------------- +// Flat content structs +// --------------------------------------------------------------------------- + +/// Text content block (spec `TextContent`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct TextContent { + /// The text content of the message. + pub text: String, + /// Optional protocol-level metadata for this content block. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Optional annotations describing how the client should use this content. + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, +} + +impl TextContent { + pub fn new(text: impl Into) -> Self { + Self { + text: text.into(), + meta: None, + annotations: None, + } + } + + pub fn with_meta(mut self, meta: MetaObject) -> Self { + self.meta = Some(meta); + self + } + + pub fn with_annotations(mut self, annotations: Annotations) -> Self { + self.annotations = Some(annotations); + self + } +} + +/// Image content with base64-encoded data (spec `ImageContent`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct ImageContent { + /// The base64-encoded image data. + pub data: String, + /// The MIME type of the image (e.g. `image/png`). + pub mime_type: String, + /// Optional protocol-level metadata for this content block. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Optional annotations describing how the client should use this content. + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, +} + +impl ImageContent { + pub fn new(data: impl Into, mime_type: impl Into) -> Self { + Self { + data: data.into(), + mime_type: mime_type.into(), + meta: None, + annotations: None, + } + } + + pub fn with_meta(mut self, meta: MetaObject) -> Self { + self.meta = Some(meta); + self + } + + pub fn with_annotations(mut self, annotations: Annotations) -> Self { + self.annotations = Some(annotations); + self + } +} + +/// Audio content with base64-encoded data (spec `AudioContent`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct AudioContent { + /// The base64-encoded audio data. + pub data: String, + /// The MIME type of the audio (e.g. `audio/wav`). + pub mime_type: String, + /// Optional protocol-level metadata for this content block. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Optional annotations describing how the client should use this content. + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, +} + +impl AudioContent { + pub fn new(data: impl Into, mime_type: impl Into) -> Self { + Self { + data: data.into(), + mime_type: mime_type.into(), + meta: None, + annotations: None, + } + } + + pub fn with_meta(mut self, meta: MetaObject) -> Self { + self.meta = Some(meta); + self + } + + pub fn with_annotations(mut self, annotations: Annotations) -> Self { + self.annotations = Some(annotations); + self + } +} + +/// Embedded resource content (spec `EmbeddedResource`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct EmbeddedResource { + /// The embedded resource contents (text or blob). + pub resource: ResourceContents, + /// Optional protocol-level metadata for this content block. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Optional annotations describing how the client should use this content. + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, +} + +impl EmbeddedResource { + pub fn new(resource: ResourceContents) -> Self { + Self { + resource, + meta: None, + annotations: None, + } + } + + pub fn get_text(&self) -> String { + match &self.resource { + ResourceContents::TextResourceContents { text, .. } => text.clone(), + _ => String::new(), + } + } + + pub fn with_meta(mut self, meta: MetaObject) -> Self { + self.meta = Some(meta); + self + } + + pub fn with_annotations(mut self, annotations: Annotations) -> Self { + self.annotations = Some(annotations); + self + } +} + +/// Tool call request from assistant (SEP-1577). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] +pub struct ToolUseContent { + pub id: String, + pub name: String, + pub input: super::JsonObject, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// Tool execution result in user message (SEP-1577). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] +pub struct ToolResultContent { + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + pub tool_use_id: String, + pub content: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub structured_content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_error: Option, +} + +impl ToolUseContent { + pub fn new(id: impl Into, name: impl Into, input: super::JsonObject) -> Self { + Self { + id: id.into(), + name: name.into(), + input, + meta: None, + } + } +} + +impl ToolResultContent { + pub fn new(tool_use_id: impl Into, content: Vec) -> Self { + Self { + meta: None, + tool_use_id: tool_use_id.into(), + content, + structured_content: None, + is_error: None, + } + } + + pub fn error(tool_use_id: impl Into, content: Vec) -> Self { + Self { + meta: None, + tool_use_id: tool_use_id.into(), + content, + structured_content: None, + is_error: Some(true), + } + } +} + +// --------------------------------------------------------------------------- +// ContentBlock — the unified content union (spec `ContentBlock`) +// --------------------------------------------------------------------------- + +/// Unified content block union (spec `ContentBlock`). +/// +/// `text | image | audio | resource_link | resource` +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub enum ContentBlock { + Text(TextContent), + Image(ImageContent), + Audio(AudioContent), + Resource(EmbeddedResource), + ResourceLink(super::resource::Resource), +} + +impl ContentBlock { + pub fn json(json: S) -> Result { + let json = serde_json::to_string(&json).map_err(|e| { + crate::ErrorData::internal_error( + "fail to serialize response to json", + Some(json!( + {"reason": e.to_string()} + )), + ) + })?; + Ok(ContentBlock::text(json)) + } + + pub fn text(text: impl Into) -> Self { + ContentBlock::Text(TextContent::new(text)) + } + + pub fn image(data: impl Into, mime_type: impl Into) -> Self { + ContentBlock::Image(ImageContent::new(data, mime_type)) + } + + pub fn audio(data: impl Into, mime_type: impl Into) -> Self { + ContentBlock::Audio(AudioContent::new(data, mime_type)) + } + + pub fn resource(resource: ResourceContents) -> Self { + ContentBlock::Resource(EmbeddedResource::new(resource)) + } + + pub fn embedded_text(uri: impl Into, content: impl Into) -> Self { + ContentBlock::Resource(EmbeddedResource::new( + ResourceContents::TextResourceContents { + uri: uri.into(), + mime_type: Some("text/plain".to_string()), + text: content.into(), + meta: None, + }, + )) + } + + pub fn resource_link(resource: super::resource::Resource) -> Self { + ContentBlock::ResourceLink(resource) + } + + pub fn as_text(&self) -> Option<&TextContent> { + match self { + ContentBlock::Text(text) => Some(text), + _ => None, + } + } + + pub fn as_image(&self) -> Option<&ImageContent> { + match self { + ContentBlock::Image(image) => Some(image), + _ => None, + } + } + + pub fn as_resource(&self) -> Option<&EmbeddedResource> { + match self { + ContentBlock::Resource(resource) => Some(resource), + _ => None, + } + } + + pub fn as_resource_link(&self) -> Option<&super::resource::Resource> { + match self { + ContentBlock::ResourceLink(link) => Some(link), + _ => None, + } + } + + pub fn as_audio(&self) -> Option<&AudioContent> { + match self { + ContentBlock::Audio(audio) => Some(audio), + _ => None, + } + } +} + +// --------------------------------------------------------------------------- +// JsonContent (unchanged) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct JsonContent(S); + +// --------------------------------------------------------------------------- +// IntoContents +// --------------------------------------------------------------------------- + +/// Types that can be converted into a list of content blocks. +pub trait IntoContents { + fn into_contents(self) -> Vec; +} + +impl IntoContents for ContentBlock { + fn into_contents(self) -> Vec { + vec![self] + } +} + +impl IntoContents for String { + fn into_contents(self) -> Vec { + vec![ContentBlock::text(self)] + } +} + +impl IntoContents for () { + fn into_contents(self) -> Vec { + vec![] + } +} + +#[cfg(test)] +mod tests { + use serde_json; + + use super::*; + + #[test] + fn test_image_content_serialization() { + let image = ImageContent::new("base64data", "image/png"); + let json = serde_json::to_string(&image).unwrap(); + assert!(json.contains("mimeType")); + assert!(!json.contains("mime_type")); + } + + #[test] + fn test_audio_content_serialization() { + let audio = AudioContent::new("base64audiodata", "audio/wav"); + let json = serde_json::to_string(&audio).unwrap(); + assert!(json.contains("mimeType")); + assert!(!json.contains("mime_type")); + } + + #[test] + fn test_audio_content_has_meta() { + let audio = AudioContent::new("data", "audio/wav").with_meta(MetaObject::default()); + let json = serde_json::to_value(&audio).unwrap(); + assert!(json.get("_meta").is_some()); + } + + #[test] + fn test_resource_link_serialization() { + use super::super::resource::Resource; + + let resource_link = ContentBlock::ResourceLink(Resource { + uri: "file:///test.txt".to_string(), + name: "test.txt".to_string(), + title: None, + description: Some("A test file".to_string()), + mime_type: Some("text/plain".to_string()), + size: Some(100), + icons: None, + meta: None, + annotations: None, + }); + + let json = serde_json::to_string(&resource_link).unwrap(); + assert!(json.contains("\"type\":\"resource_link\"")); + assert!(json.contains("\"uri\":\"file:///test.txt\"")); + assert!(json.contains("\"name\":\"test.txt\"")); + } + + #[test] + fn test_resource_link_deserialization() { + let json = r#"{ + "type": "resource_link", + "uri": "file:///example.txt", + "name": "example.txt", + "description": "Example file", + "mimeType": "text/plain" + }"#; + + let content: ContentBlock = serde_json::from_str(json).unwrap(); + + if let ContentBlock::ResourceLink(resource) = content { + assert_eq!(resource.uri, "file:///example.txt"); + assert_eq!(resource.name, "example.txt"); + assert_eq!(resource.description, Some("Example file".to_string())); + assert_eq!(resource.mime_type, Some("text/plain".to_string())); + } else { + panic!("Expected ResourceLink variant"); + } + } + + #[test] + fn test_content_block_text_with_annotations() { + let block = ContentBlock::Text( + TextContent::new("hello").with_annotations(Annotations::default().with_priority(0.8)), + ); + let json = serde_json::to_value(&block).unwrap(); + assert_eq!(json["type"], "text"); + assert_eq!(json["text"], "hello"); + assert_eq!(json["annotations"]["priority"], 0.8_f32); + } +} diff --git a/vendor/rmcp/src/model/elicitation_schema.rs b/vendor/rmcp/src/model/elicitation_schema.rs new file mode 100644 index 000000000..3cc24ca4c --- /dev/null +++ b/vendor/rmcp/src/model/elicitation_schema.rs @@ -0,0 +1,2237 @@ +//! Type-safe schema definitions for MCP elicitation requests. +//! +//! This module provides strongly-typed schema definitions for elicitation requests +//! that comply with the MCP 2025-06-18 specification. Elicitation schemas must be +//! objects with primitive-typed properties. +//! +//! # Example +//! +//! ```rust +//! use rmcp::model::*; +//! +//! let schema = ElicitationSchema::builder() +//! .required_email("email") +//! .required_integer("age", 0, 150) +//! .optional_bool("newsletter", false) +//! .build(); +//! ``` + +use std::{borrow::Cow, collections::BTreeMap, marker::PhantomData}; + +use serde::{Deserialize, Serialize}; + +use crate::{const_string, model::ConstString}; + +// ============================================================================= +// CONST TYPES FOR JSON SCHEMA TYPE FIELD +// ============================================================================= + +const_string!(ObjectTypeConst = "object"); +const_string!(StringTypeConst = "string"); +const_string!(NumberTypeConst = "number"); +const_string!(IntegerTypeConst = "integer"); +const_string!(BooleanTypeConst = "boolean"); +const_string!(EnumTypeConst = "string"); +const_string!(ArrayTypeConst = "array"); + +// ============================================================================= +// PRIMITIVE SCHEMA DEFINITIONS +// ============================================================================= + +/// Primitive schema definition for elicitation properties. +/// +/// According to MCP 2025-06-18 specification, elicitation schemas must have +/// properties of primitive types only (string, number, integer, boolean, enum). +/// +/// Note: Put Enum as the first variant to avoid ambiguity during deserialization. +/// This is due to the fact that EnumSchema can contain StringSchema internally and serde +/// uses first match wins strategy when deserializing untagged enums. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +#[non_exhaustive] +pub enum PrimitiveSchemaDefinition { + /// Enum property (explicit enum schema) + Enum(EnumSchema), + /// String property (with optional enum constraint) + String(StringSchema), + /// Number property (with optional enum constraint) + Number(NumberSchema), + /// Integer property (with optional enum constraint) + Integer(IntegerSchema), + /// Boolean property + Boolean(BooleanSchema), +} + +// ============================================================================= +// STRING SCHEMA +// ============================================================================= + +/// String format types allowed by the MCP specification. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "kebab-case")] +#[non_exhaustive] +pub enum StringFormat { + /// Email address format + Email, + /// URI format + Uri, + /// Date format (YYYY-MM-DD) + Date, + /// Date-time format (ISO 8601) + DateTime, +} + +/// Schema definition for string properties. +/// +/// Compliant with MCP 2025-06-18 specification for elicitation schemas. +/// Supports only the fields allowed by the MCP spec: +/// - format limited to: "email", "uri", "date", "date-time" +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct StringSchema { + /// Type discriminator + #[serde(rename = "type")] + pub type_: StringTypeConst, + + /// Optional title for the schema + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option>, + + /// Human-readable description + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, + + /// Minimum string length + #[serde(skip_serializing_if = "Option::is_none")] + pub min_length: Option, + + /// Maximum string length + #[serde(skip_serializing_if = "Option::is_none")] + pub max_length: Option, + + /// String format - limited to: "email", "uri", "date", "date-time" + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, + + /// Default value + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, +} + +impl Default for StringSchema { + fn default() -> Self { + Self { + type_: StringTypeConst, + title: None, + description: None, + min_length: None, + max_length: None, + format: None, + default: None, + } + } +} + +impl StringSchema { + /// Create a new string schema + pub fn new() -> Self { + Self::default() + } + + /// Create an email string schema + pub fn email() -> Self { + Self { + format: Some(StringFormat::Email), + ..Default::default() + } + } + + /// Create a URI string schema + pub fn uri() -> Self { + Self { + format: Some(StringFormat::Uri), + ..Default::default() + } + } + + /// Create a date string schema + pub fn date() -> Self { + Self { + format: Some(StringFormat::Date), + ..Default::default() + } + } + + /// Create a date-time string schema + pub fn date_time() -> Self { + Self { + format: Some(StringFormat::DateTime), + ..Default::default() + } + } + + /// Set title + pub fn title(mut self, title: impl Into>) -> Self { + self.title = Some(title.into()); + self + } + + /// Set description + pub fn description(mut self, description: impl Into>) -> Self { + self.description = Some(description.into()); + self + } + + /// Set minimum and maximum length + pub fn with_length(mut self, min: u32, max: u32) -> Result { + if min > max { + return Err("min_length must be <= max_length"); + } + self.min_length = Some(min); + self.max_length = Some(max); + Ok(self) + } + + /// Set minimum and maximum length (panics on invalid input) + pub fn length(mut self, min: u32, max: u32) -> Self { + assert!(min <= max, "min_length must be <= max_length"); + self.min_length = Some(min); + self.max_length = Some(max); + self + } + + /// Set minimum length + pub fn min_length(mut self, min: u32) -> Self { + self.min_length = Some(min); + self + } + + /// Set maximum length + pub fn max_length(mut self, max: u32) -> Self { + self.max_length = Some(max); + self + } + + /// Set format (limited to: "email", "uri", "date", "date-time") + pub fn format(mut self, format: StringFormat) -> Self { + self.format = Some(format); + self + } + + /// Set default value + pub fn with_default(mut self, default: impl Into) -> Self { + self.default = Some(default.into()); + self + } +} + +// ============================================================================= +// NUMBER SCHEMA +// ============================================================================= + +/// Schema definition for number properties (floating-point). +/// +/// Compliant with MCP 2025-06-18 specification for elicitation schemas. +/// Supports only the fields allowed by the MCP spec. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct NumberSchema { + /// Type discriminator + #[serde(rename = "type")] + pub type_: NumberTypeConst, + + /// Optional title for the schema + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option>, + + /// Human-readable description + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, + + /// Minimum value (inclusive) + #[serde(skip_serializing_if = "Option::is_none")] + pub minimum: Option, + + /// Maximum value (inclusive) + #[serde(skip_serializing_if = "Option::is_none")] + pub maximum: Option, + + /// Default value + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, +} + +impl Default for NumberSchema { + fn default() -> Self { + Self { + type_: NumberTypeConst, + title: None, + description: None, + minimum: None, + maximum: None, + default: None, + } + } +} + +impl NumberSchema { + /// Create a new number schema + pub fn new() -> Self { + Self::default() + } + + /// Set minimum and maximum (inclusive) + pub fn with_range(mut self, min: f64, max: f64) -> Result { + if min > max { + return Err("minimum must be <= maximum"); + } + self.minimum = Some(min); + self.maximum = Some(max); + Ok(self) + } + + /// Set minimum and maximum (panics on invalid input) + pub fn range(mut self, min: f64, max: f64) -> Self { + assert!(min <= max, "minimum must be <= maximum"); + self.minimum = Some(min); + self.maximum = Some(max); + self + } + + /// Set minimum (inclusive) + pub fn minimum(mut self, min: f64) -> Self { + self.minimum = Some(min); + self + } + + /// Set maximum (inclusive) + pub fn maximum(mut self, max: f64) -> Self { + self.maximum = Some(max); + self + } + + /// Set title + pub fn title(mut self, title: impl Into>) -> Self { + self.title = Some(title.into()); + self + } + + /// Set description + pub fn description(mut self, description: impl Into>) -> Self { + self.description = Some(description.into()); + self + } + + /// Set default value + pub fn with_default(mut self, default: f64) -> Self { + self.default = Some(default); + self + } +} + +// ============================================================================= +// INTEGER SCHEMA +// ============================================================================= + +/// Schema definition for integer properties. +/// +/// Compliant with MCP 2025-06-18 specification for elicitation schemas. +/// Supports only the fields allowed by the MCP spec. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct IntegerSchema { + /// Type discriminator + #[serde(rename = "type")] + pub type_: IntegerTypeConst, + + /// Optional title for the schema + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option>, + + /// Human-readable description + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, + + /// Minimum value (inclusive) + #[serde(skip_serializing_if = "Option::is_none")] + pub minimum: Option, + + /// Maximum value (inclusive) + #[serde(skip_serializing_if = "Option::is_none")] + pub maximum: Option, + + /// Default value + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, +} + +impl Default for IntegerSchema { + fn default() -> Self { + Self { + type_: IntegerTypeConst, + title: None, + description: None, + minimum: None, + maximum: None, + default: None, + } + } +} + +impl IntegerSchema { + /// Create a new integer schema + pub fn new() -> Self { + Self::default() + } + + /// Set minimum and maximum (inclusive) + pub fn with_range(mut self, min: i64, max: i64) -> Result { + if min > max { + return Err("minimum must be <= maximum"); + } + self.minimum = Some(min); + self.maximum = Some(max); + Ok(self) + } + + /// Set minimum and maximum (panics on invalid input) + pub fn range(mut self, min: i64, max: i64) -> Self { + assert!(min <= max, "minimum must be <= maximum"); + self.minimum = Some(min); + self.maximum = Some(max); + self + } + + /// Set minimum (inclusive) + pub fn minimum(mut self, min: i64) -> Self { + self.minimum = Some(min); + self + } + + /// Set maximum (inclusive) + pub fn maximum(mut self, max: i64) -> Self { + self.maximum = Some(max); + self + } + + /// Set title + pub fn title(mut self, title: impl Into>) -> Self { + self.title = Some(title.into()); + self + } + + /// Set description + pub fn description(mut self, description: impl Into>) -> Self { + self.description = Some(description.into()); + self + } + + /// Set default value + pub fn with_default(mut self, default: i64) -> Self { + self.default = Some(default); + self + } +} + +// ============================================================================= +// BOOLEAN SCHEMA +// ============================================================================= + +/// Schema definition for boolean properties. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct BooleanSchema { + /// Type discriminator + #[serde(rename = "type")] + pub type_: BooleanTypeConst, + + /// Optional title for the schema + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option>, + + /// Human-readable description + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, + + /// Default value + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, +} + +impl Default for BooleanSchema { + fn default() -> Self { + Self { + type_: BooleanTypeConst, + title: None, + description: None, + default: None, + } + } +} + +impl BooleanSchema { + /// Create a new boolean schema + pub fn new() -> Self { + Self::default() + } + + /// Set title + pub fn title(mut self, title: impl Into>) -> Self { + self.title = Some(title.into()); + self + } + + /// Set description + pub fn description(mut self, description: impl Into>) -> Self { + self.description = Some(description.into()); + self + } + + /// Set default value + pub fn with_default(mut self, default: bool) -> Self { + self.default = Some(default); + self + } +} + +// ============================================================================= +// ENUM SCHEMA +// ============================================================================= + +/// Schema definition for enum properties. +/// +/// Represent single entry for titled item +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct ConstTitle { + #[serde(rename = "const")] + pub const_: String, + pub title: String, +} + +impl ConstTitle { + /// Create a new ConstTitle. + pub fn new(const_: impl Into, title: impl Into) -> Self { + Self { + const_: const_.into(), + title: title.into(), + } + } +} + +/// Legacy enum schema, keep for backward compatibility +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct LegacyEnumSchema { + #[serde(rename = "type")] + pub type_: StringTypeConst, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, + #[serde(rename = "enum")] + pub enum_: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub enum_names: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, +} + +impl LegacyEnumSchema { + pub fn new(enum_values: Vec) -> Self { + Self { + type_: StringTypeConst, + title: None, + description: None, + enum_: enum_values, + enum_names: None, + default: None, + } + } +} + +/// Untitled single-select +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct UntitledSingleSelectEnumSchema { + #[serde(rename = "type")] + pub type_: StringTypeConst, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, + #[serde(rename = "enum")] + pub enum_: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, +} + +/// Titled single-select +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct TitledSingleSelectEnumSchema { + #[serde(rename = "type")] + pub type_: StringTypeConst, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, + #[serde(rename = "oneOf")] + pub one_of: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, +} + +impl TitledSingleSelectEnumSchema { + /// Create a new TitledSingleSelectEnumSchema. + pub fn new(one_of: Vec) -> Self { + Self { + type_: StringTypeConst, + title: None, + description: None, + one_of, + default: None, + } + } +} + +/// Combined single-select +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +#[non_exhaustive] +pub enum SingleSelectEnumSchema { + Untitled(UntitledSingleSelectEnumSchema), + Titled(TitledSingleSelectEnumSchema), +} + +/// Items for untitled multi-select options +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct UntitledItems { + #[serde(rename = "type")] + pub type_: StringTypeConst, + #[serde(rename = "enum")] + pub enum_: Vec, +} + +impl UntitledItems { + pub fn new(enum_values: Vec) -> Self { + Self { + type_: StringTypeConst, + enum_: enum_values, + } + } +} + +/// Items for titled multi-select options +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct TitledItems { + // MCP spec requires "anyOf" for multi-select enums (allows any combination) + // Alias "oneOf" for compatibility with schemars + #[serde(rename = "anyOf", alias = "oneOf")] + pub any_of: Vec, +} + +impl TitledItems { + /// Create a new TitledItems. + pub fn new(any_of: Vec) -> Self { + Self { any_of } + } +} + +/// Multi-select untitled options +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct UntitledMultiSelectEnumSchema { + #[serde(rename = "type")] + pub type_: ArrayTypeConst, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_items: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_items: Option, + pub items: UntitledItems, + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option>, +} + +/// Multi-select titled options +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct TitledMultiSelectEnumSchema { + #[serde(rename = "type")] + pub type_: ArrayTypeConst, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_items: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_items: Option, + pub items: TitledItems, + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option>, +} + +impl TitledMultiSelectEnumSchema { + /// Create a new TitledMultiSelectEnumSchema. + pub fn new(items: TitledItems) -> Self { + Self { + type_: ArrayTypeConst, + title: None, + description: None, + min_items: None, + max_items: None, + items, + default: None, + } + } + + /// Set the title. + pub fn with_title(mut self, title: impl Into>) -> Self { + self.title = Some(title.into()); + self + } + + /// Set the description. + pub fn with_description(mut self, description: impl Into>) -> Self { + self.description = Some(description.into()); + self + } + + /// Set the minimum number of items. + pub fn with_min_items(mut self, min_items: u64) -> Self { + self.min_items = Some(min_items); + self + } + + /// Set the maximum number of items. + pub fn with_max_items(mut self, max_items: u64) -> Self { + self.max_items = Some(max_items); + self + } + + /// Set the default values. + pub fn with_default(mut self, default: Vec) -> Self { + self.default = Some(default); + self + } +} + +/// Multi-select enum options +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +#[non_exhaustive] +pub enum MultiSelectEnumSchema { + Untitled(UntitledMultiSelectEnumSchema), + Titled(TitledMultiSelectEnumSchema), +} + +/// Compliant with MCP 2025-06-18 specification for elicitation schemas. +/// Enums must have string type for values and can optionally include human-readable names. +/// +/// # Example +/// +/// ```rust +/// use rmcp::model::*; +/// +/// let enum_schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()]) +/// .multiselect() +/// .min_items(1u64).expect("Min items should be correct value") +/// .max_items(4u64).expect("Max items should be correct value") +/// .description("Country code") +/// .build(); +/// ``` +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +#[non_exhaustive] +pub enum EnumSchema { + Single(SingleSelectEnumSchema), + Multi(MultiSelectEnumSchema), + Legacy(LegacyEnumSchema), +} + +/// Marker type for single-select enum builder +#[derive(Debug)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct SingleSelect; +/// Marker type for multi-select enum builder +#[derive(Debug)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct MultiSelect; +/// Builder for EnumSchema +/// Allows to create various enum schema types (single/multi select, titled/untitled) +/// with validation of provided parameters +/// +/// # Example +/// ```rust +/// use rmcp::model::*; +/// let enum_schema = EnumSchema::builder(vec!["Red".to_string(), "Green".to_string(), "Blue".to_string()]) +/// .multiselect() +/// .enum_titles(vec!["Red Color".to_string(), "Green Color".to_string(), "Blue Color".to_string()]) +/// .expect("Number of titles should match number of values") +/// .min_items(1u64).expect("Min items should be correct value") +/// .max_items(3u64).expect("Max items should be correct value") +/// .description("Select your favorite colors") +/// .build(); +/// ``` +#[derive(Debug)] +pub struct EnumSchemaBuilder { + /// Enum values + enum_values: Vec, + /// If true generate Titled EnumSchema, UnTitled otherwise + titled: bool, + /// Title of EnumSchema + title: Option>, + /// Description of EnumSchema + description: Option>, + /// Titles of given enum values + enum_titles: Vec, + /// Minimum number of items to choose for MultiSelect + min_items: Option, + /// Maximum number of items to choose for MultiSelect + max_items: Option, + /// Default values for enum + default: Vec, + select_type: PhantomData, +} + +/// Default implementation for single-select enum builder +impl Default for EnumSchemaBuilder { + fn default() -> Self { + Self { + title: None, + description: None, + titled: false, + enum_titles: Vec::new(), + enum_values: Vec::new(), + min_items: None, + max_items: None, + default: Vec::new(), + select_type: PhantomData, + } + } +} + +/// Common enum schema builder methods +impl EnumSchemaBuilder { + /// Set title of enum schema + pub fn title(mut self, value: impl Into>) -> Self { + self.title = Some(value.into()); + self + } + + /// Set description of enum schema + pub fn description(mut self, value: impl Into>) -> Self { + self.description = Some(value.into()); + self + } + + /// Set enum as untitled + /// Clears any previously set titles + pub fn untitled(mut self) -> Self { + self.enum_titles = Vec::new(); + self.titled = false; + self + } + + /// Set titles to enum values. Also, implicitly set this enum schema as titled + pub fn enum_titles(mut self, titles: Vec) -> Result, String> { + if titles.len() != self.enum_values.len() { + return Err(format!( + "Provided number of titles do not match number of values: expected {}, but got {}", + self.enum_values.len(), + titles.len() + )); + } + self.titled = true; + self.enum_titles = titles; + Ok(self) + } +} + +/// Enum selection builder for single-select enums +impl EnumSchemaBuilder { + pub fn new(values: Vec) -> EnumSchemaBuilder { + EnumSchemaBuilder { + enum_values: values, + ..Default::default() + } + } + + /// Transition to multi-select enum builder. + /// + /// Clears any previously set default values and resets min/max items. + /// After this transition, you can use `min_items()`, `max_items()`, and + /// `with_default()` for multi-select semantics. + pub fn multiselect(self) -> EnumSchemaBuilder { + EnumSchemaBuilder { + enum_values: self.enum_values, + titled: self.titled, + title: self.title, + description: self.description, + enum_titles: self.enum_titles, + min_items: None, + max_items: None, + default: Vec::new(), // Clear default for multi-select + select_type: PhantomData, + } + } + + /// Set default value + pub fn with_default( + mut self, + default_value: impl Into, + ) -> Result, String> { + let value: String = default_value.into(); + if !self.enum_values.contains(&value) { + return Err("Provided default value is not in enum values".to_string()); + } + self.default = vec![value]; + Ok(self) + } + + /// Build enum schema + pub fn build(mut self) -> EnumSchema { + match self.titled { + false => EnumSchema::Single(SingleSelectEnumSchema::Untitled( + UntitledSingleSelectEnumSchema { + type_: StringTypeConst, + title: self.title, + description: self.description, + enum_: self.enum_values, + default: self.default.pop(), + }, + )), + true => EnumSchema::Single(SingleSelectEnumSchema::Titled( + TitledSingleSelectEnumSchema { + type_: StringTypeConst, + title: self.title, + description: self.description, + one_of: self + .enum_titles + .into_iter() + .zip(self.enum_values) + .map(|(title, const_)| ConstTitle { const_, title }) + .collect(), + default: self.default.pop(), + }, + )), + } + } +} + +/// Enum selection builder for multi-select enums +impl EnumSchemaBuilder { + /// Set enum as single-select + /// If it was multi-select, clear default values + pub fn single_select(self) -> EnumSchemaBuilder { + EnumSchemaBuilder { + enum_values: self.enum_values, + titled: self.titled, + title: self.title, + description: self.description, + enum_titles: self.enum_titles, + min_items: None, + max_items: None, + default: Vec::new(), // Clear default for single-select + select_type: PhantomData, + } + } + + /// Set default values + pub fn with_default( + mut self, + default_values: Vec, + ) -> Result, String> { + for value in &default_values { + if !self.enum_values.contains(value) { + return Err("One of the provided default values is not in enum values".to_string()); + } + } + if let Some(min) = self.min_items + && (default_values.len() as u64) < min + { + return Err("Number of provided default values is less than min_items".to_string()); + } + if let Some(max) = self.max_items + && (default_values.len() as u64) > max + { + return Err("Number of provided default values is greater than max_items".to_string()); + } + self.default = default_values; + Ok(self) + } + + /// Set minimal number of items for multi-select enum options + pub fn min_items(mut self, value: u64) -> Result, String> { + if let Some(max) = self.max_items + && value > max + { + return Err("Provided value is greater than max_items".to_string()); + } + self.min_items = Some(value); + Ok(self) + } + + /// Set maximal number of items for multi-select enum options + pub fn max_items(mut self, value: u64) -> Result, String> { + if let Some(min) = self.min_items + && value < min + { + return Err("Provided value is less than min_items".to_string()); + } + self.max_items = Some(value); + Ok(self) + } + + /// Build enum schema + pub fn build(self) -> EnumSchema { + match self.titled { + false => EnumSchema::Multi(MultiSelectEnumSchema::Untitled( + UntitledMultiSelectEnumSchema { + type_: ArrayTypeConst, + title: self.title, + description: self.description, + min_items: self.min_items, + max_items: self.max_items, + items: UntitledItems { + type_: StringTypeConst, + enum_: self.enum_values, + }, + default: if self.default.is_empty() { + None + } else { + Some(self.default) + }, + }, + )), + true => EnumSchema::Multi(MultiSelectEnumSchema::Titled(TitledMultiSelectEnumSchema { + type_: ArrayTypeConst, + title: self.title, + description: self.description, + min_items: self.min_items, + max_items: self.max_items, + items: TitledItems { + any_of: self + .enum_titles + .into_iter() + .zip(self.enum_values) + .map(|(title, const_)| ConstTitle { const_, title }) + .collect(), + }, + default: if self.default.is_empty() { + None + } else { + Some(self.default) + }, + })), + } + } +} + +impl EnumSchema { + /// Creates a new `EnumSchemaBuilder` with the given enum values. + /// + /// This convenience method allows you to construct an enum schema by specifying + /// the possible string values for the enum. Use the returned builder to further + /// configure the schema before building it. + /// + /// # Arguments + /// + /// * `values` - A vector of strings representing the allowed enum values. + /// + /// # Example + /// + /// ``` + /// use rmcp::model::*; + /// + /// let enum_schema = EnumSchema::builder(vec!["A".to_string(), "B".to_string()]). + /// with_default("A"). + /// expect("Default value should be valid"). + /// enum_titles(vec!["Option A".to_string(), "Option B".to_string()]). + /// expect("Number of titles should match number of values"). + /// build(); + /// ``` + pub fn builder(values: Vec) -> EnumSchemaBuilder { + EnumSchemaBuilder::new(values) + } +} + +// ============================================================================= +// ELICITATION SCHEMA +// ============================================================================= + +/// Type-safe elicitation schema for requesting structured user input. +/// +/// This enforces the MCP 2025-06-18 specification that elicitation schemas +/// must be objects with primitive-typed properties. +/// +/// # Example +/// +/// ```rust +/// use rmcp::model::*; +/// +/// let schema = ElicitationSchema::builder() +/// .required_email("email") +/// .required_integer("age", 0, 150) +/// .optional_bool("newsletter", false) +/// .build(); +/// ``` +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct ElicitationSchema { + /// Always "object" for elicitation schemas + #[serde(rename = "type")] + pub type_: ObjectTypeConst, + + /// Optional title for the schema + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option>, + + /// Property definitions (must be primitive types) + pub properties: BTreeMap, + + /// List of required property names + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option>, + + /// Optional description of what this schema represents + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, +} + +impl ElicitationSchema { + /// Create a new elicitation schema with the given properties + pub fn new(properties: BTreeMap) -> Self { + Self { + type_: ObjectTypeConst, + title: None, + properties, + required: None, + description: None, + } + } + + /// Convert from a JSON Schema object (typically generated by schemars) + /// + /// This allows converting from JsonObject to ElicitationSchema, which is useful + /// when working with automatically generated schemas from types. + /// + /// # Example + /// + /// ```rust,ignore + /// use rmcp::model::*; + /// + /// let json_schema = schema_for_type::(); + /// let elicitation_schema = ElicitationSchema::from_json_schema(json_schema)?; + /// ``` + /// + /// # Errors + /// + /// Returns a [`serde_json::Error`] if the JSON object cannot be deserialized + /// into a valid ElicitationSchema. + pub fn from_json_schema(schema: crate::model::JsonObject) -> Result { + serde_json::from_value(serde_json::Value::Object(schema)) + } + + /// Generate an ElicitationSchema from a Rust type that implements JsonSchema + /// + /// This is a convenience method that combines schema generation and conversion. + /// It uses the same schema generation settings as the rest of the MCP SDK. + /// + /// # Example + /// + /// ```rust,ignore + /// use rmcp::model::*; + /// use schemars::JsonSchema; + /// use serde::{Deserialize, Serialize}; + /// + /// #[derive(JsonSchema, Serialize, Deserialize)] + /// struct UserInput { + /// name: String, + /// age: u32, + /// } + /// + /// let schema = ElicitationSchema::from_type::()?; + /// ``` + /// + /// # Errors + /// + /// Returns a [`serde_json::Error`] if the generated schema cannot be converted + /// to a valid ElicitationSchema. + #[cfg(feature = "schemars")] + pub fn from_type() -> Result + where + T: schemars::JsonSchema, + { + use crate::schemars::generate::SchemaSettings; + + let mut settings = SchemaSettings::draft07(); + settings.transforms = vec![Box::new(schemars::transform::AddNullable::default())]; + let generator = settings.into_generator(); + let schema = generator.into_root_schema_for::(); + let object = serde_json::to_value(schema).expect("failed to serialize schema"); + match object { + serde_json::Value::Object(object) => Self::from_json_schema(object), + _ => panic!( + "Schema serialization produced non-object value: expected JSON object but got {:?}", + object + ), + } + } + + /// Set the required fields + pub fn with_required(mut self, required: Vec) -> Self { + self.required = Some(required); + self + } + + /// Set the title + pub fn with_title(mut self, title: impl Into>) -> Self { + self.title = Some(title.into()); + self + } + + /// Set the description + pub fn with_description(mut self, description: impl Into>) -> Self { + self.description = Some(description.into()); + self + } + + /// Create a builder for constructing elicitation schemas fluently + pub fn builder() -> ElicitationSchemaBuilder { + ElicitationSchemaBuilder::new() + } +} + +// ============================================================================= +// BUILDER +// ============================================================================= + +/// Fluent builder for constructing elicitation schemas. +/// +/// # Example +/// +/// ```rust +/// use rmcp::model::*; +/// +/// let schema = ElicitationSchema::builder() +/// .required_email("email") +/// .required_integer("age", 0, 150) +/// .optional_bool("newsletter", false) +/// .description("User registration") +/// .build(); +/// ``` +#[derive(Debug, Default)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct ElicitationSchemaBuilder { + pub properties: BTreeMap, + pub required: Vec, + pub title: Option>, + pub description: Option>, +} + +impl ElicitationSchemaBuilder { + /// Create a new builder + pub fn new() -> Self { + Self::default() + } + + /// Add a property to the schema + pub fn property(mut self, name: impl Into, schema: PrimitiveSchemaDefinition) -> Self { + self.properties.insert(name.into(), schema); + self + } + + /// Add a required property to the schema + pub fn required_property( + mut self, + name: impl Into, + schema: PrimitiveSchemaDefinition, + ) -> Self { + let name_str = name.into(); + self.required.push(name_str.clone()); + self.properties.insert(name_str, schema); + self + } + + // =========================================================================== + // TYPED PROPERTY METHODS - Cleaner API without PrimitiveSchemaDefinition wrapper + // =========================================================================== + + /// Add a string property with custom builder (required) + pub fn string_property( + mut self, + name: impl Into, + f: impl FnOnce(StringSchema) -> StringSchema, + ) -> Self { + self.properties.insert( + name.into(), + PrimitiveSchemaDefinition::String(f(StringSchema::new())), + ); + self + } + + /// Add a required string property with custom builder + pub fn required_string_property( + mut self, + name: impl Into, + f: impl FnOnce(StringSchema) -> StringSchema, + ) -> Self { + let name_str = name.into(); + self.required.push(name_str.clone()); + self.properties.insert( + name_str, + PrimitiveSchemaDefinition::String(f(StringSchema::new())), + ); + self + } + + /// Add a number property with custom builder + pub fn number_property( + mut self, + name: impl Into, + f: impl FnOnce(NumberSchema) -> NumberSchema, + ) -> Self { + self.properties.insert( + name.into(), + PrimitiveSchemaDefinition::Number(f(NumberSchema::new())), + ); + self + } + + /// Add a required number property with custom builder + pub fn required_number_property( + mut self, + name: impl Into, + f: impl FnOnce(NumberSchema) -> NumberSchema, + ) -> Self { + let name_str = name.into(); + self.required.push(name_str.clone()); + self.properties.insert( + name_str, + PrimitiveSchemaDefinition::Number(f(NumberSchema::new())), + ); + self + } + + /// Add an integer property with custom builder + pub fn integer_property( + mut self, + name: impl Into, + f: impl FnOnce(IntegerSchema) -> IntegerSchema, + ) -> Self { + self.properties.insert( + name.into(), + PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())), + ); + self + } + + /// Add a required integer property with custom builder + pub fn required_integer_property( + mut self, + name: impl Into, + f: impl FnOnce(IntegerSchema) -> IntegerSchema, + ) -> Self { + let name_str = name.into(); + self.required.push(name_str.clone()); + self.properties.insert( + name_str, + PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())), + ); + self + } + + /// Add a boolean property with custom builder + pub fn bool_property( + mut self, + name: impl Into, + f: impl FnOnce(BooleanSchema) -> BooleanSchema, + ) -> Self { + self.properties.insert( + name.into(), + PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())), + ); + self + } + + /// Add a required boolean property with custom builder + pub fn required_bool_property( + mut self, + name: impl Into, + f: impl FnOnce(BooleanSchema) -> BooleanSchema, + ) -> Self { + let name_str = name.into(); + self.required.push(name_str.clone()); + self.properties.insert( + name_str, + PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())), + ); + self + } + + // =========================================================================== + // CONVENIENCE METHODS - Simple common cases + // =========================================================================== + + /// Add a required string property + pub fn required_string(self, name: impl Into) -> Self { + self.required_property(name, PrimitiveSchemaDefinition::String(StringSchema::new())) + } + + /// Add an optional string property + pub fn optional_string(self, name: impl Into) -> Self { + self.property(name, PrimitiveSchemaDefinition::String(StringSchema::new())) + } + + /// Add a required email property + pub fn required_email(self, name: impl Into) -> Self { + self.required_property( + name, + PrimitiveSchemaDefinition::String(StringSchema::email()), + ) + } + + /// Add an optional email property + pub fn optional_email(self, name: impl Into) -> Self { + self.property( + name, + PrimitiveSchemaDefinition::String(StringSchema::email()), + ) + } + + /// Add a required string property with custom builder + pub fn required_string_with( + self, + name: impl Into, + f: impl FnOnce(StringSchema) -> StringSchema, + ) -> Self { + self.required_property( + name, + PrimitiveSchemaDefinition::String(f(StringSchema::new())), + ) + } + + /// Add an optional string property with custom builder + pub fn optional_string_with( + self, + name: impl Into, + f: impl FnOnce(StringSchema) -> StringSchema, + ) -> Self { + self.property( + name, + PrimitiveSchemaDefinition::String(f(StringSchema::new())), + ) + } + + // Convenience methods for numbers + + /// Add a required number property with range + pub fn required_number(self, name: impl Into, min: f64, max: f64) -> Self { + self.required_property( + name, + PrimitiveSchemaDefinition::Number(NumberSchema::new().range(min, max)), + ) + } + + /// Add an optional number property with range + pub fn optional_number(self, name: impl Into, min: f64, max: f64) -> Self { + self.property( + name, + PrimitiveSchemaDefinition::Number(NumberSchema::new().range(min, max)), + ) + } + + /// Add a required number property with custom builder + pub fn required_number_with( + self, + name: impl Into, + f: impl FnOnce(NumberSchema) -> NumberSchema, + ) -> Self { + self.required_property( + name, + PrimitiveSchemaDefinition::Number(f(NumberSchema::new())), + ) + } + + /// Add an optional number property with custom builder + pub fn optional_number_with( + self, + name: impl Into, + f: impl FnOnce(NumberSchema) -> NumberSchema, + ) -> Self { + self.property( + name, + PrimitiveSchemaDefinition::Number(f(NumberSchema::new())), + ) + } + + // Convenience methods for integers + + /// Add a required integer property with range + pub fn required_integer(self, name: impl Into, min: i64, max: i64) -> Self { + self.required_property( + name, + PrimitiveSchemaDefinition::Integer(IntegerSchema::new().range(min, max)), + ) + } + + /// Add an optional integer property with range + pub fn optional_integer(self, name: impl Into, min: i64, max: i64) -> Self { + self.property( + name, + PrimitiveSchemaDefinition::Integer(IntegerSchema::new().range(min, max)), + ) + } + + /// Add a required integer property with custom builder + pub fn required_integer_with( + self, + name: impl Into, + f: impl FnOnce(IntegerSchema) -> IntegerSchema, + ) -> Self { + self.required_property( + name, + PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())), + ) + } + + /// Add an optional integer property with custom builder + pub fn optional_integer_with( + self, + name: impl Into, + f: impl FnOnce(IntegerSchema) -> IntegerSchema, + ) -> Self { + self.property( + name, + PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())), + ) + } + + // Convenience methods for booleans + + /// Add a required boolean property + pub fn required_bool(self, name: impl Into) -> Self { + self.required_property( + name, + PrimitiveSchemaDefinition::Boolean(BooleanSchema::new()), + ) + } + + /// Add an optional boolean property with default value + pub fn optional_bool(self, name: impl Into, default: bool) -> Self { + self.property( + name, + PrimitiveSchemaDefinition::Boolean(BooleanSchema::new().with_default(default)), + ) + } + + /// Add a required boolean property with custom builder + pub fn required_bool_with( + self, + name: impl Into, + f: impl FnOnce(BooleanSchema) -> BooleanSchema, + ) -> Self { + self.required_property( + name, + PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())), + ) + } + + /// Add an optional boolean property with custom builder + pub fn optional_bool_with( + self, + name: impl Into, + f: impl FnOnce(BooleanSchema) -> BooleanSchema, + ) -> Self { + self.property( + name, + PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())), + ) + } + + // Enum convenience methods + + /// Add a required enum property using EnumSchema + pub fn required_enum_schema(self, name: impl Into, enum_schema: EnumSchema) -> Self { + self.required_property(name, PrimitiveSchemaDefinition::Enum(enum_schema)) + } + + /// Add an optional enum property using EnumSchema + pub fn optional_enum_schema(self, name: impl Into, enum_schema: EnumSchema) -> Self { + self.property(name, PrimitiveSchemaDefinition::Enum(enum_schema)) + } + + /// Mark an existing property as required + pub fn mark_required(mut self, name: impl Into) -> Self { + self.required.push(name.into()); + self + } + + /// Set the schema title + pub fn title(mut self, title: impl Into>) -> Self { + self.title = Some(title.into()); + self + } + + /// Set the schema description + pub fn description(mut self, description: impl Into>) -> Self { + self.description = Some(description.into()); + self + } + + /// Build the elicitation schema with validation + /// + /// # Errors + /// + /// Returns an error if: + /// - Required fields reference non-existent properties + /// - No properties are defined (empty schema) + pub fn build(self) -> Result { + // Validate that all required fields exist in properties + if !self.required.is_empty() { + for field_name in &self.required { + if !self.properties.contains_key(field_name) { + return Err("Required field does not exist in properties"); + } + } + } + + Ok(ElicitationSchema { + type_: ObjectTypeConst, + title: self.title, + properties: self.properties, + required: if self.required.is_empty() { + None + } else { + Some(self.required) + }, + description: self.description, + }) + } + + /// Build the elicitation schema without validation (panics on invalid schema) + /// + /// # Panics + /// + /// Panics if required fields reference non-existent properties + pub fn build_unchecked(self) -> ElicitationSchema { + self.build().expect("Invalid elicitation schema") + } +} + +#[cfg(test)] +mod tests { + use anyhow::anyhow; + use rstest::rstest; + use serde_json::json; + + use super::*; + + fn string_schema_json() -> serde_json::Value { + serde_json::to_value(StringSchema::email().description("Email address")).unwrap() + } + + fn number_schema_json() -> serde_json::Value { + serde_json::to_value( + NumberSchema::new() + .range(0.0, 100.0) + .description("Percentage"), + ) + .unwrap() + } + + fn integer_schema_json() -> serde_json::Value { + serde_json::to_value(IntegerSchema::new().range(0, 150)).unwrap() + } + + fn boolean_schema_json() -> serde_json::Value { + serde_json::to_value(BooleanSchema::new().with_default(true)).unwrap() + } + + #[rstest] + #[case::string_schema( + string_schema_json, + json!({ + "type": "string", + "format": "email", + "description": "Email address", + }) + )] + #[case::number_schema( + number_schema_json, + json!({ + "type": "number", + "description": "Percentage", + "minimum": 0.0, + "maximum": 100.0, + }) + )] + #[case::integer_schema( + integer_schema_json, + json!({ + "type": "integer", + "minimum": 0, + "maximum": 150, + }) + )] + #[case::boolean_schema( + boolean_schema_json, + json!({ + "type": "boolean", + "default": true, + }) + )] + fn primitive_schema_serializes_to_expected_json( + #[case] schema_json: fn() -> serde_json::Value, + #[case] expected: serde_json::Value, + ) { + assert_eq!(schema_json(), expected); + } + + #[test] + fn test_enum_schema_untitled_single_select_serialization() { + let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()]) + .description("Country code") + .build(); + let json = serde_json::to_value(&schema).unwrap(); + + assert_eq!(json["type"], "string"); + assert_eq!(json["enum"], json!(["US", "UK"])); + assert_eq!(json["description"], "Country code"); + } + + #[test] + fn test_enum_schema_untitled_multi_select_serialization() -> anyhow::Result<()> { + let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()]) + .multiselect() + .min_items(1u64) + .map_err(|e| anyhow!("{e}"))? + .max_items(4u64) + .map_err(|e| anyhow!("{e}"))? + .description("Country code") + .build(); + let json = serde_json::to_value(&schema)?; + + assert_eq!(json["type"], "array"); + assert_eq!(json["minItems"], 1u64); + assert_eq!(json["maxItems"], 4u64); + assert_eq!(json["items"], json!({"type":"string", "enum":["US", "UK"]})); + assert_eq!(json["description"], "Country code"); + Ok(()) + } + + #[test] + fn test_enum_schema_titled_single_select_serialization() -> anyhow::Result<()> { + let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()]) + .enum_titles(vec![ + "United States".to_string(), + "United Kingdom".to_string(), + ]) + .map_err(|e| anyhow!("{e}"))? + .description("Country code") + .build(); + let json = serde_json::to_value(&schema)?; + + assert_eq!(json["type"], "string"); + assert_eq!( + json["oneOf"], + json!([ + {"const": "US", "title":"United States"}, + {"const": "UK", "title":"United Kingdom"} + ]) + ); + assert_eq!(json["description"], "Country code"); + Ok(()) + } + + #[test] + fn test_enum_schema_legacy_serialization() -> anyhow::Result<()> { + let schema = EnumSchema::Legacy(LegacyEnumSchema { + type_: StringTypeConst, + title: Some("Legacy Enum".into()), + description: Some("A legacy enum schema".into()), + enum_: vec!["A".to_string(), "B".to_string()], + enum_names: Some(vec!["Option A".to_string(), "Option B".to_string()]), + default: None, + }); + let json = serde_json::to_value(&schema)?; + + assert_eq!(json["type"], "string"); + assert_eq!(json["title"], "Legacy Enum"); + assert_eq!(json["description"], "A legacy enum schema"); + assert_eq!(json["enum"], json!(["A", "B"])); + assert_eq!(json["enumNames"], json!(["Option A", "Option B"])); + Ok(()) + } + + #[test] + fn test_legacy_enum_schema_roundtrip_preserves_enum_names() -> anyhow::Result<()> { + // Regression test for: legacy enum payload with `enumNames` was silently + // deserialized as `UntitledSingleSelectEnumSchema` (which has no `enumNames` + // field), causing the array to be dropped on re-serialization. + let input = serde_json::json!({ + "type": "object", + "properties": { + "choice": { + "type": "string", + "enum": ["opt1", "opt2", "opt3"], + "enumNames": ["Option One", "Option Two", "Option Three"] + } + } + }); + let schema: ElicitationSchema = serde_json::from_value(input.clone())?; + let output = serde_json::to_value(&schema)?; + assert_eq!( + output["properties"]["choice"]["enumNames"], + serde_json::json!(["Option One", "Option Two", "Option Three"]), + ); + Ok(()) + } + + #[test] + fn test_legacy_enum_schema_no_enum_names_omits_field() -> anyhow::Result<()> { + // `LegacyEnumSchema` with `enum_names: None` must not serialize `"enumNames": null`. + let schema = EnumSchema::Legacy(LegacyEnumSchema { + type_: StringTypeConst, + title: None, + description: None, + enum_: vec!["a".to_string(), "b".to_string()], + enum_names: None, + default: None, + }); + let json = serde_json::to_value(&schema)?; + assert!(!json.as_object().unwrap().contains_key("enumNames")); + Ok(()) + } + + #[test] + fn test_enum_schema_titled_multi_select_serialization() -> anyhow::Result<()> { + let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()]) + .enum_titles(vec![ + "United States".to_string(), + "United Kingdom".to_string(), + ]) + .map_err(|e| anyhow!("{e}"))? + .multiselect() + .min_items(1u64) + .map_err(|e| anyhow!("{e}"))? + .max_items(4u64) + .map_err(|e| anyhow!("{e}"))? + .description("Country code") + .build(); + let json = serde_json::to_value(&schema)?; + + assert_eq!(json["type"], "array"); + assert_eq!(json["minItems"], 1u64); + assert_eq!(json["maxItems"], 4u64); + assert_eq!( + json["items"], + json!({"anyOf":[ + {"const":"US","title":"United States"}, + {"const":"UK","title":"United Kingdom"} + ]}) + ); + assert_eq!(json["description"], "Country code"); + Ok(()) + } + + #[test] + fn test_enum_schema_single_select_with_default() -> anyhow::Result<()> { + let schema = EnumSchema::builder(vec![ + "red".to_string(), + "green".to_string(), + "blue".to_string(), + ]) + .with_default("green") + .map_err(|e| anyhow!("{e}"))? + .description("Favorite color") + .build(); + + let json = serde_json::to_value(&schema)?; + + assert_eq!(json["type"], "string"); + assert_eq!(json["enum"], json!(["red", "green", "blue"])); + assert_eq!(json["default"], "green"); + assert_eq!(json["description"], "Favorite color"); + Ok(()) + } + + #[test] + fn test_enum_schema_multi_select_with_default() -> anyhow::Result<()> { + let schema = EnumSchema::builder(vec![ + "red".to_string(), + "green".to_string(), + "blue".to_string(), + ]) + .multiselect() + .with_default(vec!["red".to_string(), "blue".to_string()]) + .map_err(|e| anyhow!("{e}"))? + .min_items(1) + .map_err(|e| anyhow!("{e}"))? + .max_items(3) + .map_err(|e| anyhow!("{e}"))? + .build(); + + let json = serde_json::to_value(&schema)?; + + assert_eq!(json["type"], "array"); + assert_eq!(json["items"]["enum"], json!(["red", "green", "blue"])); + assert_eq!(json["default"], json!(["red", "blue"])); + assert_eq!(json["minItems"], 1); + assert_eq!(json["maxItems"], 3); + Ok(()) + } + + #[test] + fn test_enum_schema_transition_clears_defaults() -> anyhow::Result<()> { + // Start with single-select with default + let builder = EnumSchema::builder(vec!["A".to_string(), "B".to_string()]) + .with_default("A") + .map_err(|e| anyhow!("{e}"))?; + + // Transition to multi-select should clear the default + let schema = builder.multiselect().build(); + let json = serde_json::to_value(&schema)?; + + assert_eq!(json["type"], "array"); + assert!(json["default"].is_null()); + Ok(()) + } + + #[test] + fn test_enum_schema_multi_to_single_transition() -> anyhow::Result<()> { + // Start with multi-select with defaults + let builder = EnumSchema::builder(vec!["A".to_string(), "B".to_string(), "C".to_string()]) + .multiselect() + .with_default(vec!["A".to_string(), "B".to_string()]) + .map_err(|e| anyhow!("{e}"))? + .min_items(1) + .map_err(|e| anyhow!("{e}"))?; + + // Transition back to single-select should clear defaults and min/max items + let schema = builder.single_select().build(); + let json = serde_json::to_value(&schema)?; + + assert_eq!(json["type"], "string"); + assert!(json["default"].is_null()); + assert!(json["minItems"].is_null()); + assert!(json["maxItems"].is_null()); + Ok(()) + } + + #[test] + fn test_enum_schema_invalid_single_default() { + let result = EnumSchema::builder(vec!["A".to_string(), "B".to_string()]).with_default("C"); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "Provided default value is not in enum values" + ); + } + + #[test] + fn test_enum_schema_invalid_multi_default() { + let result = EnumSchema::builder(vec!["A".to_string(), "B".to_string()]) + .multiselect() + .with_default(vec!["A".to_string(), "C".to_string()]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "One of the provided default values is not in enum values" + ); + } + + #[test] + fn test_enum_schema_titled_with_default() -> anyhow::Result<()> { + let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()]) + .enum_titles(vec![ + "United States".to_string(), + "United Kingdom".to_string(), + ]) + .map_err(|e| anyhow!("{e}"))? + .with_default("UK") + .map_err(|e| anyhow!("{e}"))? + .build(); + + let json = serde_json::to_value(&schema)?; + + assert_eq!(json["type"], "string"); + assert_eq!(json["default"], "UK"); + assert_eq!( + json["oneOf"], + json!([ + {"const": "US", "title": "United States"}, + {"const": "UK", "title": "United Kingdom"} + ]) + ); + Ok(()) + } + + #[test] + fn test_enum_schema_untitled_after_titled() -> anyhow::Result<()> { + let schema = EnumSchema::builder(vec!["A".to_string(), "B".to_string()]) + .enum_titles(vec!["Option A".to_string(), "Option B".to_string()]) + .map_err(|e| anyhow!("{e}"))? + .untitled() + .build(); + + let json = serde_json::to_value(&schema)?; + + assert_eq!(json["type"], "string"); + assert_eq!(json["enum"], json!(["A", "B"])); + assert!(json["oneOf"].is_null()); + Ok(()) + } + + #[test] + fn test_primitive_schema_enum_deserialization() { + // Test that enum schemas deserialize as Enum variant, not String + let json = json!({ + "type": "string", + "enum": ["a", "b"] + }); + let schema: PrimitiveSchemaDefinition = serde_json::from_value(json).unwrap(); + assert!(matches!(schema, PrimitiveSchemaDefinition::Enum(_))); + // Test that string schemas deserialize as String variant + let json = json!({ + "type": "string" + }); + let schema: PrimitiveSchemaDefinition = serde_json::from_value(json).unwrap(); + assert!(matches!(schema, PrimitiveSchemaDefinition::String(_))); + } + + #[test] + fn test_elicitation_schema_builder_simple() { + let schema = ElicitationSchema::builder() + .required_email("email") + .optional_bool("newsletter", false) + .build() + .unwrap(); + + assert_eq!(schema.properties.len(), 2); + assert!(schema.properties.contains_key("email")); + assert!(schema.properties.contains_key("newsletter")); + assert_eq!(schema.required, Some(vec!["email".to_string()])); + } + + #[test] + fn test_elicitation_schema_builder_complex() { + let enum_schema = + EnumSchema::builder(vec!["US".to_string(), "UK".to_string(), "CA".to_string()]).build(); + let schema = ElicitationSchema::builder() + .required_string_with("name", |s| s.length(1, 100)) + .required_integer("age", 0, 150) + .optional_bool("newsletter", false) + .required_enum_schema("country", enum_schema) + .description("User registration") + .build() + .unwrap(); + + assert_eq!(schema.properties.len(), 4); + assert_eq!( + schema.required, + Some(vec![ + "name".to_string(), + "age".to_string(), + "country".to_string() + ]) + ); + assert_eq!(schema.description.as_deref(), Some("User registration")); + } + + #[test] + fn test_elicitation_schema_serialization() { + let schema = ElicitationSchema::builder() + .required_string_with("name", |s| s.length(1, 100)) + .build() + .unwrap(); + + let json = serde_json::to_value(&schema).unwrap(); + + assert_eq!(json["type"], "object"); + assert!(json["properties"]["name"].is_object()); + assert_eq!(json["required"], json!(["name"])); + } + + #[test] + #[should_panic(expected = "minimum must be <= maximum")] + fn test_integer_range_validation() { + IntegerSchema::new().range(10, 5); // Should panic + } + + #[test] + #[should_panic(expected = "min_length must be <= max_length")] + fn test_string_length_validation() { + StringSchema::new().length(10, 5); // Should panic + } + + #[test] + fn test_integer_range_validation_with_result() { + let result = IntegerSchema::new().with_range(10, 5); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "minimum must be <= maximum"); + } + + #[cfg(feature = "schemars")] + mod schemars_tests { + use anyhow::Result; + use schemars::JsonSchema; + use serde::{Deserialize, Serialize}; + use serde_json::json; + + use crate::model::ElicitationSchema; + + #[derive(Debug, Serialize, Deserialize, JsonSchema, Default)] + #[schemars(inline)] + #[schemars(extend("type" = "string"))] + enum TitledEnum { + #[schemars(title = "Title for the first value")] + #[default] + FirstValue, + #[schemars(title = "Title for the second value")] + SecondValue, + } + + #[derive(Debug, Serialize, Deserialize, JsonSchema)] + #[schemars(inline)] + enum UntitledEnum { + First, + Second, + Third, + } + + fn default_untitled_multi_select() -> Vec { + vec![UntitledEnum::Second, UntitledEnum::Third] + } + + #[derive(Debug, Serialize, Deserialize, JsonSchema)] + #[schemars(description = "User information")] + struct UserInfo { + #[schemars(description = "User's name")] + pub name: String, + pub single_select_untitled: UntitledEnum, + #[schemars( + title = "Single Select Titled", + description = "Description for single select enum", + default + )] + pub single_select_titled: TitledEnum, + #[serde(default = "default_untitled_multi_select")] + pub multi_select_untitled: Vec, + #[schemars( + title = "Multi Select Titled", + description = "Multi Select Description" + )] + pub multi_select_titled: Vec, + } + + #[test] + fn test_schema_inference_for_enum_fields() -> Result<()> { + let schema = ElicitationSchema::from_type::()?; + + let json = serde_json::to_value(&schema)?; + assert_eq!(json["type"], "object"); + assert_eq!(json["description"], "User information"); + assert_eq!( + json["required"], + json!(["name", "single_select_untitled", "multi_select_titled"]) + ); + let properties = match json.get("properties") { + Some(serde_json::Value::Object(map)) => map, + _ => panic!("Schema does not have 'properties' field or it is not object type"), + }; + + assert_eq!(properties.len(), 5); + assert_eq!( + properties["name"], + json!({"description":"User's name", "type":"string"}) + ); + + assert_eq!( + properties["single_select_untitled"], + serde_json::json!({ + "type":"string", + "enum":["First", "Second", "Third"] + }) + ); + + assert_eq!( + properties["single_select_titled"], + json!({ + "type":"string", + "title":"Single Select Titled", + "description":"Description for single select enum", + "oneOf":[ + {"const":"FirstValue", "title":"Title for the first value"}, + {"const":"SecondValue", "title":"Title for the second value"} + ], + "default":"FirstValue" + }) + ); + assert_eq!( + properties["multi_select_untitled"], + serde_json::json!({ + "type":"array", + "items" : { + "type":"string", + "enum":["First", "Second", "Third"] + }, + "default":["Second", "Third"] + }) + ); + assert_eq!( + properties["multi_select_titled"], + serde_json::json!({ + "type":"array", + "title":"Multi Select Titled", + "description":"Multi Select Description", + "items":{ + "anyOf":[ + {"const":"FirstValue", "title":"Title for the first value"}, + {"const":"SecondValue", "title":"Title for the second value"} + ] + } + }) + ); + Ok(()) + } + } +} diff --git a/vendor/rmcp/src/model/extension.rs b/vendor/rmcp/src/model/extension.rs new file mode 100644 index 000000000..039fdf2eb --- /dev/null +++ b/vendor/rmcp/src/model/extension.rs @@ -0,0 +1,337 @@ +//! A container for those extra data could be carried on request or notification. +//! +//! This file is copied and modified from crate [http](https://github.com/hyperium/http). +//! +//! - Original code license: +//! - Original code: +use std::{ + any::{Any, TypeId}, + collections::HashMap, + fmt, + hash::{BuildHasherDefault, Hasher}, +}; + +type AnyMap = HashMap, BuildHasherDefault>; + +// With TypeIds as keys, there's no need to hash them. They are already hashes +// themselves, coming from the compiler. The IdHasher just holds the u64 of +// the TypeId, and then returns it, instead of doing any bit fiddling. +#[derive(Default)] +struct IdHasher(u64); + +impl Hasher for IdHasher { + fn write(&mut self, _: &[u8]) { + unreachable!("TypeId calls write_u64"); + } + + #[inline] + fn write_u64(&mut self, id: u64) { + self.0 = id; + } + + #[inline] + fn finish(&self) -> u64 { + self.0 + } +} + +/// A type map of protocol extensions. +/// +/// `Extensions` can be used by `Request` `Notification` and `Response` to store +/// extra data derived from the underlying protocol. +#[derive(Clone, Default)] +pub struct Extensions { + // If extensions are never used, no need to carry around an empty HashMap. + // That's 3 words. Instead, this is only 1 word. + map: Option>, +} + +impl Extensions { + /// Create an empty `Extensions`. + #[inline] + pub const fn new() -> Extensions { + Extensions { map: None } + } + + /// Insert a type into this `Extensions`. + /// + /// If a extension of this type already existed, it will + /// be returned and replaced with the new one. + /// + /// # Example + /// + /// ``` + /// # use rmcp::model::Extensions; + /// let mut ext = Extensions::new(); + /// assert!(ext.insert(5i32).is_none()); + /// assert!(ext.insert(4u8).is_none()); + /// assert_eq!(ext.insert(9i32), Some(5i32)); + /// ``` + pub fn insert(&mut self, val: T) -> Option { + self.map + .get_or_insert_with(Box::default) + .insert(TypeId::of::(), Box::new(val)) + .and_then(|boxed| boxed.into_any().downcast().ok().map(|boxed| *boxed)) + } + + /// Get a reference to a type previously inserted on this `Extensions`. + /// + /// # Example + /// + /// ``` + /// # use rmcp::model::Extensions; + /// let mut ext = Extensions::new(); + /// assert!(ext.get::().is_none()); + /// ext.insert(5i32); + /// + /// assert_eq!(ext.get::(), Some(&5i32)); + /// ``` + pub fn get(&self) -> Option<&T> { + self.map + .as_ref() + .and_then(|map| map.get(&TypeId::of::())) + .and_then(|boxed| (**boxed).as_any().downcast_ref()) + } + + /// Get a mutable reference to a type previously inserted on this `Extensions`. + /// + /// # Example + /// + /// ``` + /// # use rmcp::model::Extensions; + /// let mut ext = Extensions::new(); + /// ext.insert(String::from("Hello")); + /// ext.get_mut::().unwrap().push_str(" World"); + /// + /// assert_eq!(ext.get::().unwrap(), "Hello World"); + /// ``` + pub fn get_mut(&mut self) -> Option<&mut T> { + self.map + .as_mut() + .and_then(|map| map.get_mut(&TypeId::of::())) + .and_then(|boxed| (**boxed).as_any_mut().downcast_mut()) + } + + /// Get a mutable reference to a type, inserting `value` if not already present on this + /// `Extensions`. + /// + /// # Example + /// + /// ``` + /// # use rmcp::model::Extensions; + /// let mut ext = Extensions::new(); + /// *ext.get_or_insert(1i32) += 2; + /// + /// assert_eq!(*ext.get::().unwrap(), 3); + /// ``` + pub fn get_or_insert(&mut self, value: T) -> &mut T { + self.get_or_insert_with(|| value) + } + + /// Get a mutable reference to a type, inserting the value created by `f` if not already present + /// on this `Extensions`. + /// + /// # Example + /// + /// ``` + /// # use rmcp::model::Extensions; + /// let mut ext = Extensions::new(); + /// *ext.get_or_insert_with(|| 1i32) += 2; + /// + /// assert_eq!(*ext.get::().unwrap(), 3); + /// ``` + pub fn get_or_insert_with T>( + &mut self, + f: F, + ) -> &mut T { + let out = self + .map + .get_or_insert_with(Box::default) + .entry(TypeId::of::()) + .or_insert_with(|| Box::new(f())); + (**out).as_any_mut().downcast_mut().unwrap() + } + + /// Get a mutable reference to a type, inserting the type's default value if not already present + /// on this `Extensions`. + /// + /// # Example + /// + /// ``` + /// # use rmcp::model::Extensions; + /// let mut ext = Extensions::new(); + /// *ext.get_or_insert_default::() += 2; + /// + /// assert_eq!(*ext.get::().unwrap(), 2); + /// ``` + pub fn get_or_insert_default(&mut self) -> &mut T { + self.get_or_insert_with(T::default) + } + + /// Remove a type from this `Extensions`. + /// + /// If a extension of this type existed, it will be returned. + /// + /// # Example + /// + /// ``` + /// # use rmcp::model::Extensions; + /// let mut ext = Extensions::new(); + /// ext.insert(5i32); + /// assert_eq!(ext.remove::(), Some(5i32)); + /// assert!(ext.get::().is_none()); + /// ``` + pub fn remove(&mut self) -> Option { + self.map + .as_mut() + .and_then(|map| map.remove(&TypeId::of::())) + .and_then(|boxed| boxed.into_any().downcast().ok().map(|boxed| *boxed)) + } + + /// Clear the `Extensions` of all inserted extensions. + /// + /// # Example + /// + /// ``` + /// # use rmcp::model::Extensions; + /// let mut ext = Extensions::new(); + /// ext.insert(5i32); + /// ext.clear(); + /// + /// assert!(ext.get::().is_none()); + /// ``` + #[inline] + pub fn clear(&mut self) { + if let Some(ref mut map) = self.map { + map.clear(); + } + } + + /// Check whether the extension set is empty or not. + /// + /// # Example + /// + /// ``` + /// # use rmcp::model::Extensions; + /// let mut ext = Extensions::new(); + /// assert!(ext.is_empty()); + /// ext.insert(5i32); + /// assert!(!ext.is_empty()); + /// ``` + #[inline] + pub fn is_empty(&self) -> bool { + self.map.as_ref().is_none_or(|map| map.is_empty()) + } + + /// Get the number of extensions available. + /// + /// # Example + /// + /// ``` + /// # use rmcp::model::Extensions; + /// let mut ext = Extensions::new(); + /// assert_eq!(ext.len(), 0); + /// ext.insert(5i32); + /// assert_eq!(ext.len(), 1); + /// ``` + #[inline] + pub fn len(&self) -> usize { + self.map.as_ref().map_or(0, |map| map.len()) + } + + /// Extends `self` with another `Extensions`. + /// + /// If an instance of a specific type exists in both, the one in `self` is overwritten with the + /// one from `other`. + /// + /// # Example + /// + /// ``` + /// # use rmcp::model::Extensions; + /// let mut ext_a = Extensions::new(); + /// ext_a.insert(8u8); + /// ext_a.insert(16u16); + /// + /// let mut ext_b = Extensions::new(); + /// ext_b.insert(4u8); + /// ext_b.insert("hello"); + /// + /// ext_a.extend(ext_b); + /// assert_eq!(ext_a.len(), 3); + /// assert_eq!(ext_a.get::(), Some(&4u8)); + /// assert_eq!(ext_a.get::(), Some(&16u16)); + /// assert_eq!(ext_a.get::<&'static str>().copied(), Some("hello")); + /// ``` + pub fn extend(&mut self, other: Self) { + if let Some(other) = other.map { + if let Some(map) = &mut self.map { + map.extend(*other); + } else { + self.map = Some(other); + } + } + } +} + +impl fmt::Debug for Extensions { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Extensions").finish() + } +} + +trait AnyClone: Any { + fn clone_box(&self) -> Box; + fn as_any(&self) -> &dyn Any; + fn as_any_mut(&mut self) -> &mut dyn Any; + fn into_any(self: Box) -> Box; +} + +impl AnyClone for T { + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn into_any(self: Box) -> Box { + self + } +} + +impl Clone for Box { + fn clone(&self) -> Self { + (**self).clone_box() + } +} + +#[test] +fn test_extensions() { + #[derive(Clone, Debug, PartialEq)] + struct MyType(i32); + + let mut extensions = Extensions::new(); + + extensions.insert(5i32); + extensions.insert(MyType(10)); + + assert_eq!(extensions.get(), Some(&5i32)); + assert_eq!(extensions.get_mut(), Some(&mut 5i32)); + + let ext2 = extensions.clone(); + + assert_eq!(extensions.remove::(), Some(5i32)); + assert!(extensions.get::().is_none()); + + // clone still has it + assert_eq!(ext2.get(), Some(&5i32)); + assert_eq!(ext2.get(), Some(&MyType(10))); + + assert_eq!(extensions.get::(), None); + assert_eq!(extensions.get(), Some(&MyType(10))); +} diff --git a/vendor/rmcp/src/model/meta.rs b/vendor/rmcp/src/model/meta.rs new file mode 100644 index 000000000..0a7121e4a --- /dev/null +++ b/vendor/rmcp/src/model/meta.rs @@ -0,0 +1,859 @@ +use std::ops::{Deref, DerefMut}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::{ + ClientCapabilities, ClientNotification, ClientRequest, CustomNotification, CustomRequest, + Extensions, Implementation, JsonObject, JsonRpcMessage, LoggingLevel, ProgressToken, + ProtocolVersion, RequestId, ServerNotification, ServerRequest, +}; + +/// Access to the metadata carried by a message envelope's [`Extensions`]. +/// +/// The metadata type differs by message kind: requests carry a +/// [`RequestMetaObject`] and notifications carry a [`NotificationMetaObject`]. +/// +/// The envelope extensions are the canonical runtime location for `_meta`: +/// deserialization strips the wire `params._meta` into the extensions (typed +/// params `meta` fields stay empty), and the service loop moves it into +/// [`RequestContext::meta`] / [`NotificationContext::meta`] before dispatch. +/// Typed params `meta` fields are honored when serializing outgoing messages; +/// on key conflicts the extensions-level metadata wins. +/// +/// [`RequestContext::meta`]: crate::service::RequestContext +/// [`NotificationContext::meta`]: crate::service::NotificationContext +pub trait GetMeta { + /// The metadata type for this message kind. + type Metadata: Default; + fn get_meta_mut(&mut self) -> &mut Self::Metadata; + fn get_meta(&self) -> &Self::Metadata; +} + +pub trait GetExtensions { + fn extensions(&self) -> &Extensions; + fn extensions_mut(&mut self) -> &mut Extensions; +} + +/// Trait for request params that contain the `_meta` field. +/// +/// Per the MCP spec, all request params may have an optional `_meta` +/// field ([`RequestMetaObject`]) that can contain a `progressToken` for +/// tracking long-running operations. +pub trait RequestParamsMeta { + /// Get a reference to the meta field + fn meta(&self) -> Option<&RequestMetaObject>; + /// Get a mutable reference to the meta field + fn meta_mut(&mut self) -> &mut Option; + /// Set the meta field + fn set_meta(&mut self, meta: RequestMetaObject) { + *self.meta_mut() = Some(meta); + } + /// Get the progress token from meta, if present + fn progress_token(&self) -> Option { + self.meta().and_then(|m| m.get_progress_token()) + } + /// Set a progress token in meta + fn set_progress_token(&mut self, token: ProgressToken) { + match self.meta_mut() { + Some(meta) => meta.set_progress_token(token), + none => { + let mut meta = RequestMetaObject::new(); + meta.set_progress_token(token); + *none = Some(meta); + } + } + } + /// Get the W3C `traceparent` value from meta, if present (SEP-414) + fn traceparent(&self) -> Option<&str> { + self.meta().and_then(|m| m.get_traceparent()) + } + /// Set the W3C `traceparent` value in meta (SEP-414) + fn set_traceparent(&mut self, value: &str) { + self.meta_or_default().set_traceparent(value); + } + /// Get the W3C `tracestate` value from meta, if present (SEP-414) + fn tracestate(&self) -> Option<&str> { + self.meta().and_then(|m| m.get_tracestate()) + } + /// Set the W3C `tracestate` value in meta (SEP-414) + fn set_tracestate(&mut self, value: &str) { + self.meta_or_default().set_tracestate(value); + } + /// Get the W3C `baggage` value from meta, if present (SEP-414) + fn baggage(&self) -> Option<&str> { + self.meta().and_then(|m| m.get_baggage()) + } + /// Set the W3C `baggage` value in meta (SEP-414) + fn set_baggage(&mut self, value: &str) { + self.meta_or_default().set_baggage(value); + } + /// Get a mutable reference to meta, inserting an empty one if absent. + fn meta_or_default(&mut self) -> &mut RequestMetaObject { + self.meta_mut().get_or_insert_with(RequestMetaObject::new) + } +} + +impl GetExtensions for CustomNotification { + fn extensions(&self) -> &Extensions { + &self.extensions + } + fn extensions_mut(&mut self) -> &mut Extensions { + &mut self.extensions + } +} + +impl GetMeta for CustomNotification { + type Metadata = NotificationMetaObject; + fn get_meta_mut(&mut self) -> &mut NotificationMetaObject { + self.extensions_mut().get_or_insert_default() + } + fn get_meta(&self) -> &NotificationMetaObject { + self.extensions() + .get::() + .unwrap_or(NotificationMetaObject::static_empty()) + } +} + +impl GetExtensions for CustomRequest { + fn extensions(&self) -> &Extensions { + &self.extensions + } + fn extensions_mut(&mut self) -> &mut Extensions { + &mut self.extensions + } +} + +impl GetMeta for CustomRequest { + type Metadata = RequestMetaObject; + fn get_meta_mut(&mut self) -> &mut RequestMetaObject { + self.extensions_mut().get_or_insert_default() + } + fn get_meta(&self) -> &RequestMetaObject { + self.extensions() + .get::() + .unwrap_or(RequestMetaObject::static_empty()) + } +} + +macro_rules! variant_extension { + ( + $Enum: ident: $Metadata: ident { + $($variant: ident)* + } + ) => { + impl GetExtensions for $Enum { + fn extensions(&self) -> &Extensions { + match self { + $( + $Enum::$variant(v) => &v.extensions, + )* + } + } + fn extensions_mut(&mut self) -> &mut Extensions { + match self { + $( + $Enum::$variant(v) => &mut v.extensions, + )* + } + } + } + impl GetMeta for $Enum { + type Metadata = $Metadata; + fn get_meta_mut(&mut self) -> &mut $Metadata { + self.extensions_mut().get_or_insert_default() + } + fn get_meta(&self) -> &$Metadata { + self.extensions().get::<$Metadata>().unwrap_or($Metadata::static_empty()) + } + } + }; +} + +variant_extension! { + ClientRequest: RequestMetaObject { + PingRequest + InitializeRequest + DiscoverRequest + CompleteRequest + SetLevelRequest + GetPromptRequest + ListPromptsRequest + ListResourcesRequest + ListResourceTemplatesRequest + ReadResourceRequest + SubscriptionsListenRequest + SubscribeRequest + UnsubscribeRequest + CallToolRequest + ListToolsRequest + CustomRequest + GetTaskRequest + UpdateTaskRequest + CancelTaskRequest + } +} + +variant_extension! { + ServerRequest: RequestMetaObject { + PingRequest + CreateMessageRequest + ListRootsRequest + ElicitRequest + CustomRequest + } +} + +variant_extension! { + ClientNotification: NotificationMetaObject { + CancelledNotification + ProgressNotification + InitializedNotification + RootsListChangedNotification + CustomNotification + } +} + +variant_extension! { + ServerNotification: NotificationMetaObject { + CancelledNotification + ProgressNotification + LoggingMessageNotification + ResourceUpdatedNotification + ResourceListChangedNotification + ToolListChangedNotification + PromptListChangedNotification + SubscriptionsAcknowledgedNotification + TaskStatusNotification + CustomNotification + } +} + +/// General-purpose `_meta` map (spec `MetaObject`). +/// +/// This is the metadata shape used by results, content blocks, and catalog +/// descriptors (tools, prompts, resources, roots, ...). It preserves arbitrary +/// extension keys and offers helpers for the reserved W3C Trace Context keys +/// (SEP-414). +/// +/// Request and notification `_meta` maps have additional reserved keys; see +/// [`RequestMetaObject`] and [`NotificationMetaObject`]. +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)] +#[serde(transparent)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct MetaObject(pub JsonObject); + +impl MetaObject { + /// Reserved `_meta` key for the W3C Trace Context `traceparent` value (SEP-414). + const TRACEPARENT_FIELD: &str = "traceparent"; + /// Reserved `_meta` key for the W3C Trace Context `tracestate` value (SEP-414). + const TRACESTATE_FIELD: &str = "tracestate"; + /// Reserved `_meta` key for the W3C Baggage value (SEP-414). + const BAGGAGE_FIELD: &str = "baggage"; + + /// Create an empty metadata map. + pub fn new() -> Self { + Self(JsonObject::new()) + } + + /// Read a string-valued `_meta` field, or `None` if absent or not a string. + fn get_str(&self, field: &str) -> Option<&str> { + self.0.get(field).and_then(Value::as_str) + } + + /// Write a string-valued `_meta` field. + fn set_str(&mut self, field: &str, value: impl Into) { + self.0 + .insert(field.to_string(), Value::String(value.into())); + } + + /// Get the W3C `traceparent` value (SEP-414), if present. + pub fn get_traceparent(&self) -> Option<&str> { + self.get_str(Self::TRACEPARENT_FIELD) + } + + /// Set the W3C `traceparent` value (SEP-414). + /// + /// ``` + /// use rmcp::model::MetaObject; + /// + /// let mut meta = MetaObject::new(); + /// meta.set_traceparent("00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"); + /// assert_eq!( + /// meta.get_traceparent(), + /// Some("00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"), + /// ); + /// ``` + pub fn set_traceparent(&mut self, value: impl Into) { + self.set_str(Self::TRACEPARENT_FIELD, value); + } + + /// Get the W3C `tracestate` value (SEP-414), if present. + pub fn get_tracestate(&self) -> Option<&str> { + self.get_str(Self::TRACESTATE_FIELD) + } + + /// Set the W3C `tracestate` value (SEP-414). + pub fn set_tracestate(&mut self, value: impl Into) { + self.set_str(Self::TRACESTATE_FIELD, value); + } + + /// Get the W3C `baggage` value (SEP-414), if present. + pub fn get_baggage(&self) -> Option<&str> { + self.get_str(Self::BAGGAGE_FIELD) + } + + /// Set the W3C `baggage` value (SEP-414). + pub fn set_baggage(&mut self, value: impl Into) { + self.set_str(Self::BAGGAGE_FIELD, value); + } + + /// Insert every entry of `other`, overwriting existing keys on conflict. + pub fn extend(&mut self, other: MetaObject) { + self.0.extend(other.0); + } + + fn decode_value(&self, key: &str) -> Option + where + T: for<'de> Deserialize<'de>, + { + self.0.get(key).and_then(|value| T::deserialize(value).ok()) + } + + fn insert_serialized(&mut self, key: &str, value: T) + where + T: Serialize, + { + let value = serde_json::to_value(value) + .expect("MCP meta helper value should serialize to valid JSON"); + self.0.insert(key.to_string(), value); + } +} + +impl Deref for MetaObject { + type Target = JsonObject; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for MetaObject { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl From for MetaObject { + fn from(object: JsonObject) -> Self { + Self(object) + } +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for MetaObject { + fn schema_name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("MetaObject") + } + + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "description": "See [MCP general fields](https://modelcontextprotocol.io/specification/2026-07-28/basic#general-fields) for notes on _meta usage.", + "type": "object", + "additionalProperties": true, + }) + } +} + +/// The `_meta` map carried by requests (spec `RequestMetaObject`). +/// +/// In addition to arbitrary extension keys, requests reserve: +/// - `progressToken` for progress tracking +/// - `io.modelcontextprotocol/protocolVersion` (SEP-2575) +/// - `io.modelcontextprotocol/clientInfo` (SEP-2575) +/// - `io.modelcontextprotocol/clientCapabilities` (SEP-2575) +/// - `io.modelcontextprotocol/logLevel` (SEP-2575) +/// +/// The 2026-07-28 draft schema requires the protocol-version and +/// client-capabilities keys; client-info is optional. Earlier protocol versions +/// do not know them. All keys therefore stay optional at runtime and in the +/// generated (version-shared) JSON schema — use +/// [`RequestMetaObject::missing_required_keys`] to validate a request against +/// the negotiated protocol version. +/// +/// This type dereferences to [`MetaObject`] (and transitively to the underlying +/// map), so general helpers such as the SEP-414 trace-context accessors remain +/// available. +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)] +#[serde(transparent)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct RequestMetaObject(pub MetaObject); + +impl RequestMetaObject { + const PROGRESS_TOKEN_FIELD: &str = "progressToken"; + const META_KEY_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion"; + const META_KEY_CLIENT_INFO: &str = "io.modelcontextprotocol/clientInfo"; + const META_KEY_CLIENT_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities"; + const META_KEY_LOG_LEVEL: &str = "io.modelcontextprotocol/logLevel"; + + /// Request `_meta` keys the 2026-07-28 draft schema marks as required. + pub const DRAFT_REQUIRED_KEYS: [&str; 2] = [ + Self::META_KEY_PROTOCOL_VERSION, + Self::META_KEY_CLIENT_CAPABILITIES, + ]; + + /// Create an empty request metadata map. + pub fn new() -> Self { + Self::default() + } + + /// Create a new request meta with a progress token set + pub fn with_progress_token(token: ProgressToken) -> Self { + let mut meta = Self::new(); + meta.set_progress_token(token); + meta + } + + /// Create request metadata with the client context SEP-2575 requires on every request. + pub fn with_client_context( + protocol_version: ProtocolVersion, + client_info: Implementation, + client_capabilities: ClientCapabilities, + ) -> Self { + let mut meta = Self::new(); + meta.set_protocol_version(protocol_version); + meta.set_client_info(client_info); + meta.set_client_capabilities(client_capabilities); + meta + } + + pub(crate) fn static_empty() -> &'static Self { + static EMPTY: std::sync::OnceLock = std::sync::OnceLock::new(); + EMPTY.get_or_init(Default::default) + } + + /// Get the progress token carried in `_meta`, if present and valid. + pub fn get_progress_token(&self) -> Option { + self.0.decode_value(Self::PROGRESS_TOKEN_FIELD) + } + + /// Set the progress token carried in `_meta`. + pub fn set_progress_token(&mut self, token: ProgressToken) { + self.0.insert_serialized(Self::PROGRESS_TOKEN_FIELD, token); + } + + /// Get the MCP protocol version carried in `_meta`, if present and valid. + pub fn protocol_version(&self) -> Option { + self.0.decode_value(Self::META_KEY_PROTOCOL_VERSION) + } + + /// Set the MCP protocol version carried in `_meta`. + pub fn set_protocol_version(&mut self, protocol_version: ProtocolVersion) { + self.0.0.insert( + Self::META_KEY_PROTOCOL_VERSION.to_string(), + Value::String(protocol_version.to_string()), + ); + } + + /// Get the client implementation identity carried in `_meta`, if present and valid. + pub fn client_info(&self) -> Option { + self.0.decode_value(Self::META_KEY_CLIENT_INFO) + } + + /// Set the client implementation identity carried in `_meta`. + pub fn set_client_info(&mut self, client_info: Implementation) { + self.0 + .insert_serialized(Self::META_KEY_CLIENT_INFO, client_info); + } + + /// Get the client capabilities carried in `_meta`, if present and valid. + pub fn client_capabilities(&self) -> Option { + self.0.decode_value(Self::META_KEY_CLIENT_CAPABILITIES) + } + + /// Set the client capabilities carried in `_meta`. + pub fn set_client_capabilities(&mut self, client_capabilities: ClientCapabilities) { + self.0 + .insert_serialized(Self::META_KEY_CLIENT_CAPABILITIES, client_capabilities); + } + + /// Get the requested per-request log level carried in `_meta`, if present and valid. + pub fn log_level(&self) -> Option { + self.0.decode_value(Self::META_KEY_LOG_LEVEL) + } + + /// Set the requested per-request log level carried in `_meta`. + pub fn set_log_level(&mut self, log_level: LoggingLevel) { + self.0 + .insert_serialized(Self::META_KEY_LOG_LEVEL, log_level); + } + + /// Return the [`Self::DRAFT_REQUIRED_KEYS`] whose values are absent or + /// invalid in this map, if `protocol_version` requires them. + /// + /// A key counts as missing when it is not present *or* when its value does + /// not decode into the expected type (e.g. a numeric `protocolVersion` or + /// a string `clientInfo`), matching what the typed accessors return. + /// + /// Protocol versions before 2026-07-28 have no required request metadata, + /// so this always returns an empty list for them. + /// + /// # Examples + /// + /// ``` + /// use rmcp::model::{ProtocolVersion, RequestMetaObject}; + /// + /// let meta = RequestMetaObject::new(); + /// // Older protocols have no required request metadata. + /// assert!( + /// meta.missing_required_keys(&ProtocolVersion::V_2025_11_25) + /// .is_empty() + /// ); + /// // The 2026-07-28 protocol requires per-request context. + /// assert_eq!( + /// meta.missing_required_keys(&ProtocolVersion::V_2026_07_28), + /// RequestMetaObject::DRAFT_REQUIRED_KEYS.to_vec(), + /// ); + /// ``` + pub fn missing_required_keys(&self, protocol_version: &ProtocolVersion) -> Vec<&'static str> { + if protocol_version.as_str() < ProtocolVersion::V_2026_07_28.as_str() { + return Vec::new(); + } + let mut missing = Vec::new(); + if self.protocol_version().is_none() { + missing.push(Self::META_KEY_PROTOCOL_VERSION); + } + if self.client_capabilities().is_none() { + missing.push(Self::META_KEY_CLIENT_CAPABILITIES); + } + missing + } + + /// Insert every entry of `other`, overwriting existing keys on conflict. + pub fn extend(&mut self, other: RequestMetaObject) { + self.0.extend(other.0); + } +} + +impl Deref for RequestMetaObject { + type Target = MetaObject; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for RequestMetaObject { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl From for RequestMetaObject { + fn from(meta: MetaObject) -> Self { + Self(meta) + } +} + +impl From for RequestMetaObject { + fn from(object: JsonObject) -> Self { + Self(MetaObject(object)) + } +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for RequestMetaObject { + fn schema_name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("RequestMetaObject") + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + let progress_token = generator.subschema_for::(); + let client_info = generator.subschema_for::(); + let client_capabilities = generator.subschema_for::(); + let log_level = generator.subschema_for::(); + // rmcp generates one schema shared by every supported protocol + // version, so the keys validated for 2026-07-28 are left + // optional here: a 2025-11-25 request whose `_meta` only carries + // `progressToken` is valid. Version-specific validation is available at + // runtime via [`RequestMetaObject::missing_required_keys`]. + schemars::json_schema!({ + "description": "Metadata reserved by MCP on requests. Extension keys are also allowed.", + "type": "object", + "properties": { + "progressToken": progress_token, + "io.modelcontextprotocol/protocolVersion": { + "type": "string", + }, + "io.modelcontextprotocol/clientInfo": client_info, + "io.modelcontextprotocol/clientCapabilities": client_capabilities, + "io.modelcontextprotocol/logLevel": log_level, + }, + "additionalProperties": true, + }) + } +} + +/// The `_meta` map carried by notifications (spec `NotificationMetaObject`). +/// +/// In addition to arbitrary extension keys, notifications reserve +/// `io.modelcontextprotocol/subscriptionId` to correlate a notification with a +/// prior subscription request. +/// +/// This type dereferences to [`MetaObject`] (and transitively to the underlying +/// map), so general helpers such as the SEP-414 trace-context accessors remain +/// available. +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)] +#[serde(transparent)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct NotificationMetaObject(pub MetaObject); + +impl NotificationMetaObject { + const META_KEY_SUBSCRIPTION_ID: &str = "io.modelcontextprotocol/subscriptionId"; + + /// Create an empty notification metadata map. + pub fn new() -> Self { + Self::default() + } + + pub(crate) fn static_empty() -> &'static Self { + static EMPTY: std::sync::OnceLock = std::sync::OnceLock::new(); + EMPTY.get_or_init(Default::default) + } + + /// Get the subscription id carried in `_meta`, if present and valid. + /// + /// # Examples + /// + /// ``` + /// use rmcp::model::{NotificationMetaObject, RequestId}; + /// + /// let mut meta = NotificationMetaObject::new(); + /// assert_eq!(meta.subscription_id(), None); + /// meta.set_subscription_id(RequestId::Number(7)); + /// assert_eq!(meta.subscription_id(), Some(RequestId::Number(7))); + /// ``` + pub fn subscription_id(&self) -> Option { + self.0.decode_value(Self::META_KEY_SUBSCRIPTION_ID) + } + + /// Set the subscription id carried in `_meta`. + pub fn set_subscription_id(&mut self, subscription_id: RequestId) { + self.0 + .insert_serialized(Self::META_KEY_SUBSCRIPTION_ID, subscription_id); + } + + /// Insert every entry of `other`, overwriting existing keys on conflict. + pub fn extend(&mut self, other: NotificationMetaObject) { + self.0.extend(other.0); + } +} + +impl Deref for NotificationMetaObject { + type Target = MetaObject; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for NotificationMetaObject { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl From for NotificationMetaObject { + fn from(meta: MetaObject) -> Self { + Self(meta) + } +} + +impl From for NotificationMetaObject { + fn from(object: JsonObject) -> Self { + Self(MetaObject(object)) + } +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for NotificationMetaObject { + fn schema_name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("NotificationMetaObject") + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + let subscription_id = generator.subschema_for::(); + schemars::json_schema!({ + "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", + "type": "object", + "properties": { + "io.modelcontextprotocol/subscriptionId": subscription_id, + }, + "additionalProperties": true, + }) + } +} + +impl JsonRpcMessage +where + Req: GetExtensions, + Noti: GetExtensions, +{ + pub fn insert_extension(&mut self, value: T) { + match self { + JsonRpcMessage::Request(json_rpc_request) => { + json_rpc_request.request.extensions_mut().insert(value); + } + JsonRpcMessage::Notification(json_rpc_notification) => { + json_rpc_notification + .notification + .extensions_mut() + .insert(value); + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::NumberOrString; + + #[derive(Default)] + struct Params { + meta: Option, + } + + impl RequestParamsMeta for Params { + fn meta(&self) -> Option<&RequestMetaObject> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } + } + + const TRACEPARENT: &str = "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"; + + #[test] + fn trace_context_round_trip() { + let mut meta = MetaObject::new(); + meta.set_traceparent(TRACEPARENT); + meta.set_tracestate("vendor1=value1,vendor2=value2"); + meta.set_baggage("userId=alice,region=us-east-1"); + assert_eq!(meta.get_traceparent(), Some(TRACEPARENT)); + assert_eq!(meta.get_tracestate(), Some("vendor1=value1,vendor2=value2")); + assert_eq!(meta.get_baggage(), Some("userId=alice,region=us-east-1")); + } + + #[test] + fn absent_field_is_none() { + let meta = MetaObject::new(); + assert_eq!(meta.get_traceparent(), None); + assert_eq!(meta.get_tracestate(), None); + assert_eq!(meta.get_baggage(), None); + } + + #[test] + fn non_string_value_is_none() { + let mut meta = MetaObject::new(); + meta.0 + .insert(MetaObject::TRACEPARENT_FIELD.to_string(), Value::from(42)); + assert_eq!(meta.get_traceparent(), None); + } + + #[test] + fn trait_setter_inserts_meta_when_absent() { + let mut params = Params::default(); + assert_eq!(params.traceparent(), None); + params.set_traceparent(TRACEPARENT); + assert_eq!(params.traceparent(), Some(TRACEPARENT)); + } + + #[test] + fn request_meta_derefs_to_general_helpers() { + let mut meta = RequestMetaObject::new(); + meta.set_traceparent(TRACEPARENT); + meta.set_progress_token(ProgressToken(NumberOrString::Number(7))); + assert_eq!(meta.get_traceparent(), Some(TRACEPARENT)); + assert_eq!( + meta.get_progress_token(), + Some(ProgressToken(NumberOrString::Number(7))) + ); + } + + mod subscription_id { + use super::*; + + #[test] + fn returns_none_when_absent() { + let meta = NotificationMetaObject::new(); + assert_eq!(meta.subscription_id(), None); + } + + #[test] + fn round_trips_number_id() { + let mut meta = NotificationMetaObject::new(); + meta.set_subscription_id(RequestId::Number(42)); + assert_eq!(meta.subscription_id(), Some(RequestId::Number(42))); + } + + #[test] + fn round_trips_string_id() { + let mut meta = NotificationMetaObject::new(); + meta.set_subscription_id(RequestId::String("sub-1".into())); + assert_eq!( + meta.subscription_id(), + Some(RequestId::String("sub-1".into())) + ); + } + } + + mod missing_required_keys { + use super::*; + + #[test] + fn is_empty_for_pre_draft_protocols() { + let meta = RequestMetaObject::new(); + assert!( + meta.missing_required_keys(&ProtocolVersion::V_2025_11_25) + .is_empty() + ); + } + + #[test] + fn lists_all_draft_keys_for_empty_meta() { + let meta = RequestMetaObject::new(); + assert_eq!( + meta.missing_required_keys(&ProtocolVersion::V_2026_07_28), + RequestMetaObject::DRAFT_REQUIRED_KEYS.to_vec() + ); + } + + #[test] + fn treats_malformed_values_as_missing() { + let meta: RequestMetaObject = serde_json::from_value(serde_json::json!({ + "io.modelcontextprotocol/protocolVersion": 123, + "io.modelcontextprotocol/clientCapabilities": null, + })) + .unwrap(); + assert_eq!( + meta.missing_required_keys(&ProtocolVersion::V_2026_07_28), + RequestMetaObject::DRAFT_REQUIRED_KEYS.to_vec() + ); + } + + #[test] + fn is_empty_when_draft_keys_are_present() { + let mut meta = RequestMetaObject::new(); + meta.set_protocol_version(ProtocolVersion::V_2026_07_28); + meta.set_client_info(Implementation::from_build_env()); + meta.set_client_capabilities(ClientCapabilities::default()); + assert!( + meta.missing_required_keys(&ProtocolVersion::V_2026_07_28) + .is_empty() + ); + } + } +} diff --git a/vendor/rmcp/src/model/mrtr.rs b/vendor/rmcp/src/model/mrtr.rs new file mode 100644 index 000000000..8dac9a391 --- /dev/null +++ b/vendor/rmcp/src/model/mrtr.rs @@ -0,0 +1,558 @@ +//! Multi Round-Trip Request (MRTR) types for SEP-2322. +//! +//! Provides [`InputRequiredResult`], [`InputRequests`], and [`InputResponses`] +//! for the stateless multi round-trip request pattern defined in the MCP spec. +//! [`ResultType`] lives in the parent [`super`] module alongside other base result types. +//! +//! # Overview +//! +//! A server may respond to `tools/call`, `prompts/get`, or `resources/read` with an +//! [`InputRequiredResult`] instead of the normal result. The client fulfills the +//! [`InputRequests`], then retries the original request with [`InputResponses`] and +//! the echoed `requestState`. +//! +//! # Using MRTR +//! +//! **Server:** return an [`InputRequiredResult`] from a tool/prompt/resource +//! handler via the matching outcome enum ([`CallToolResponse`], +//! [`GetPromptResponse`], [`ReadResourceResponse`]). The SDK only lets an +//! `InputRequiredResult` reach a peer that negotiated protocol version +//! `2026-07-28` or newer; older peers get a protocol error instead. +//! +//! **Client:** the high-level `RunningService` helpers — `call_tool`, +//! `get_prompt`, and `read_resource` — automatically fulfil each +//! [`InputRequest`] through the local `ClientHandler` and retry, up to +//! [`DEFAULT_MRTR_MAX_ROUNDS`]. Use the `*_once` variants (e.g. +//! `call_tool_once`) to receive an [`InputRequiredResult`] directly and drive +//! the rounds yourself. +//! +//! # `requestState` is untrusted +//! +//! The client echoes `requestState` back verbatim, so a stateless server that +//! stores meaningful data in it MUST verify integrity before trusting the echoed +//! value. Enable the `request-state` feature and use `RequestStateCodec` to seal +//! and open it, or keep the state server-side and use `requestState` only as an +//! opaque handle. +//! +//! A complete runnable walkthrough lives in the `servers_mrtr` example. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::{ + CallToolResult, CreateMessageRequest, CreateTaskResult, ElicitRequest, GetPromptResult, + ListRootsRequest, MetaObject, ReadResourceResult, ResultType, ServerResult, +}; + +/// Default maximum number of MRTR rounds a high-level client call will drive. +/// +/// This matches the default used by other Tier 1 SDKs and prevents a +/// misbehaving peer from keeping a request alive indefinitely. +pub const DEFAULT_MRTR_MAX_ROUNDS: usize = 10; + +/// A server-initiated request that can appear inside [`InputRequests`]. +/// +/// Per the MCP spec, only `CreateMessageRequest` (sampling), +/// `ElicitRequest` (elicitation), and `ListRootsRequest` (roots) +/// are allowed. This is modeled as an untagged enum rather than a +/// `ServerRequest` alias to prevent `PingRequest` or `CustomRequest` from +/// being included. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub enum InputRequest { + /// A `sampling/createMessage` request. + CreateMessage(CreateMessageRequest), + /// An `elicitation/create` request. + Elicitation(ElicitRequest), + /// A `roots/list` request. + ListRoots(ListRootsRequest), +} + +// Wire-level equality: two `InputRequest`s are equal if they serialize to the +// same JSON. The wrapped request envelopes do not implement `PartialEq` +// structurally (they carry `Extensions`). +impl PartialEq for InputRequest { + fn eq(&self, other: &Self) -> bool { + match (serde_json::to_value(self), serde_json::to_value(other)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } + } +} + +/// A map of server-initiated requests that the client must fulfill. +/// +/// Keys are server-assigned string identifiers; values are request objects +/// (`ElicitRequest`, `CreateMessageRequest`, or `ListRootsRequest`). +pub type InputRequests = BTreeMap; + +/// A map of client responses to server-initiated requests. +/// +/// Keys correspond to the keys in the [`InputRequests`] map; values are the +/// client's result for each request (`ElicitResult`, `CreateMessageResult`, +/// or `ListRootsResult`), represented as opaque JSON because the +/// heterogeneous `ClientResult` union does not derive the traits required +/// for use as a `BTreeMap` value. +pub type InputResponses = BTreeMap; + +/// Result of a `tools/call` request, including the MRTR intermediate result. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum CallToolResponse { + /// The server completed the tool call. + Complete(CallToolResult), + /// The server requires client-side input before the tool call can complete. + InputRequired(InputRequiredResult), + /// The server materialized a task for this call (SEP-2663 Tasks extension, + /// `resultType: "task"`). The client polls `tasks/get` for the result. + Task(CreateTaskResult), +} + +impl From for CallToolResponse { + fn from(result: CallToolResult) -> Self { + Self::Complete(result) + } +} + +impl From for CallToolResponse { + fn from(result: InputRequiredResult) -> Self { + Self::InputRequired(result) + } +} + +impl From for ServerResult { + fn from(response: CallToolResponse) -> Self { + match response { + CallToolResponse::Complete(result) => ServerResult::CallToolResult(result), + CallToolResponse::InputRequired(result) => ServerResult::InputRequiredResult(result), + CallToolResponse::Task(result) => ServerResult::CreateTaskResult(result), + } + } +} + +impl From for CallToolResponse { + fn from(result: CreateTaskResult) -> Self { + Self::Task(result) + } +} + +/// Result of a `prompts/get` request, including the MRTR intermediate result. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum GetPromptResponse { + /// The server completed the prompt request. + Complete(GetPromptResult), + /// The server requires client-side input before the prompt can be returned. + InputRequired(InputRequiredResult), +} + +impl From for GetPromptResponse { + fn from(result: GetPromptResult) -> Self { + Self::Complete(result) + } +} + +impl From for GetPromptResponse { + fn from(result: InputRequiredResult) -> Self { + Self::InputRequired(result) + } +} + +impl From for ServerResult { + fn from(response: GetPromptResponse) -> Self { + match response { + GetPromptResponse::Complete(result) => ServerResult::GetPromptResult(result), + GetPromptResponse::InputRequired(result) => ServerResult::InputRequiredResult(result), + } + } +} + +/// Result of a `resources/read` request, including the MRTR intermediate result. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum ReadResourceResponse { + /// The server completed the resource read. + Complete(ReadResourceResult), + /// The server requires client-side input before the resource can be returned. + InputRequired(InputRequiredResult), +} + +impl From for ReadResourceResponse { + fn from(result: ReadResourceResult) -> Self { + Self::Complete(result) + } +} + +impl From for ReadResourceResponse { + fn from(result: InputRequiredResult) -> Self { + Self::InputRequired(result) + } +} + +impl From for ServerResult { + fn from(response: ReadResourceResponse) -> Self { + match response { + ReadResourceResponse::Complete(result) => ServerResult::ReadResourceResult(result), + ReadResourceResponse::InputRequired(result) => { + ServerResult::InputRequiredResult(result) + } + } + } +} + +/// A result indicating that additional input is needed before the request +/// can be completed. +/// +/// At least one of [`input_requests`](Self::input_requests) or +/// [`request_state`](Self::request_state) MUST be present. +/// +/// Servers MAY send this in response to `tools/call`, `prompts/get`, or +/// `resources/read`. Servers MUST NOT send this for any other request. +/// +/// # Examples +/// +/// ``` +/// use rmcp::model::InputRequiredResult; +/// +/// let result = InputRequiredResult::from_request_state("opaque-server-state"); +/// assert!(result.input_requests.is_none()); +/// assert_eq!(result.request_state.as_deref(), Some("opaque-server-state")); +/// ``` +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct InputRequiredResult { + /// Always `"input_required"` for this result type. + pub result_type: ResultType, + + /// Server-initiated requests that the client must fulfill before retrying. + #[serde(skip_serializing_if = "Option::is_none")] + pub input_requests: Option, + + /// Opaque request state to be echoed back by the client on retry. + /// Clients MUST NOT inspect, parse, modify, or make any assumptions + /// about the contents. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_state: Option, + + /// Optional protocol-level metadata. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// Custom deserializer that requires `resultType: "input_required"` to prevent +/// greedy matching in the untagged `ServerResult` enum (which would otherwise +/// swallow empty objects or unknown shapes). +impl<'de> Deserialize<'de> for InputRequiredResult { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Helper { + result_type: Option, + input_requests: Option, + request_state: Option, + #[serde(rename = "_meta")] + meta: Option, + } + + let helper = Helper::deserialize(deserializer)?; + + match &helper.result_type { + Some(rt) if rt.is_input_required() => {} + _ => { + return Err(serde::de::Error::custom( + "InputRequiredResult requires resultType to be \"input_required\"", + )); + } + } + + if helper.input_requests.is_none() && helper.request_state.is_none() { + return Err(serde::de::Error::custom( + "InputRequiredResult requires at least one of inputRequests or requestState", + )); + } + + Ok(InputRequiredResult { + result_type: ResultType::INPUT_REQUIRED, + input_requests: helper.input_requests, + request_state: helper.request_state, + meta: helper.meta, + }) + } +} + +impl InputRequiredResult { + /// Creates a new `InputRequiredResult` with both input requests and request state. + pub fn new(input_requests: Option, request_state: Option) -> Self { + Self { + result_type: ResultType::INPUT_REQUIRED, + input_requests, + request_state, + meta: None, + } + } + + /// Creates from input requests only. + pub fn from_input_requests(input_requests: InputRequests) -> Self { + Self::new(Some(input_requests), None) + } + + /// Creates from request state only (e.g. for load shedding). + pub fn from_request_state(request_state: impl Into) -> Self { + Self::new(None, Some(request_state.into())) + } + + /// Sets optional metadata. + pub fn with_meta(mut self, meta: MetaObject) -> Self { + self.meta = Some(meta); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod result_type { + use super::*; + + #[test] + fn default_is_complete() { + assert_eq!(ResultType::default(), ResultType::COMPLETE); + } + + #[test] + fn serializes_complete() { + assert_eq!( + serde_json::to_value(&ResultType::COMPLETE).unwrap(), + serde_json::json!("complete") + ); + } + + #[test] + fn serializes_input_required() { + assert_eq!( + serde_json::to_value(&ResultType::INPUT_REQUIRED).unwrap(), + serde_json::json!("input_required") + ); + } + + #[test] + fn deserializes_known_values() { + let complete: ResultType = + serde_json::from_value(serde_json::json!("complete")).unwrap(); + assert_eq!(complete, ResultType::COMPLETE); + + let input_required: ResultType = + serde_json::from_value(serde_json::json!("input_required")).unwrap(); + assert_eq!(input_required, ResultType::INPUT_REQUIRED); + } + + #[test] + fn preserves_unknown_extension_values() { + let custom: ResultType = + serde_json::from_value(serde_json::json!("streaming")).unwrap(); + assert_eq!(custom.as_str(), "streaming"); + assert!(!custom.is_complete()); + assert!(!custom.is_input_required()); + + let reserialized = serde_json::to_value(&custom).unwrap(); + assert_eq!(reserialized, serde_json::json!("streaming")); + } + } + + mod input_required_result { + use super::*; + + #[test] + fn deserializes_with_requests_and_state() { + let json = serde_json::json!({ + "resultType": "input_required", + "inputRequests": { + "github_login": { + "method": "elicitation/create", + "params": { + "message": "Please provide your GitHub username", + "requestedSchema": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"] + } + } + }, + "capital_of_france": { + "method": "sampling/createMessage", + "params": { + "messages": [{ + "role": "user", + "content": { "type": "text", "text": "What is the capital of France?" } + }], + "maxTokens": 100 + } + } + }, + "requestState": "eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0" + }); + + let result: InputRequiredResult = serde_json::from_value(json).unwrap(); + + let requests = result + .input_requests + .as_ref() + .expect("should have input_requests"); + assert_eq!(requests.len(), 2); + assert!(requests.contains_key("github_login")); + assert!(requests.contains_key("capital_of_france")); + assert_eq!( + result.request_state.as_deref(), + Some("eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0") + ); + } + + #[test] + fn roundtrip_preserves_all_fields() { + let json = serde_json::json!({ + "resultType": "input_required", + "inputRequests": { + "key": { + "method": "elicitation/create", + "params": { + "message": "test", + "requestedSchema": { "type": "object", "properties": {} } + } + } + }, + "requestState": "abc123" + }); + + let result: InputRequiredResult = serde_json::from_value(json).unwrap(); + let reserialized = serde_json::to_value(&result).unwrap(); + + assert_eq!(reserialized["resultType"], "input_required"); + assert!(reserialized["inputRequests"].is_object()); + assert_eq!(reserialized["requestState"], "abc123"); + } + + #[test] + fn deserializes_with_request_state_only() { + let json = serde_json::json!({ + "resultType": "input_required", + "requestState": "eyJwcm9ncmVzcyI6IjUwJSJ9" + }); + + let result: InputRequiredResult = serde_json::from_value(json).unwrap(); + + assert!(result.input_requests.is_none()); + assert_eq!( + result.request_state.as_deref(), + Some("eyJwcm9ncmVzcyI6IjUwJSJ9") + ); + } + + #[test] + fn rejects_missing_input_requests_and_request_state() { + let json = serde_json::json!({ + "resultType": "input_required", + "_meta": {} + }); + let err = serde_json::from_value::(json).unwrap_err(); + assert!( + err.to_string().contains("inputRequests or requestState"), + "error should mention the required continuation fields, got: {err}" + ); + } + + #[test] + fn rejects_missing_result_type() { + let json = serde_json::json!({ + "requestState": "some-state" + }); + let err = serde_json::from_value::(json).unwrap_err(); + assert!( + err.to_string().contains("input_required"), + "error should mention the required resultType, got: {err}" + ); + } + + #[test] + fn rejects_wrong_result_type() { + let json = serde_json::json!({ + "resultType": "complete", + "requestState": "some-state" + }); + let err = serde_json::from_value::(json).unwrap_err(); + assert!( + err.to_string().contains("input_required"), + "error should mention the required resultType, got: {err}" + ); + } + } + + mod input_responses { + use super::*; + + #[test] + fn deserializes_heterogeneous_results() { + let json = serde_json::json!({ + "github_login": { + "action": "accept", + "content": { "name": "octocat" } + }, + "capital_of_france": { + "role": "assistant", + "content": { "type": "text", "text": "Paris." }, + "model": "claude-3-sonnet-20240307", + "stopReason": "endTurn" + } + }); + + let responses: InputResponses = serde_json::from_value(json).unwrap(); + + assert_eq!(responses.len(), 2); + assert!(responses.contains_key("github_login")); + assert!(responses.contains_key("capital_of_france")); + } + } + + mod constructors { + use super::*; + + #[test] + fn from_request_state_sets_state_only() { + let result = InputRequiredResult::from_request_state("opaque"); + + assert_eq!(result.result_type, ResultType::INPUT_REQUIRED); + assert!(result.input_requests.is_none()); + assert_eq!(result.request_state.as_deref(), Some("opaque")); + } + + #[test] + fn from_input_requests_sets_requests_only() { + let mut requests = InputRequests::new(); + requests.insert( + "key".to_string(), + serde_json::from_value(serde_json::json!({ + "method": "elicitation/create", + "params": { + "message": "test", + "requestedSchema": { "type": "object", "properties": {} } + } + })) + .unwrap(), + ); + + let result = InputRequiredResult::from_input_requests(requests); + + assert!(result.input_requests.is_some()); + assert!(result.request_state.is_none()); + } + } +} diff --git a/vendor/rmcp/src/model/prompt.rs b/vendor/rmcp/src/model/prompt.rs new file mode 100644 index 000000000..46f5168ea --- /dev/null +++ b/vendor/rmcp/src/model/prompt.rs @@ -0,0 +1,367 @@ +use serde::{Deserialize, Serialize}; + +use super::{ + Annotations, ContentBlock, Icon, MetaObject, Role, + content::{AudioContent, EmbeddedResource, ImageContent, TextContent}, + resource::ResourceContents, +}; + +/// A prompt or prompt template that the server offers (spec `Prompt`). +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct Prompt { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub arguments: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub icons: Option>, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl Prompt { + pub fn new( + name: N, + description: Option, + arguments: Option>, + ) -> Self + where + N: Into, + D: Into, + { + Prompt { + name: name.into(), + title: None, + description: description.map(Into::into), + arguments, + icons: None, + meta: None, + } + } + + pub fn from_raw( + name: impl Into, + description: Option>, + arguments: Option>, + ) -> Self { + Prompt { + name: name.into(), + title: None, + description: description.map(Into::into), + arguments, + icons: None, + meta: None, + } + } + + pub fn with_title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + pub fn with_icons(mut self, icons: Vec) -> Self { + self.icons = Some(icons); + self + } + + pub fn with_meta(mut self, meta: MetaObject) -> Self { + self.meta = Some(meta); + self + } +} + +/// Describes an argument that a prompt can accept (spec `PromptArgument`). +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct PromptArgument { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option, +} + +impl PromptArgument { + pub fn new>(name: N) -> Self { + PromptArgument { + name: name.into(), + title: None, + description: None, + required: None, + } + } + + pub fn with_title>(mut self, title: T) -> Self { + self.title = Some(title.into()); + self + } + + pub fn with_description>(mut self, description: D) -> Self { + self.description = Some(description.into()); + self + } + + pub fn with_required(mut self, required: bool) -> Self { + self.required = Some(required); + self + } +} + +/// A message returned as part of a prompt (spec `PromptMessage`). +/// +/// Uses the unified `ContentBlock` for its content (text | image | audio | resource_link | resource). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct PromptMessage { + pub role: Role, + pub content: ContentBlock, +} + +impl PromptMessage { + pub fn new(role: Role, content: ContentBlock) -> Self { + Self { role, content } + } + + pub fn new_text>(role: Role, text: S) -> Self { + Self { + role, + content: ContentBlock::text(text), + } + } + + #[cfg(feature = "base64")] + pub fn new_image( + role: Role, + data: &[u8], + mime_type: &str, + meta: Option, + annotations: Option, + ) -> Self { + use base64::{Engine, prelude::BASE64_STANDARD}; + + let base64 = BASE64_STANDARD.encode(data); + Self { + role, + content: ContentBlock::Image(ImageContent { + data: base64, + mime_type: mime_type.into(), + meta, + annotations, + }), + } + } + + #[cfg(feature = "base64")] + pub fn new_audio( + role: Role, + data: &[u8], + mime_type: &str, + meta: Option, + annotations: Option, + ) -> Self { + use base64::{Engine, prelude::BASE64_STANDARD}; + + let base64 = BASE64_STANDARD.encode(data); + Self { + role, + content: ContentBlock::Audio(AudioContent { + data: base64, + mime_type: mime_type.into(), + meta, + annotations, + }), + } + } + + pub fn new_resource( + role: Role, + uri: String, + mime_type: Option, + text: Option, + resource_meta: Option, + resource_content_meta: Option, + annotations: Option, + ) -> Self { + let resource_contents = match text { + Some(t) => ResourceContents::TextResourceContents { + uri, + mime_type, + text: t, + meta: resource_content_meta, + }, + None => ResourceContents::BlobResourceContents { + uri, + mime_type, + blob: String::new(), + meta: resource_content_meta, + }, + }; + Self { + role, + content: ContentBlock::Resource(EmbeddedResource { + meta: resource_meta, + resource: resource_contents, + annotations, + }), + } + } + + pub fn new_text_with_meta>( + role: Role, + text: S, + meta: Option, + ) -> Self { + Self { + role, + content: ContentBlock::Text(TextContent { + text: text.into(), + meta, + annotations: None, + }), + } + } + + pub fn new_resource_link(role: Role, resource: super::resource::Resource) -> Self { + Self { + role, + content: ContentBlock::ResourceLink(resource), + } + } +} + +#[cfg(test)] +mod tests { + use serde_json; + + use super::*; + + #[test] + fn test_prompt_message_image_serialization() { + let image = ImageContent::new("base64data", "image/png"); + let json = serde_json::to_string(&image).unwrap(); + assert!(json.contains("mimeType")); + assert!(!json.contains("mime_type")); + } + + #[test] + fn test_prompt_message_audio_serialization_and_deserialization() { + let content = ContentBlock::Audio(AudioContent::new("YXVkaW8=", "audio/wav")); + let value = serde_json::to_value(&content).unwrap(); + assert_eq!(value.get("type").and_then(|v| v.as_str()), Some("audio")); + assert_eq!(value.get("data").and_then(|v| v.as_str()), Some("YXVkaW8=")); + assert_eq!( + value.get("mimeType").and_then(|v| v.as_str()), + Some("audio/wav"), + "expected camelCase mimeType, got: {value:#?}" + ); + + let json = r#"{"type":"audio","data":"YXVkaW8=","mimeType":"audio/wav"}"#; + let parsed: ContentBlock = serde_json::from_str(json).unwrap(); + assert_eq!(parsed, content); + } + + #[test] + #[cfg(feature = "base64")] + fn test_prompt_message_new_audio_constructor() { + let message = PromptMessage::new_audio(Role::User, b"hello", "audio/wav", None, None); + let value = serde_json::to_value(&message).unwrap(); + let content = value.get("content").expect("content present"); + assert_eq!(content.get("type").and_then(|v| v.as_str()), Some("audio")); + assert_eq!( + content.get("mimeType").and_then(|v| v.as_str()), + Some("audio/wav") + ); + assert_eq!( + content.get("data").and_then(|v| v.as_str()), + Some("aGVsbG8=") + ); + } + + #[test] + fn test_prompt_message_resource_link_serialization() { + use super::super::resource::Resource; + + let resource = Resource::new("file:///test.txt", "test.txt"); + let message = PromptMessage::new_resource_link(Role::User, resource); + + let json = serde_json::to_string(&message).unwrap(); + assert!(json.contains("\"type\":\"resource_link\"")); + assert!(json.contains("\"uri\":\"file:///test.txt\"")); + assert!(json.contains("\"name\":\"test.txt\"")); + } + + #[test] + fn test_prompt_message_resource_serialization_is_flat() { + let message = PromptMessage::new_resource( + Role::User, + "alc://packages/sc/narrative".to_string(), + Some("text/markdown".to_string()), + Some("# Hello".to_string()), + None, + None, + None, + ); + + let value: serde_json::Value = serde_json::to_value(&message).unwrap(); + let content = value.get("content").expect("content present"); + assert_eq!( + content.get("type").and_then(|v| v.as_str()), + Some("resource") + ); + + let resource = content + .get("resource") + .expect("resource field present at content level"); + + assert_eq!( + resource.get("uri").and_then(|v| v.as_str()), + Some("alc://packages/sc/narrative"), + "expected flat resource.uri, got: {resource:#?}" + ); + assert_eq!( + resource.get("mimeType").and_then(|v| v.as_str()), + Some("text/markdown") + ); + assert_eq!( + resource.get("text").and_then(|v| v.as_str()), + Some("# Hello") + ); + + assert!( + resource.get("resource").is_none(), + "double-nested resource detected (regression): {resource:#?}" + ); + } + + #[test] + fn test_prompt_message_content_resource_link_deserialization() { + let json = r#"{ + "type": "resource_link", + "uri": "file:///example.txt", + "name": "example.txt", + "description": "Example file", + "mimeType": "text/plain" + }"#; + + let content: ContentBlock = serde_json::from_str(json).unwrap(); + + if let ContentBlock::ResourceLink(resource) = content { + assert_eq!(resource.uri, "file:///example.txt"); + assert_eq!(resource.name, "example.txt"); + assert_eq!(resource.description, Some("Example file".to_string())); + assert_eq!(resource.mime_type, Some("text/plain".to_string())); + } else { + panic!("Expected ResourceLink variant"); + } + } +} diff --git a/vendor/rmcp/src/model/request_state.rs b/vendor/rmcp/src/model/request_state.rs new file mode 100644 index 000000000..5ed0f7de8 --- /dev/null +++ b/vendor/rmcp/src/model/request_state.rs @@ -0,0 +1,577 @@ +//! Integrity protection for SEP-2322 `requestState`. +//! +//! In the multi round-trip request (MRTR) flow, a server places an opaque +//! `requestState` string in an [`InputRequiredResult`](super::InputRequiredResult) +//! and the client echoes it back verbatim on retry. From the server's point of +//! view the echoed value is **untrusted, attacker-controlled input**: a client +//! can send back anything it likes. Per SEP-2322, a server that lets +//! `requestState` influence authorization, resource access, or business logic +//! MUST protect its integrity and reject values that fail verification. +//! +//! [`RequestStateCodec`] provides an opt-in way to do this. It seals a payload +//! into an opaque string with an HMAC-SHA256 tag and opens it again, rejecting +//! any value that was forged or tampered with. +//! +//! To follow the spec's replay-prevention guidance without hand-rolling the +//! checks, the codec supports two bindings via [`SealOptions`]: +//! +//! * **Associated data** — arbitrary context (e.g. the authenticated principal +//! plus a digest of the originating request) that is mixed into the tag but +//! not stored in the token. [`open_with`](RequestStateCodec::open_with) only +//! succeeds when the caller supplies the same context, so a value cannot be +//! replayed by a different principal or against a different request. This is +//! *fail-closed*: forgetting to pass the context makes verification fail. +//! * **TTL** — a relative expiry stamped into the token; opening a value past +//! its expiry fails with [`RequestStateError::Expired`]. +//! +//! Single-use/nonce enforcement (for one-time redemptions) still has to be done +//! server-side, as the spec notes. +//! +//! This helper is only about *integrity*, not *confidentiality*: the sealed +//! payload is signed, not encrypted, so it is base64url-readable by anyone. Do +//! not put secrets in it. +//! +//! Using the codec is entirely optional. A server that keeps its state +//! server-side, or that does not trust `requestState` for anything security +//! sensitive, can keep building the string by hand via +//! [`InputRequiredResult::from_request_state`](super::InputRequiredResult::from_request_state). +//! +//! # Examples +//! +//! ``` +//! use rmcp::model::{RequestStateCodec, SealOptions}; +//! +//! // Derive the key from a per-process secret; keep it out of client reach. +//! let codec = RequestStateCodec::new(b"a-32-byte-or-longer-secret-key!!!"); +//! +//! // Bind the state to the caller and the originating request. +//! let context = b"user:alice|tools/call:weather"; +//! let sealed = codec.seal_with( +//! b"step=2", +//! &SealOptions::new().associated_data(context), +//! ); +//! +//! // On retry the client echoes `sealed` back untouched; the server re-derives +//! // the same context and opens it. +//! let opened = codec.open_with(&sealed, context).expect("integrity check passes"); +//! assert_eq!(opened, b"step=2"); +//! +//! // A different principal (different context) is rejected. +//! assert!(codec.open_with(&sealed, b"user:bob|tools/call:weather").is_err()); +//! ``` + +use std::time::Duration; + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use hmac::{Hmac, KeyInit, Mac}; +use serde::{Serialize, de::DeserializeOwned}; +use sha2::Sha256; +use thiserror::Error; + +type HmacSha256 = Hmac; + +/// Version tag prefixing every sealed value, so the wire format can evolve. +const VERSION: &str = "rs1"; + +/// Domain-separation label mixed into the HMAC so a `requestState` tag can never +/// be confused with an HMAC computed for some other purpose using the same key. +const DOMAIN: &[u8] = b"rmcp/mrtr/request-state/v1"; + +/// Length of the big-endian expiry prefix (unix milliseconds) stored at the +/// front of every sealed body. `0` means "no expiry". +const EXPIRY_LEN: usize = 8; + +/// Errors returned when opening a sealed [`RequestStateCodec`] value. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum RequestStateError { + /// The value is not a well-formed sealed request state (wrong prefix or + /// missing sections). + #[error("request state is malformed or uses an unsupported format")] + MalformedFormat, + + /// A section of the value was not valid base64url. + #[error("request state is not valid base64url")] + InvalidEncoding, + + /// The HMAC tag did not match; the value was forged, tampered with, or + /// opened with the wrong associated data. + #[error("request state failed integrity verification")] + IntegrityCheckFailed, + + /// The value carried a TTL that has already elapsed. + #[error("request state has expired")] + Expired, + + /// The sealed payload could not be serialized to JSON. + #[error("failed to serialize request state payload: {0}")] + Serialization(#[source] serde_json::Error), + + /// The opened payload could not be deserialized from JSON. + #[error("failed to deserialize request state payload: {0}")] + Deserialization(#[source] serde_json::Error), +} + +/// Options controlling how a value is sealed by [`RequestStateCodec`]. +/// +/// Defaults to no associated data and no expiry, which is equivalent to the +/// bare [`seal`](RequestStateCodec::seal) / [`open`](RequestStateCodec::open) +/// methods. +#[derive(Clone, Copy, Debug, Default)] +pub struct SealOptions<'a> { + associated_data: &'a [u8], + ttl: Option, +} + +impl<'a> SealOptions<'a> { + /// Creates empty options (no associated data, no expiry). + pub fn new() -> Self { + Self::default() + } + + /// Binds the sealed value to `associated_data`. The same bytes must be + /// supplied to [`open_with`](RequestStateCodec::open_with); the data is + /// authenticated but not stored in the token. + /// + /// Use this to bind the state to the authenticated principal and/or the + /// originating request (e.g. method name plus a digest of its parameters). + pub fn associated_data(mut self, associated_data: &'a [u8]) -> Self { + self.associated_data = associated_data; + self + } + + /// Sets a relative time-to-live after which opening the value fails with + /// [`RequestStateError::Expired`]. + pub fn ttl(mut self, ttl: Duration) -> Self { + self.ttl = Some(ttl); + self + } +} + +/// A keyed codec that seals and opens SEP-2322 `requestState` values with +/// HMAC-SHA256 integrity protection. +/// +/// Construct one codec per signing key and reuse it for the lifetime of the +/// key. The same key must be used to [`seal`](Self::seal) and +/// [`open`](Self::open) a value, so it has to survive across the rounds of a +/// single MRTR exchange (e.g. a stable per-process or per-deployment secret). +/// +/// The key may be any length; HMAC internally normalizes it. For meaningful +/// security use a high-entropy key of at least 32 bytes. +#[derive(Clone)] +pub struct RequestStateCodec { + key: Box<[u8]>, +} + +impl std::fmt::Debug for RequestStateCodec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never leak the signing key through Debug output. + f.debug_struct("RequestStateCodec") + .field("key", &"") + .finish() + } +} + +impl RequestStateCodec { + /// Creates a codec from a signing key. + pub fn new(key: impl Into>) -> Self { + Self { + key: key.into().into_boxed_slice(), + } + } + + /// Seals raw bytes into an opaque, integrity-protected string suitable for + /// use as `requestState`. + pub fn seal(&self, payload: &[u8]) -> String { + self.seal_with(payload, &SealOptions::default()) + } + + /// Seals raw bytes with [`SealOptions`] (associated data and/or TTL). + pub fn seal_with(&self, payload: &[u8], options: &SealOptions<'_>) -> String { + self.seal_at(payload, options, Self::now_ms()) + } + + /// Seals a serializable value by encoding it as JSON before sealing. + /// + /// # Errors + /// + /// Returns [`RequestStateError::Serialization`] if `value` cannot be encoded + /// as JSON. + pub fn seal_json(&self, value: &T) -> Result { + self.seal_json_with(value, &SealOptions::default()) + } + + /// Seals a serializable value with [`SealOptions`]. + /// + /// # Errors + /// + /// Returns [`RequestStateError::Serialization`] if `value` cannot be encoded + /// as JSON. + pub fn seal_json_with( + &self, + value: &T, + options: &SealOptions<'_>, + ) -> Result { + let payload = serde_json::to_vec(value).map_err(RequestStateError::Serialization)?; + Ok(self.seal_with(&payload, options)) + } + + /// Opens a sealed value that was sealed without associated data, verifying + /// its integrity and expiry and returning the original bytes. + /// + /// # Errors + /// + /// See [`open_with`](Self::open_with). + pub fn open(&self, sealed: &str) -> Result, RequestStateError> { + self.open_with(sealed, &[]) + } + + /// Opens a sealed value, verifying its integrity against `associated_data` + /// and checking its expiry. + /// + /// `associated_data` must match the bytes passed to + /// [`SealOptions::associated_data`] when the value was sealed (use `&[]` for + /// values sealed without it). + /// + /// # Errors + /// + /// - [`RequestStateError::IntegrityCheckFailed`] if the value was not + /// produced by this key or the associated data differs. + /// - [`RequestStateError::Expired`] if the value's TTL has elapsed. + /// - [`RequestStateError::MalformedFormat`] or + /// [`RequestStateError::InvalidEncoding`] if it is not a well-formed sealed + /// value. + pub fn open_with( + &self, + sealed: &str, + associated_data: &[u8], + ) -> Result, RequestStateError> { + self.open_at(sealed, associated_data, Self::now_ms()) + } + + /// Opens a sealed value (no associated data) and deserializes its JSON + /// payload. + /// + /// # Errors + /// + /// See [`open_json_with`](Self::open_json_with). + pub fn open_json(&self, sealed: &str) -> Result { + self.open_json_with(sealed, &[]) + } + + /// Opens a sealed value against `associated_data` and deserializes its JSON + /// payload. + /// + /// # Errors + /// + /// Returns the same integrity, expiry, and format errors as + /// [`open_with`](Self::open_with), plus [`RequestStateError::Deserialization`] + /// if the payload is not valid JSON for `T`. + pub fn open_json_with( + &self, + sealed: &str, + associated_data: &[u8], + ) -> Result { + let payload = self.open_with(sealed, associated_data)?; + serde_json::from_slice(&payload).map_err(RequestStateError::Deserialization) + } + + fn seal_at(&self, payload: &[u8], options: &SealOptions<'_>, now_ms: i64) -> String { + let expiry = match options.ttl { + Some(ttl) => now_ms.saturating_add(ttl.as_millis().min(i64::MAX as u128) as i64), + None => 0, + }; + + // body = big-endian expiry (0 = none) followed by the caller payload. + let mut body = Vec::with_capacity(EXPIRY_LEN + payload.len()); + body.extend_from_slice(&expiry.to_be_bytes()); + body.extend_from_slice(payload); + + let tag = self + .mac_for(options.associated_data, &body) + .finalize() + .into_bytes(); + + // base64url without padding encodes 3 bytes as 4 chars, rounding up. + let b64_len = |n: usize| n.div_ceil(3) * 4; + let mut out = + String::with_capacity(VERSION.len() + 2 + b64_len(body.len()) + b64_len(tag.len())); + out.push_str(VERSION); + out.push('.'); + URL_SAFE_NO_PAD.encode_string(&body, &mut out); + out.push('.'); + URL_SAFE_NO_PAD.encode_string(tag.as_slice(), &mut out); + out + } + + fn open_at( + &self, + sealed: &str, + associated_data: &[u8], + now_ms: i64, + ) -> Result, RequestStateError> { + let mut parts = sealed.split('.'); + let version = parts.next().ok_or(RequestStateError::MalformedFormat)?; + let body_b64 = parts.next().ok_or(RequestStateError::MalformedFormat)?; + let tag_b64 = parts.next().ok_or(RequestStateError::MalformedFormat)?; + if parts.next().is_some() || version != VERSION { + return Err(RequestStateError::MalformedFormat); + } + + let body = URL_SAFE_NO_PAD + .decode(body_b64) + .map_err(|_| RequestStateError::InvalidEncoding)?; + let tag = URL_SAFE_NO_PAD + .decode(tag_b64) + .map_err(|_| RequestStateError::InvalidEncoding)?; + + // `verify_slice` compares in constant time and rejects wrong-length tags. + self.mac_for(associated_data, &body) + .verify_slice(&tag) + .map_err(|_| RequestStateError::IntegrityCheckFailed)?; + + // The body is now authenticated, so its framing can be trusted. + if body.len() < EXPIRY_LEN { + return Err(RequestStateError::MalformedFormat); + } + let expiry = i64::from_be_bytes(body[..EXPIRY_LEN].try_into().expect("checked length")); + if expiry != 0 && now_ms > expiry { + return Err(RequestStateError::Expired); + } + + Ok(body[EXPIRY_LEN..].to_vec()) + } + + /// Builds an HMAC keyed for request-state tags, pre-fed with the + /// domain-separation label, a length-prefixed `associated_data`, and the + /// body. The length prefix keeps the `associated_data`/`body` boundary + /// unambiguous so distinct inputs cannot collide. + fn mac_for(&self, associated_data: &[u8], body: &[u8]) -> HmacSha256 { + let mut mac = + HmacSha256::new_from_slice(&self.key).expect("HMAC accepts keys of any length"); + mac.update(DOMAIN); + mac.update(&(associated_data.len() as u64).to_be_bytes()); + mac.update(associated_data); + mac.update(body); + mac + } + + fn now_ms() -> i64 { + chrono::Utc::now().timestamp_millis() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn seal_open_roundtrips_bytes() { + let codec = RequestStateCodec::new(b"test-key-test-key-test-key-32byte".to_vec()); + let sealed = codec.seal(b"hello world"); + assert!(sealed.starts_with("rs1.")); + assert_eq!(codec.open(&sealed).unwrap(), b"hello world"); + } + + #[test] + fn seal_open_roundtrips_json() { + #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)] + struct State { + tool: String, + round: u32, + } + let codec = RequestStateCodec::new(b"another-strong-signing-key-here!!".to_vec()); + let state = State { + tool: "weather".into(), + round: 3, + }; + let sealed = codec.seal_json(&state).unwrap(); + let opened: State = codec.open_json(&sealed).unwrap(); + assert_eq!(opened, state); + } + + #[test] + fn empty_payload_roundtrips() { + let codec = RequestStateCodec::new(b"k".to_vec()); + let sealed = codec.seal(b""); + assert_eq!(codec.open(&sealed).unwrap(), b""); + } + + #[test] + fn tampered_payload_is_rejected() { + let codec = RequestStateCodec::new(b"signing-key-signing-key-signing!!".to_vec()); + let sealed = codec.seal(b"amount=100"); + + // Replace the body section but keep the original tag. + let mut parts: Vec<&str> = sealed.split('.').collect(); + let forged_body = URL_SAFE_NO_PAD.encode(b"amount=999"); + parts[1] = &forged_body; + let forged = parts.join("."); + + assert!(matches!( + codec.open(&forged), + Err(RequestStateError::IntegrityCheckFailed) + )); + } + + #[test] + fn different_key_is_rejected() { + let signer = RequestStateCodec::new(b"the-real-signing-key-value-here!!".to_vec()); + let attacker = RequestStateCodec::new(b"a-totally-different-forged-key!!!".to_vec()); + let sealed = signer.seal(b"trusted"); + assert!(matches!( + attacker.open(&sealed), + Err(RequestStateError::IntegrityCheckFailed) + )); + } + + #[test] + fn appended_bytes_are_rejected() { + let codec = RequestStateCodec::new(b"key-key-key-key-key-key-key-key!!".to_vec()); + let mut sealed = codec.seal(b"state"); + sealed.push('x'); + assert!(codec.open(&sealed).is_err()); + } + + #[test] + fn wrong_version_prefix_is_malformed() { + let codec = RequestStateCodec::new(b"key".to_vec()); + let sealed = codec.seal(b"state"); + let bumped = sealed.replacen("rs1.", "rs2.", 1); + assert!(matches!( + codec.open(&bumped), + Err(RequestStateError::MalformedFormat) + )); + } + + #[test] + fn missing_sections_are_malformed() { + let codec = RequestStateCodec::new(b"key".to_vec()); + assert!(matches!( + codec.open("rs1"), + Err(RequestStateError::MalformedFormat) + )); + assert!(matches!( + codec.open("rs1.onlybody"), + Err(RequestStateError::MalformedFormat) + )); + assert!(matches!( + codec.open("rs1.a.b.c"), + Err(RequestStateError::MalformedFormat) + )); + } + + #[test] + fn non_base64_sections_are_invalid_encoding() { + let codec = RequestStateCodec::new(b"key".to_vec()); + assert!(matches!( + codec.open("rs1.!!!!.!!!!"), + Err(RequestStateError::InvalidEncoding) + )); + } + + #[test] + fn debug_does_not_leak_key() { + let codec = RequestStateCodec::new(b"super-secret-key".to_vec()); + let rendered = format!("{codec:?}"); + assert!(!rendered.contains("super-secret-key")); + assert!(rendered.contains("redacted")); + } + + mod associated_data { + use super::*; + + #[test] + fn matching_context_opens() { + let codec = RequestStateCodec::new(b"key-key-key-key-key-key-key-key!!".to_vec()); + let ctx = b"user:alice|tools/call:weather"; + let sealed = codec.seal_with(b"state", &SealOptions::new().associated_data(ctx)); + assert_eq!(codec.open_with(&sealed, ctx).unwrap(), b"state"); + } + + #[test] + fn different_context_is_rejected() { + let codec = RequestStateCodec::new(b"key-key-key-key-key-key-key-key!!".to_vec()); + let sealed = + codec.seal_with(b"state", &SealOptions::new().associated_data(b"user:alice")); + assert!(matches!( + codec.open_with(&sealed, b"user:bob"), + Err(RequestStateError::IntegrityCheckFailed) + )); + } + + #[test] + fn missing_context_is_rejected() { + let codec = RequestStateCodec::new(b"key-key-key-key-key-key-key-key!!".to_vec()); + let sealed = + codec.seal_with(b"state", &SealOptions::new().associated_data(b"user:alice")); + // Opening without the associated data must fail closed. + assert!(matches!( + codec.open(&sealed), + Err(RequestStateError::IntegrityCheckFailed) + )); + } + } + + mod ttl { + use super::*; + + const KEY: &[u8] = b"ttl-signing-key-ttl-signing-key!!"; + + #[test] + fn within_ttl_opens() { + let codec = RequestStateCodec::new(KEY.to_vec()); + let sealed = codec.seal_at( + b"state", + &SealOptions::new().ttl(Duration::from_secs(60)), + 1_000, + ); + // 30s later, still valid. + assert_eq!(codec.open_at(&sealed, &[], 31_000).unwrap(), b"state"); + } + + #[test] + fn past_ttl_is_expired() { + let codec = RequestStateCodec::new(KEY.to_vec()); + let sealed = codec.seal_at( + b"state", + &SealOptions::new().ttl(Duration::from_secs(60)), + 1_000, + ); + // 61s later, expired. + assert!(matches!( + codec.open_at(&sealed, &[], 62_000), + Err(RequestStateError::Expired) + )); + } + + #[test] + fn no_ttl_never_expires() { + let codec = RequestStateCodec::new(KEY.to_vec()); + let sealed = codec.seal_at(b"state", &SealOptions::new(), 1_000); + assert_eq!(codec.open_at(&sealed, &[], i64::MAX).unwrap(), b"state"); + } + + #[test] + fn ttl_and_associated_data_combine() { + let codec = RequestStateCodec::new(KEY.to_vec()); + let ctx = b"user:alice"; + let sealed = codec.seal_at( + b"state", + &SealOptions::new() + .associated_data(ctx) + .ttl(Duration::from_secs(60)), + 1_000, + ); + assert_eq!(codec.open_at(&sealed, ctx, 10_000).unwrap(), b"state"); + assert!(matches!( + codec.open_at(&sealed, b"user:bob", 10_000), + Err(RequestStateError::IntegrityCheckFailed) + )); + assert!(matches!( + codec.open_at(&sealed, ctx, 99_000), + Err(RequestStateError::Expired) + )); + } + } +} diff --git a/vendor/rmcp/src/model/resource.rs b/vendor/rmcp/src/model/resource.rs new file mode 100644 index 000000000..9c27dce2f --- /dev/null +++ b/vendor/rmcp/src/model/resource.rs @@ -0,0 +1,310 @@ +use serde::{Deserialize, Serialize}; + +use super::{Annotations, Icon, MetaObject}; + +/// A known resource that the server is capable of reading (spec `Resource`). +/// +/// Also used as the inner type of `ContentBlock::ResourceLink` (spec `ResourceLink extends Resource`). +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct Resource { + /// The URI of this resource (e.g. `file:///path/to/file`). + pub uri: String, + /// The programmatic name of the resource. + pub name: String, + /// Optional human-readable display title. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Optional description of what this resource represents. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// The MIME type of this resource, if known. + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + /// The size of the raw resource content in bytes (before base64/tokenization), if known. + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + /// Optional set of icons the client may display for this resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub icons: Option>, + /// Optional protocol-level metadata for this resource. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Optional annotations describing how the client should use this resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, +} + +impl Resource { + pub fn new(uri: impl Into, name: impl Into) -> Self { + Self { + uri: uri.into(), + name: name.into(), + title: None, + description: None, + mime_type: None, + size: None, + icons: None, + meta: None, + annotations: None, + } + } + + pub fn with_title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + pub fn with_mime_type(mut self, mime_type: impl Into) -> Self { + self.mime_type = Some(mime_type.into()); + self + } + + pub fn with_size(mut self, size: u64) -> Self { + self.size = Some(size); + self + } + + pub fn with_icons(mut self, icons: Vec) -> Self { + self.icons = Some(icons); + self + } + + pub fn with_meta(mut self, meta: MetaObject) -> Self { + self.meta = Some(meta); + self + } + + pub fn with_annotations(mut self, annotations: Annotations) -> Self { + self.annotations = Some(annotations); + self + } +} + +/// A template description for resources available on the server (spec `ResourceTemplate`). +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct ResourceTemplate { + /// An RFC 6570 URI template for constructing resource URIs. + pub uri_template: String, + /// The programmatic name of the resource template. + pub name: String, + /// Optional human-readable display title. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Optional description of what this template is for. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// The MIME type for resources matching this template, if uniform. + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + /// Optional set of icons the client may display for this template. + #[serde(skip_serializing_if = "Option::is_none")] + pub icons: Option>, + /// Optional protocol-level metadata for this resource template. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Optional annotations describing how the client should use this template. + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, +} + +impl ResourceTemplate { + pub fn new(uri_template: impl Into, name: impl Into) -> Self { + Self { + uri_template: uri_template.into(), + name: name.into(), + title: None, + description: None, + mime_type: None, + icons: None, + meta: None, + annotations: None, + } + } + + pub fn with_title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + pub fn with_mime_type(mut self, mime_type: impl Into) -> Self { + self.mime_type = Some(mime_type.into()); + self + } + + pub fn with_icons(mut self, icons: Vec) -> Self { + self.icons = Some(icons); + self + } + + pub fn with_meta(mut self, meta: MetaObject) -> Self { + self.meta = Some(meta); + self + } + + pub fn with_annotations(mut self, annotations: Annotations) -> Self { + self.annotations = Some(annotations); + self + } +} + +/// The contents of a specific resource or sub-resource. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(untagged)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub enum ResourceContents { + #[serde(rename_all = "camelCase")] + TextResourceContents { + uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + mime_type: Option, + text: String, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + meta: Option, + }, + #[serde(rename_all = "camelCase")] + BlobResourceContents { + uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + mime_type: Option, + blob: String, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + meta: Option, + }, +} + +impl ResourceContents { + pub fn text(text: impl Into, uri: impl Into) -> Self { + Self::TextResourceContents { + uri: uri.into(), + mime_type: Some("text/plain".into()), + text: text.into(), + meta: None, + } + } + + pub fn blob(blob: impl Into, uri: impl Into) -> Self { + Self::BlobResourceContents { + uri: uri.into(), + mime_type: None, + blob: blob.into(), + meta: None, + } + } + + pub fn with_mime_type(mut self, mime_type: impl Into) -> Self { + match &mut self { + Self::TextResourceContents { mime_type: mt, .. } => *mt = Some(mime_type.into()), + Self::BlobResourceContents { mime_type: mt, .. } => *mt = Some(mime_type.into()), + } + self + } + + pub fn with_meta(mut self, meta: MetaObject) -> Self { + match &mut self { + Self::TextResourceContents { meta: m, .. } => *m = Some(meta), + Self::BlobResourceContents { meta: m, .. } => *m = Some(meta), + } + self + } +} + +#[cfg(test)] +mod tests { + use serde_json; + + use super::*; + use crate::model::IconTheme; + + #[test] + fn test_resource_serialization() { + let resource = Resource::new("file:///test.txt", "test") + .with_description("Test resource") + .with_mime_type("text/plain") + .with_size(100); + + let json = serde_json::to_string(&resource).unwrap(); + assert!(json.contains("mimeType")); + assert!(!json.contains("mime_type")); + } + + #[test] + fn test_resource_contents_serialization() { + let text_contents = ResourceContents::TextResourceContents { + uri: "file:///test.txt".to_string(), + mime_type: Some("text/plain".to_string()), + text: "Hello world".to_string(), + meta: None, + }; + + let json = serde_json::to_string(&text_contents).unwrap(); + assert!(json.contains("mimeType")); + assert!(!json.contains("mime_type")); + } + + #[test] + fn test_resource_template_with_icons() { + let resource_template = ResourceTemplate::new("file:///{path}", "template") + .with_title("Test Template") + .with_description("A test resource template") + .with_mime_type("text/plain") + .with_icons(vec![Icon { + src: "https://example.com/icon.png".to_string(), + mime_type: Some("image/png".to_string()), + sizes: Some(vec!["48x48".to_string()]), + theme: Some(IconTheme::Light), + }]); + + let json = serde_json::to_value(&resource_template).unwrap(); + assert!(json["icons"].is_array()); + assert_eq!(json["icons"][0]["src"], "https://example.com/icon.png"); + assert_eq!(json["icons"][0]["sizes"][0], "48x48"); + assert_eq!(json["icons"][0]["theme"], "light"); + } + + #[test] + fn test_resource_template_without_icons() { + let resource_template = ResourceTemplate::new("file:///{path}", "template"); + let json = serde_json::to_value(&resource_template).unwrap(); + assert!(json.get("icons").is_none()); + } + + #[test] + fn test_resource_size_u64() { + let resource = Resource::new("file:///big", "big").with_size(5_000_000_000); + let json = serde_json::to_value(&resource).unwrap(); + assert_eq!(json["size"], 5_000_000_000_u64); + } + + #[test] + fn test_resource_with_annotations() { + let resource = Resource::new("file:///test.txt", "test") + .with_annotations(Annotations::default().with_priority(0.9)); + let json = serde_json::to_value(&resource).unwrap(); + assert_eq!(json["annotations"]["priority"], 0.9_f32); + } + + #[test] + fn test_resource_template_with_meta() { + let resource_template = + ResourceTemplate::new("file:///{path}", "template").with_meta(MetaObject::default()); + let json = serde_json::to_value(&resource_template).unwrap(); + assert!(json.get("_meta").is_some()); + } +} diff --git a/vendor/rmcp/src/model/serde_impl.rs b/vendor/rmcp/src/model/serde_impl.rs new file mode 100644 index 000000000..bfd3b5783 --- /dev/null +++ b/vendor/rmcp/src/model/serde_impl.rs @@ -0,0 +1,851 @@ +use std::borrow::Cow; + +use serde::{Deserialize, Serialize}; + +use super::{ + CustomNotification, CustomRequest, Extensions, JsonObject, MetaObject, Notification, + NotificationMetaObject, NotificationNoParam, Request, RequestMetaObject, RequestNoParam, + RequestOptionalParam, +}; + +/// Wire-side view of `params`: the `_meta` map plus the remaining fields. +/// +/// All metadata types are transparent wrappers over [`JsonObject`], so the +/// serde plumbing works on the raw map; call sites wrap/unwrap the typed +/// metadata ([`RequestMetaObject`] / [`NotificationMetaObject`]). +#[derive(Deserialize)] +struct WithMeta<'a, P> { + _meta: Option>, + #[serde(flatten)] + _rest: P, +} + +impl Serialize for WithMeta<'_, P> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeMap; + + // Serialize _rest to a Value so we can inspect and strip any duplicate _meta + let mut rest_value = + serde_json::to_value(&self._rest).map_err(serde::ser::Error::custom)?; + + // Extract _meta from the serialized params (if it's an object containing one) + let params_meta: Option = rest_value + .as_object_mut() + .and_then(|obj| obj.remove("_meta")) + .and_then(|v| serde_json::from_value(v).ok()); + + // Merge: params-level _meta as base, extensions-level _meta overwrites on conflict + let merged_meta = match (self._meta.as_deref(), params_meta) { + (Some(ext_meta), Some(mut params_meta)) => { + params_meta.extend(ext_meta.clone()); + Some(params_meta) + } + (Some(ext_meta), None) => Some(ext_meta.clone()), + (None, Some(params_meta)) => Some(params_meta), + (None, None) => None, + }; + + // Serialize as a flat map: single _meta + remaining params fields + let rest_obj = match rest_value { + serde_json::Value::Object(map) => map, + _ => serde_json::Map::new(), + }; + let meta_count = usize::from(merged_meta.is_some()); + let mut map = serializer.serialize_map(Some(rest_obj.len() + meta_count))?; + + if let Some(meta) = &merged_meta { + map.serialize_entry("_meta", meta)?; + } + + for (k, v) in &rest_obj { + map.serialize_entry(k, v)?; + } + + map.end() + } +} + +#[derive(Serialize, Deserialize)] +struct Proxy<'a, M, P> { + method: M, + params: WithMeta<'a, P>, +} + +#[derive(Serialize, Deserialize)] +struct ProxyOptionalParam<'a, M, P> { + method: M, + params: Option>, +} + +#[derive(Serialize, Deserialize)] +struct ProxyNoParam { + method: M, +} + +/// Combine the message-specific `_meta` map with a legacy [`MetaObject`] +/// extension so metadata stored in [`Extensions`] is not lost on the wire. +/// On key conflicts the message-specific map wins. +fn merge_legacy_meta<'a>( + typed: Option<&'a JsonObject>, + extensions: &'a Extensions, +) -> Option> { + let legacy = extensions.get::().map(|meta| &meta.0); + match (typed, legacy) { + (Some(typed), None) => Some(Cow::Borrowed(typed)), + (None, Some(legacy)) => Some(Cow::Borrowed(legacy)), + (Some(typed), Some(legacy)) => { + let mut merged = legacy.clone(); + merged.extend( + typed + .iter() + .map(|(key, value)| (key.clone(), value.clone())), + ); + Some(Cow::Owned(merged)) + } + (None, None) => None, + } +} + +/// Borrow the request `_meta` map from extensions, if any. +fn request_meta(extensions: &Extensions) -> Option> { + let typed = extensions.get::().map(|meta| &meta.0.0); + merge_legacy_meta(typed, extensions) +} + +/// Borrow the notification `_meta` map from extensions, if any. +fn notification_meta(extensions: &Extensions) -> Option> { + let typed = extensions + .get::() + .map(|meta| &meta.0.0); + merge_legacy_meta(typed, extensions) +} + +/// Build extensions holding a typed metadata map deserialized from `params._meta`. +fn extensions_with_meta(meta: Option>) -> Extensions +where + T: From + Clone + Send + Sync + 'static, +{ + let mut extensions = Extensions::new(); + if let Some(meta) = meta { + extensions.insert(T::from(meta.into_owned())); + } + extensions +} + +impl Serialize for Request +where + M: Serialize, + R: Serialize, +{ + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + Proxy::serialize( + &Proxy { + method: &self.method, + params: WithMeta { + _rest: &self.params, + _meta: request_meta(&self.extensions), + }, + }, + serializer, + ) + } +} + +impl<'de, M, R> Deserialize<'de> for Request +where + M: Deserialize<'de>, + R: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let body = Proxy::deserialize(deserializer)?; + Ok(Request { + extensions: extensions_with_meta::(body.params._meta), + method: body.method, + params: body.params._rest, + }) + } +} + +impl Serialize for RequestOptionalParam +where + M: Serialize, + R: Serialize, +{ + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + Proxy::serialize( + &Proxy { + method: &self.method, + params: WithMeta { + _rest: &self.params, + _meta: request_meta(&self.extensions), + }, + }, + serializer, + ) + } +} + +impl<'de, M, R> Deserialize<'de> for RequestOptionalParam +where + M: Deserialize<'de>, + R: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let body = ProxyOptionalParam::<'_, _, Option>::deserialize(deserializer)?; + let mut params = None; + let mut _meta = None; + if let Some(body_params) = body.params { + params = body_params._rest; + _meta = body_params._meta; + } + Ok(RequestOptionalParam { + extensions: extensions_with_meta::(_meta), + method: body.method, + params, + }) + } +} + +impl Serialize for RequestNoParam +where + M: Serialize, +{ + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + // Emit `params` only when metadata is present, so the wire shape of + // meta-less requests stays `{"method": ...}`. + match request_meta(&self.extensions) { + Some(_meta) => Proxy::serialize( + &Proxy { + method: &self.method, + params: WithMeta { + _meta: Some(_meta), + _rest: (), + }, + }, + serializer, + ), + None => ProxyNoParam::serialize( + &ProxyNoParam { + method: &self.method, + }, + serializer, + ), + } + } +} + +impl<'de, M> Deserialize<'de> for RequestNoParam +where + M: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let body = ProxyOptionalParam::<'_, _, Option>::deserialize(deserializer)?; + let _meta = body.params.and_then(|params| params._meta); + Ok(RequestNoParam { + extensions: extensions_with_meta::(_meta), + method: body.method, + }) + } +} + +impl Serialize for Notification +where + M: Serialize, + R: Serialize, +{ + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + Proxy::serialize( + &Proxy { + method: &self.method, + params: WithMeta { + _rest: &self.params, + _meta: notification_meta(&self.extensions), + }, + }, + serializer, + ) + } +} + +impl<'de, M, R> Deserialize<'de> for Notification +where + M: Deserialize<'de>, + R: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let body = ProxyOptionalParam::<'_, _, R>::deserialize(deserializer)?; + let (_meta, params) = match body.params { + Some(with_meta) => (with_meta._meta, with_meta._rest), + None => { + // JSON-RPC 2.0: params is optional. Treat absent params as {}. + let empty = serde_json::Value::Object(serde_json::Map::new()); + let r = R::deserialize(empty).map_err(serde::de::Error::custom)?; + (None, r) + } + }; + Ok(Notification { + extensions: extensions_with_meta::(_meta), + method: body.method, + params, + }) + } +} + +impl Serialize for NotificationNoParam +where + M: Serialize, +{ + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + // Emit `params` only when metadata is present, so the wire shape of + // meta-less notifications stays `{"method": ...}`. + match notification_meta(&self.extensions) { + Some(_meta) => Proxy::serialize( + &Proxy { + method: &self.method, + params: WithMeta { + _meta: Some(_meta), + _rest: (), + }, + }, + serializer, + ), + None => ProxyNoParam::serialize( + &ProxyNoParam { + method: &self.method, + }, + serializer, + ), + } + } +} + +impl<'de, M> Deserialize<'de> for NotificationNoParam +where + M: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let body = ProxyOptionalParam::<'_, _, Option>::deserialize(deserializer)?; + let _meta = body.params.and_then(|params| params._meta); + Ok(NotificationNoParam { + extensions: extensions_with_meta::(_meta), + method: body.method, + }) + } +} + +impl Serialize for CustomRequest { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let _meta = request_meta(&self.extensions); + let params = self.params.as_ref(); + + let params = if _meta.is_some() || params.is_some() { + Some(WithMeta { + _meta, + _rest: &self.params, + }) + } else { + None + }; + + ProxyOptionalParam::serialize( + &ProxyOptionalParam { + method: &self.method, + params, + }, + serializer, + ) + } +} + +impl<'de> Deserialize<'de> for CustomRequest { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let body = + ProxyOptionalParam::<'_, _, Option>::deserialize(deserializer)?; + let mut params = None; + let mut _meta = None; + if let Some(body_params) = body.params { + params = body_params._rest; + _meta = body_params._meta; + } + Ok(CustomRequest { + extensions: extensions_with_meta::(_meta), + method: body.method, + params, + }) + } +} + +impl Serialize for CustomNotification { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let _meta = notification_meta(&self.extensions); + let params = self.params.as_ref(); + + let params = if _meta.is_some() || params.is_some() { + Some(WithMeta { + _meta, + _rest: &self.params, + }) + } else { + None + }; + + ProxyOptionalParam::serialize( + &ProxyOptionalParam { + method: &self.method, + params, + }, + serializer, + ) + } +} + +impl<'de> Deserialize<'de> for CustomNotification { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let body = + ProxyOptionalParam::<'_, _, Option>::deserialize(deserializer)?; + let mut params = None; + let mut _meta = None; + if let Some(body_params) = body.params { + params = body_params._rest; + _meta = body_params._meta; + } + Ok(CustomNotification { + extensions: extensions_with_meta::(_meta), + method: body.method, + params, + }) + } +} + +#[cfg(test)] +mod test { + use serde_json::json; + + use crate::model::{ + CallToolRequest, CallToolRequestParams, CustomRequest, Extensions, InitializedNotification, + ListToolsRequest, NotificationMetaObject, PingRequest, RequestMetaObject, + }; + + #[test] + fn test_deserialize_lost_tools_request() { + let _req: ListToolsRequest = serde_json::from_value(json!( + { + "method": "tools/list", + } + )) + .unwrap(); + } + + #[test] + fn test_no_duplicate_meta_both_sources() { + // When both extensions and params contain _meta, the output should have + // a single merged _meta key (not two separate ones). + let mut extensions = Extensions::new(); + let mut ext_meta = RequestMetaObject::new(); + ext_meta.insert("traceId".to_string(), json!("abc")); + extensions.insert(ext_meta); + + let mut params_meta = RequestMetaObject::new(); + params_meta.insert("progressToken".to_string(), json!(1)); + + let req = CallToolRequest { + extensions, + method: Default::default(), + params: CallToolRequestParams { + meta: Some(params_meta), + name: "my_tool".into(), + arguments: None, + input_responses: None, + request_state: None, + }, + }; + + let value = serde_json::to_value(&req).unwrap(); + let params = value.get("params").unwrap(); + + // There should be exactly one _meta key (JSON objects naturally deduplicate) + let meta = params.get("_meta").unwrap(); + + // Both entries should be present in the merged _meta + assert_eq!(meta.get("traceId").unwrap(), "abc"); + assert_eq!(meta.get("progressToken").unwrap(), 1); + + // Verify the raw JSON string has exactly one occurrence of "_meta" + let raw = serde_json::to_string(&req).unwrap(); + assert_eq!( + raw.matches("\"_meta\"").count(), + 1, + "Expected exactly one _meta key in serialized output, got: {}", + raw + ); + } + + #[test] + fn test_meta_only_from_extensions() { + let mut extensions = Extensions::new(); + let mut ext_meta = RequestMetaObject::new(); + ext_meta.insert("traceId".to_string(), json!("ext-only")); + extensions.insert(ext_meta); + + let req = CallToolRequest { + extensions, + method: Default::default(), + params: CallToolRequestParams { + meta: None, + name: "my_tool".into(), + arguments: None, + input_responses: None, + request_state: None, + }, + }; + + let value = serde_json::to_value(&req).unwrap(); + let meta = value["params"]["_meta"].as_object().unwrap(); + assert_eq!(meta.get("traceId").unwrap(), "ext-only"); + } + + #[test] + fn test_meta_only_from_params() { + let mut params_meta = RequestMetaObject::new(); + params_meta.insert("progressToken".to_string(), json!(42)); + + let req = CallToolRequest { + extensions: Extensions::new(), + method: Default::default(), + params: CallToolRequestParams { + meta: Some(params_meta), + name: "my_tool".into(), + arguments: None, + input_responses: None, + request_state: None, + }, + }; + + let value = serde_json::to_value(&req).unwrap(); + let meta = value["params"]["_meta"].as_object().unwrap(); + assert_eq!(meta.get("progressToken").unwrap(), 42); + } + + #[test] + fn test_no_meta_emitted_when_neither_source() { + let req = CallToolRequest { + extensions: Extensions::new(), + method: Default::default(), + params: CallToolRequestParams { + meta: None, + name: "my_tool".into(), + arguments: None, + input_responses: None, + request_state: None, + }, + }; + + let value = serde_json::to_value(&req).unwrap(); + assert!( + value["params"].get("_meta").is_none(), + "Expected no _meta when neither source is populated" + ); + } + + #[test] + fn test_extensions_meta_takes_priority_on_conflict() { + // When both sources have the same key, extensions should win. + let mut extensions = Extensions::new(); + let mut ext_meta = RequestMetaObject::new(); + ext_meta.insert("shared_key".to_string(), json!("from_extensions")); + extensions.insert(ext_meta); + + let mut params_meta = RequestMetaObject::new(); + params_meta.insert("shared_key".to_string(), json!("from_params")); + params_meta.insert("params_only".to_string(), json!("kept")); + + let req = CallToolRequest { + extensions, + method: Default::default(), + params: CallToolRequestParams { + meta: Some(params_meta), + name: "my_tool".into(), + arguments: None, + input_responses: None, + request_state: None, + }, + }; + + let value = serde_json::to_value(&req).unwrap(); + let meta = value["params"]["_meta"].as_object().unwrap(); + assert_eq!(meta.get("shared_key").unwrap(), "from_extensions"); + assert_eq!(meta.get("params_only").unwrap(), "kept"); + } + + #[test] + fn test_round_trip_preserves_meta() { + let mut extensions = Extensions::new(); + let mut ext_meta = RequestMetaObject::new(); + ext_meta.insert("traceId".to_string(), json!("round-trip")); + extensions.insert(ext_meta); + + let req = CallToolRequest { + extensions, + method: Default::default(), + params: CallToolRequestParams { + meta: None, + name: "my_tool".into(), + arguments: Some(serde_json::Map::from_iter([("x".to_string(), json!(1))])), + input_responses: None, + request_state: None, + }, + }; + + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: CallToolRequest = serde_json::from_str(&serialized).unwrap(); + + // Extensions should have the meta after round-trip + let meta = deserialized.extensions.get::().unwrap(); + assert_eq!(meta.get("traceId").unwrap(), "round-trip"); + + // Params should be preserved + assert_eq!(deserialized.params.name, "my_tool"); + assert_eq!( + deserialized + .params + .arguments + .as_ref() + .unwrap() + .get("x") + .unwrap(), + &json!(1) + ); + } + + #[test] + fn test_custom_request_no_duplicate_meta() { + // CustomRequest uses Option as params — verify no duplicate _meta. + let mut extensions = Extensions::new(); + let mut ext_meta = RequestMetaObject::new(); + ext_meta.insert("traceId".to_string(), json!("custom-ext")); + extensions.insert(ext_meta); + + let params = Some(json!({ + "_meta": { "progressToken": 99 }, + "foo": "bar" + })); + + let req = CustomRequest { + extensions, + method: "custom/method".into(), + params, + }; + + let raw = serde_json::to_string(&req).unwrap(); + assert_eq!( + raw.matches("\"_meta\"").count(), + 1, + "Expected exactly one _meta key in CustomRequest output, got: {}", + raw + ); + + let value: serde_json::Value = serde_json::from_str(&raw).unwrap(); + let meta = value["params"]["_meta"].as_object().unwrap(); + assert_eq!(meta.get("traceId").unwrap(), "custom-ext"); + assert_eq!(meta.get("progressToken").unwrap(), 99); + } + + #[test] + fn test_request_no_param_meta_round_trip() { + // Ping-shaped requests must carry `params._meta` on the wire. + let mut extensions = Extensions::new(); + let mut meta = RequestMetaObject::new(); + meta.insert("traceId".to_string(), json!("ping-trace")); + extensions.insert(meta); + + let req = PingRequest { + method: Default::default(), + extensions, + }; + + let value = serde_json::to_value(&req).unwrap(); + assert_eq!(value["params"]["_meta"]["traceId"], json!("ping-trace")); + + let deserialized: PingRequest = serde_json::from_value(value).unwrap(); + let meta = deserialized + .extensions + .get::() + .expect("meta should survive the round-trip"); + assert_eq!(meta.get("traceId").unwrap(), &json!("ping-trace")); + } + + #[test] + fn test_request_no_param_without_meta_has_no_params_key() { + let req = PingRequest { + method: Default::default(), + extensions: Extensions::new(), + }; + let value = serde_json::to_value(&req).unwrap(); + assert!( + value.get("params").is_none(), + "meta-less no-param requests must keep the historical wire shape: {value}" + ); + } + + #[test] + fn test_notification_no_param_meta_round_trip() { + // Initialized-shaped notifications must carry `params._meta` on the wire. + let mut extensions = Extensions::new(); + let mut meta = NotificationMetaObject::new(); + meta.insert("traceId".to_string(), json!("init-trace")); + extensions.insert(meta); + + let notification = InitializedNotification { + method: Default::default(), + extensions, + }; + + let value = serde_json::to_value(¬ification).unwrap(); + assert_eq!(value["params"]["_meta"]["traceId"], json!("init-trace")); + + let deserialized: InitializedNotification = serde_json::from_value(value).unwrap(); + let meta = deserialized + .extensions + .get::() + .expect("meta should survive the round-trip"); + assert_eq!(meta.get("traceId").unwrap(), &json!("init-trace")); + } + + #[test] + fn test_notification_no_param_without_meta_has_no_params_key() { + let notification = InitializedNotification { + method: Default::default(), + extensions: Extensions::new(), + }; + let value = serde_json::to_value(¬ification).unwrap(); + assert!( + value.get("params").is_none(), + "meta-less no-param notifications must keep the historical wire shape: {value}" + ); + } + + #[test] + fn test_no_param_ignores_unknown_params_fields() { + // Old/foreign peers may send params without _meta; both shapes must parse. + let _req: PingRequest = + serde_json::from_value(json!({"method": "ping", "params": {}})).unwrap(); + let _req: PingRequest = + serde_json::from_value(json!({"method": "ping", "params": {"unknown": 1}})).unwrap(); + let _req: PingRequest = serde_json::from_value(json!({"method": "ping"})).unwrap(); + } + + #[test] + fn test_legacy_meta_extension_still_serializes() { + let mut extensions = Extensions::new(); + let mut legacy = crate::model::MetaObject::new(); + legacy.insert("traceId".to_string(), json!("legacy")); + extensions.insert(legacy); + + let req = CallToolRequest { + extensions, + method: Default::default(), + params: CallToolRequestParams { + meta: None, + name: "my_tool".into(), + arguments: None, + input_responses: None, + request_state: None, + }, + }; + + let value = serde_json::to_value(&req).unwrap(); + assert_eq!(value["params"]["_meta"]["traceId"], json!("legacy")); + } + + #[test] + fn test_typed_meta_wins_over_legacy_extension_on_conflict() { + let mut extensions = Extensions::new(); + let mut legacy = crate::model::MetaObject::new(); + legacy.insert("shared".to_string(), json!("legacy")); + legacy.insert("legacy_only".to_string(), json!("kept")); + extensions.insert(legacy); + let mut typed = RequestMetaObject::new(); + typed.insert("shared".to_string(), json!("typed")); + extensions.insert(typed); + + let req = CallToolRequest { + extensions, + method: Default::default(), + params: CallToolRequestParams { + meta: None, + name: "my_tool".into(), + arguments: None, + input_responses: None, + request_state: None, + }, + }; + + let value = serde_json::to_value(&req).unwrap(); + let meta = value["params"]["_meta"].as_object().unwrap(); + assert_eq!(meta.get("shared").unwrap(), "typed"); + assert_eq!(meta.get("legacy_only").unwrap(), "kept"); + } + + #[test] + fn test_arbitrary_meta_keys_round_trip_unchanged() { + let input = json!({ + "method": "tools/call", + "params": { + "_meta": { + "progressToken": 5, + "vendor.example/custom": {"nested": ["a", 1, null]}, + "another-key": true + }, + "name": "my_tool" + } + }); + let req: CallToolRequest = serde_json::from_value(input.clone()).unwrap(); + let output = serde_json::to_value(&req).unwrap(); + assert_eq!(input, output); + } +} diff --git a/vendor/rmcp/src/model/task.rs b/vendor/rmcp/src/model/task.rs new file mode 100644 index 000000000..bbdd1c99d --- /dev/null +++ b/vendor/rmcp/src/model/task.rs @@ -0,0 +1,544 @@ +//! Task types for the MCP Tasks extension (SEP-2663). +//! +//! Tasks are defined by the official `io.modelcontextprotocol/tasks` extension. +//! A server may respond to a supported request (currently `tools/call`) with a +//! [`CreateTaskResult`] (`resultType: "task"`) instead of the standard result. +//! The client then polls `tasks/get`, answers in-task server-to-client requests +//! via `tasks/update`, and may signal cancellation via `tasks/cancel`. + +use serde::{Deserialize, Serialize}; + +use super::{InputRequests, JsonObject, MetaObject, ResultType}; + +/// Extension identifier for the MCP Tasks extension (SEP-2663). +pub const TASKS_EXTENSION_ID: &str = "io.modelcontextprotocol/tasks"; + +/// Canonical task lifecycle status (SEP-2663). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub enum TaskStatus { + /// The request is currently being processed. + #[default] + Working, + /// The server needs input from the client before the task can proceed. + InputRequired, + /// The request completed successfully and the result is available. + /// This includes tool calls that returned results with `isError: true`. + Completed, + /// The request failed due to a JSON-RPC error during execution. + Failed, + /// The request was cancelled before completion. + Cancelled, +} + +impl TaskStatus { + /// Returns `true` for terminal statuses (`completed`, `failed`, `cancelled`). + pub fn is_terminal(&self) -> bool { + matches!(self, Self::Completed | Self::Failed | Self::Cancelled) + } +} + +/// Operational metadata about ongoing work (spec `Task`, SEP-2663). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct Task { + /// Stable identifier for this task, generated by the server. + pub task_id: String, + /// Current task status. + pub status: TaskStatus, + /// Optional message describing the current task state. + /// This MAY be exposed to the end-user or model. + #[serde(skip_serializing_if = "Option::is_none")] + pub status_message: Option, + /// ISO 8601 timestamp when the task was created. + pub created_at: String, + /// ISO 8601 timestamp when the task was last updated. + pub last_updated_at: String, + /// Time-to-live duration from creation in integer milliseconds; `None` + /// (serialized as `null`) means unlimited. The server may discard the task + /// after the TTL elapses. This value MAY change over the lifetime of a task. + pub ttl_ms: Option, + /// Suggested polling interval in integer milliseconds. Clients SHOULD honor + /// this value to avoid overwhelming the server. This value MAY change over + /// the lifetime of a task. + #[serde(skip_serializing_if = "Option::is_none")] + pub poll_interval_ms: Option, +} + +impl Task { + /// Create a new task with required fields. + pub fn new( + task_id: impl Into, + status: TaskStatus, + created_at: impl Into, + last_updated_at: impl Into, + ) -> Self { + Self { + task_id: task_id.into(), + status, + status_message: None, + created_at: created_at.into(), + last_updated_at: last_updated_at.into(), + ttl_ms: None, + poll_interval_ms: None, + } + } + + /// Set the status message. + pub fn with_status_message(mut self, status_message: impl Into) -> Self { + self.status_message = Some(status_message.into()); + self + } + + /// Set the TTL in milliseconds. `None` means unlimited retention. + pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self { + self.ttl_ms = Some(ttl_ms); + self + } + + /// Set the suggested poll interval in milliseconds. + pub fn with_poll_interval_ms(mut self, poll_interval_ms: u64) -> Self { + self.poll_interval_ms = Some(poll_interval_ms); + self + } +} + +/// Status-specific payload carried alongside the base [`Task`] fields in a +/// [`DetailedTask`]. +/// +/// Mirrors the spec's `WorkingTask` / `InputRequiredTask` / `CompletedTask` / +/// `FailedTask` / `CancelledTask` union: the variant is discriminated by the +/// `status` field on the wire, with the payload fields inlined at the top level. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum TaskPayload { + /// `status: "working"` — no additional payload. + Working, + /// `status: "input_required"` — outstanding server-to-client requests. + InputRequired { + /// Server-to-client requests that need to be fulfilled during task + /// execution. Keys are arbitrary identifiers for matching requests + /// to responses, unique over the lifetime of the task. + input_requests: InputRequests, + }, + /// `status: "completed"` — the final result of the task. The structure + /// matches the result type of the original request (e.g. `CallToolResult`). + Completed { + /// The final result of the original request. + result: JsonObject, + }, + /// `status: "failed"` — the JSON-RPC error that caused the task to fail. + Failed { + /// The JSON-RPC error object. + error: JsonObject, + }, + /// `status: "cancelled"` — no additional payload. + Cancelled, +} + +impl TaskPayload { + /// The [`TaskStatus`] this payload corresponds to. + pub fn status(&self) -> TaskStatus { + match self { + Self::Working => TaskStatus::Working, + Self::InputRequired { .. } => TaskStatus::InputRequired, + Self::Completed { .. } => TaskStatus::Completed, + Self::Failed { .. } => TaskStatus::Failed, + Self::Cancelled => TaskStatus::Cancelled, + } + } +} + +/// A task with its status-specific payload inlined (spec `DetailedTask`). +/// +/// Used by `tasks/get` responses ([`GetTaskResult`]) and `notifications/tasks` +/// ([`TaskStatusNotificationParams`](crate::model::TaskStatusNotificationParams)). +/// On the wire, the payload fields (`inputRequests` / `result` / `error`) are +/// flattened at the top level next to the base [`Task`] fields, and `status` +/// discriminates the variant. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct DetailedTask { + /// Base task metadata. Its `status` always agrees with the payload. + pub task: Task, + /// Status-specific payload. + pub payload: TaskPayload, +} + +impl DetailedTask { + /// Build a `DetailedTask`, forcing `task.status` to match the payload. + pub fn new(mut task: Task, payload: TaskPayload) -> Self { + task.status = payload.status(); + Self { task, payload } + } + + /// The current status. + pub fn status(&self) -> TaskStatus { + self.task.status + } +} + +// Wire shape helper: base Task fields + optional payload fields, all flattened. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +struct DetailedTaskWire { + #[serde(flatten)] + task: Task, + #[serde(skip_serializing_if = "Option::is_none")] + input_requests: Option, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +impl From for DetailedTaskWire { + fn from(value: DetailedTask) -> Self { + let DetailedTask { task, payload } = value; + let (input_requests, result, error) = match payload { + TaskPayload::Working | TaskPayload::Cancelled => (None, None, None), + TaskPayload::InputRequired { input_requests } => (Some(input_requests), None, None), + TaskPayload::Completed { result } => (None, Some(result), None), + TaskPayload::Failed { error } => (None, None, Some(error)), + }; + Self { + task, + input_requests, + result, + error, + } + } +} + +impl TryFrom for DetailedTask { + type Error = String; + fn try_from(wire: DetailedTaskWire) -> Result { + let payload = match wire.task.status { + TaskStatus::Working => TaskPayload::Working, + TaskStatus::Cancelled => TaskPayload::Cancelled, + TaskStatus::InputRequired => TaskPayload::InputRequired { + input_requests: wire.input_requests.ok_or_else(|| { + "task with status \"input_required\" is missing `inputRequests`".to_owned() + })?, + }, + TaskStatus::Completed => TaskPayload::Completed { + result: wire.result.ok_or_else(|| { + "task with status \"completed\" is missing `result`".to_owned() + })?, + }, + TaskStatus::Failed => TaskPayload::Failed { + error: wire + .error + .ok_or_else(|| "task with status \"failed\" is missing `error`".to_owned())?, + }, + }; + Ok(DetailedTask { + task: wire.task, + payload, + }) + } +} + +impl Serialize for DetailedTask { + fn serialize(&self, serializer: S) -> Result { + DetailedTaskWire::from(self.clone()).serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for DetailedTask { + fn deserialize>(deserializer: D) -> Result { + let wire = DetailedTaskWire::deserialize(deserializer)?; + Self::try_from(wire).map_err(serde::de::Error::custom) + } +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for DetailedTask { + fn schema_name() -> std::borrow::Cow<'static, str> { + "DetailedTask".into() + } + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + // The actual wire shape: base Task fields plus the optional + // status-specific payload fields (inputRequests / result / error). + ::json_schema(generator) + } +} + +/// Result returned in lieu of a standard result to indicate the request will +/// be processed asynchronously (spec `CreateTaskResult`, `resultType: "task"`). +/// +/// The embedded task is the seed state for the task; the client uses +/// `task.task_id` for all subsequent `tasks/get`, `tasks/update`, and +/// `tasks/cancel` calls. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct CreateTaskResult { + /// Always `"task"`. + pub result_type: ResultType, + /// Seed state of the newly created task, flattened at the top level. + #[serde(flatten)] + pub task: Task, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +// Custom deserializer that requires `resultType: "task"`. Without this, +// `CreateTaskResult` would greedily match other task-shaped results (e.g. +// `tasks/get` responses, which also carry `taskId`/`status` at the top level +// but use `resultType: "complete"`) inside `#[serde(untagged)]` unions such +// as `ServerResult`. +impl<'de> Deserialize<'de> for CreateTaskResult { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Helper { + result_type: ResultType, + #[serde(flatten)] + task: Task, + #[serde(rename = "_meta", default)] + meta: Option, + } + let helper = Helper::deserialize(deserializer)?; + if !helper.result_type.is_task() { + return Err(serde::de::Error::custom( + "CreateTaskResult requires resultType to be \"task\"", + )); + } + Ok(CreateTaskResult { + result_type: helper.result_type, + task: helper.task, + meta: helper.meta, + }) + } +} + +impl CreateTaskResult { + /// Create a new `CreateTaskResult` from the seed task state. + pub fn new(task: Task) -> Self { + Self { + result_type: ResultType::TASK, + task, + meta: None, + } + } + + /// Sets the protocol-level metadata for this result. + pub fn with_meta(mut self, meta: MetaObject) -> Self { + self.meta = Some(meta); + self + } +} + +/// Response to a `tasks/get` request (spec `GetTaskResult = Result & DetailedTask`). +/// +/// `resultType` is `"complete"` — this is the standard result shape for +/// `tasks/get`, not a task handle. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct GetTaskResult { + /// Result type discriminator. `tasks/get` responses are standard results: + /// `"complete"` (SEP-2322). Absent values deserialize as `"complete"`. + #[serde(default)] + pub result_type: ResultType, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// The task with status-specific payload inlined. + #[serde(flatten)] + pub task: DetailedTask, +} + +impl GetTaskResult { + pub fn new(task: DetailedTask) -> Self { + Self { + result_type: ResultType::COMPLETE, + meta: None, + task, + } + } +} + +/// Empty acknowledgement for `tasks/update` and `tasks/cancel` (SEP-2663). +/// +/// The spec requires these acks to be empty results carrying the SEP-2322 +/// `resultType: "complete"` discriminator; task state changes are observed +/// via the next `tasks/get`. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct TaskAckResult { + /// Always `"complete"`. + pub result_type: ResultType, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +// Custom deserializer that requires `resultType: "complete"` and rejects any +// other fields. Without this, `TaskAckResult` would greedily match arbitrary +// result objects carrying a `resultType` key inside `#[serde(untagged)]` +// unions such as `ServerResult`, shadowing `CustomResult` and losing data. +impl<'de> Deserialize<'de> for TaskAckResult { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct Helper { + result_type: ResultType, + #[serde(rename = "_meta", default)] + meta: Option, + } + let helper = Helper::deserialize(deserializer)?; + if !helper.result_type.is_complete() { + return Err(serde::de::Error::custom( + "TaskAckResult requires resultType to be \"complete\"", + )); + } + Ok(TaskAckResult { + result_type: helper.result_type, + meta: helper.meta, + }) + } +} + +impl Default for TaskAckResult { + fn default() -> Self { + Self { + result_type: ResultType::COMPLETE, + meta: None, + } + } +} + +impl TaskAckResult { + pub fn new() -> Self { + Self::default() + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn base_task(status: TaskStatus) -> Task { + Task::new( + "task-1", + status, + "2025-11-25T10:30:00Z", + "2025-11-25T10:40:00Z", + ) + .with_ttl_ms(60000) + .with_poll_interval_ms(5000) + } + + #[test] + fn create_task_result_wire_shape() { + let result = CreateTaskResult::new(base_task(TaskStatus::Working)); + let value = serde_json::to_value(&result).unwrap(); + assert_eq!( + value, + json!({ + "resultType": "task", + "taskId": "task-1", + "status": "working", + "createdAt": "2025-11-25T10:30:00Z", + "lastUpdatedAt": "2025-11-25T10:40:00Z", + "ttlMs": 60000, + "pollIntervalMs": 5000 + }) + ); + let roundtrip: CreateTaskResult = serde_json::from_value(value).unwrap(); + assert_eq!(roundtrip, result); + } + + #[test] + fn ttl_ms_null_means_unlimited() { + let mut task = base_task(TaskStatus::Working); + task.ttl_ms = None; + let value = serde_json::to_value(&task).unwrap(); + assert_eq!(value["ttlMs"], serde_json::Value::Null); + let roundtrip: Task = serde_json::from_value(value).unwrap(); + assert_eq!(roundtrip.ttl_ms, None); + } + + #[test] + fn detailed_task_completed_roundtrip() { + let detailed = DetailedTask::new( + base_task(TaskStatus::Working), + TaskPayload::Completed { + result: serde_json::from_value(json!({ + "content": [{"type": "text", "text": "ok"}], + "isError": false + })) + .unwrap(), + }, + ); + // Status is forced to match the payload. + assert_eq!(detailed.status(), TaskStatus::Completed); + let value = serde_json::to_value(&detailed).unwrap(); + assert_eq!(value["status"], "completed"); + assert_eq!(value["result"]["isError"], false); + let roundtrip: DetailedTask = serde_json::from_value(value).unwrap(); + assert_eq!(roundtrip, detailed); + } + + #[test] + fn detailed_task_input_required_requires_input_requests() { + let err = serde_json::from_value::(json!({ + "taskId": "task-1", + "status": "input_required", + "createdAt": "2025-11-25T10:30:00Z", + "lastUpdatedAt": "2025-11-25T10:40:00Z", + "ttlMs": null + })) + .unwrap_err(); + assert!(err.to_string().contains("inputRequests")); + } + + #[test] + fn detailed_task_failed_roundtrip() { + let detailed = DetailedTask::new( + base_task(TaskStatus::Failed), + TaskPayload::Failed { + error: serde_json::from_value(json!({ + "code": -32603, + "message": "boom" + })) + .unwrap(), + }, + ); + let value = serde_json::to_value(&detailed).unwrap(); + assert_eq!(value["status"], "failed"); + assert_eq!(value["error"]["code"], -32603); + let roundtrip: DetailedTask = serde_json::from_value(value).unwrap(); + assert_eq!(roundtrip, detailed); + } + + #[test] + fn get_task_result_flattens_detailed_task() { + let result = GetTaskResult::new(DetailedTask::new( + base_task(TaskStatus::Working), + TaskPayload::Working, + )); + let value = serde_json::to_value(&result).unwrap(); + assert_eq!(value["taskId"], "task-1"); + assert_eq!(value["status"], "working"); + let roundtrip: GetTaskResult = serde_json::from_value(value).unwrap(); + assert_eq!(roundtrip, result); + } +} diff --git a/vendor/rmcp/src/model/tool.rs b/vendor/rmcp/src/model/tool.rs new file mode 100644 index 000000000..be7cbbd0a --- /dev/null +++ b/vendor/rmcp/src/model/tool.rs @@ -0,0 +1,259 @@ +use std::{borrow::Cow, sync::Arc}; + +#[cfg(feature = "server")] +use schemars::JsonSchema; +/// Tools represent a routine that a server can execute +/// Tool calls represent requests from the client to execute one +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::{Icon, JsonObject, MetaObject}; + +/// A tool that can be used by a model. +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct Tool { + /// The name of the tool + pub name: Cow<'static, str>, + /// A human-readable title for the tool + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + /// A description of what the tool does + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, + /// A JSON Schema object defining the expected parameters for the tool + pub input_schema: Arc, + /// An optional JSON Schema object defining the structure of the tool's output + #[serde(skip_serializing_if = "Option::is_none")] + pub output_schema: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional additional tool information. + pub annotations: Option, + /// Optional list of icons for the tool + #[serde(skip_serializing_if = "Option::is_none")] + pub icons: Option>, + /// Optional additional metadata for this tool + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// Additional properties describing a Tool to clients. +/// +/// NOTE: all properties in ToolAnnotations are **hints**. +/// They are not guaranteed to provide a faithful description of +/// tool behavior (including descriptive properties like `title`). +/// +/// Clients should never make tool use decisions based on ToolAnnotations +/// received from untrusted servers. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct ToolAnnotations { + /// A human-readable title for the tool. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + + /// If true, the tool does not modify its environment. + /// + /// Default: false + #[serde(skip_serializing_if = "Option::is_none")] + pub read_only_hint: Option, + + /// If true, the tool may perform destructive updates to its environment. + /// If false, the tool performs only additive updates. + /// + /// (This property is meaningful only when `readOnlyHint == false`) + /// + /// Default: true + /// A human-readable description of the tool's purpose. + #[serde(skip_serializing_if = "Option::is_none")] + pub destructive_hint: Option, + + /// If true, calling the tool repeatedly with the same arguments + /// will have no additional effect on the its environment. + /// + /// (This property is meaningful only when `readOnlyHint == false`) + /// + /// Default: false. + #[serde(skip_serializing_if = "Option::is_none")] + pub idempotent_hint: Option, + + /// If true, this tool may interact with an "open world" of external + /// entities. If false, the tool's domain of interaction is closed. + /// For example, the world of a web search tool is open, whereas that + /// of a memory tool is not. + /// + /// Default: true + #[serde(skip_serializing_if = "Option::is_none")] + pub open_world_hint: Option, +} + +impl ToolAnnotations { + pub fn new() -> Self { + Self::default() + } + + /// Create a new ToolAnnotations with all fields specified + pub fn from_raw( + title: Option, + read_only_hint: Option, + destructive_hint: Option, + idempotent_hint: Option, + open_world_hint: Option, + ) -> Self { + ToolAnnotations { + title, + read_only_hint, + destructive_hint, + idempotent_hint, + open_world_hint, + } + } + + pub fn with_title(title: T) -> Self + where + T: Into, + { + ToolAnnotations { + title: Some(title.into()), + ..Self::default() + } + } + pub fn read_only(self, read_only: bool) -> Self { + ToolAnnotations { + read_only_hint: Some(read_only), + ..self + } + } + pub fn destructive(self, destructive: bool) -> Self { + ToolAnnotations { + destructive_hint: Some(destructive), + ..self + } + } + pub fn idempotent(self, idempotent: bool) -> Self { + ToolAnnotations { + idempotent_hint: Some(idempotent), + ..self + } + } + pub fn open_world(self, open_world: bool) -> Self { + ToolAnnotations { + open_world_hint: Some(open_world), + ..self + } + } + + /// If not set, defaults to true. + pub fn is_destructive(&self) -> bool { + self.destructive_hint.unwrap_or(true) + } + + /// If not set, defaults to false. + pub fn is_idempotent(&self) -> bool { + self.idempotent_hint.unwrap_or(false) + } +} + +impl Tool { + /// Create a new tool with the given name and description + pub fn new(name: N, description: D, input_schema: S) -> Self + where + N: Into>, + D: Into>, + S: Into>, + { + Tool { + name: name.into(), + title: None, + description: Some(description.into()), + input_schema: input_schema.into(), + output_schema: None, + annotations: None, + icons: None, + meta: None, + } + } + + /// Create a new tool with just a name and input schema (no description) + pub fn new_with_raw( + name: N, + description: Option>, + input_schema: S, + ) -> Self + where + N: Into>, + S: Into>, + { + Tool { + name: name.into(), + title: None, + description, + input_schema: input_schema.into(), + output_schema: None, + annotations: None, + icons: None, + meta: None, + } + } + + /// Set the human-readable title + pub fn with_title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + /// Set the output schema from a raw value + pub fn with_raw_output_schema(mut self, output_schema: Arc) -> Self { + self.output_schema = Some(output_schema); + self + } + + /// Set the annotations + pub fn with_annotations(mut self, annotations: ToolAnnotations) -> Self { + self.annotations = Some(annotations); + self + } + + /// Set the icons + pub fn with_icons(mut self, icons: Vec) -> Self { + self.icons = Some(icons); + self + } + + /// Set the metadata + pub fn with_meta(mut self, meta: MetaObject) -> Self { + self.meta = Some(meta); + self + } + + pub fn annotate(self, annotations: ToolAnnotations) -> Self { + Tool { + annotations: Some(annotations), + ..self + } + } + + /// Set the output schema using a type that implements JsonSchema + #[cfg(feature = "server")] + pub fn with_output_schema(mut self) -> Self { + self.output_schema = Some(crate::handler::server::tool::schema_for_output::()); + self + } + + /// Set the input schema using a type that implements JsonSchema + #[cfg(feature = "server")] + pub fn with_input_schema(mut self) -> Self { + self.input_schema = crate::handler::server::tool::schema_for_input::() + .unwrap_or_else(|e| panic!("Invalid input schema for tool '{}': {}", self.name, e)); + self + } + + /// Get the schema as json value + pub fn schema_as_json_value(&self) -> Value { + Value::Object(self.input_schema.as_ref().clone()) + } +} diff --git a/vendor/rmcp/src/service.rs b/vendor/rmcp/src/service.rs new file mode 100644 index 000000000..20fd2e981 --- /dev/null +++ b/vendor/rmcp/src/service.rs @@ -0,0 +1,1889 @@ +use std::{borrow::Cow, sync::OnceLock}; + +use futures::FutureExt; +#[cfg(not(feature = "local"))] +use futures::future::BoxFuture; +#[cfg(feature = "local")] +use futures::future::LocalBoxFuture; +use thiserror::Error; + +// --------------------------------------------------------------------------- +// Conditional Send helpers +// +// `MaybeSend` – supertrait alias: `Send + Sync` without `local`, empty with `local` +// `MaybeSendFuture` – future bound alias: `Send` without `local`, empty with `local` +// `MaybeBoxFuture` – boxed future type: `BoxFuture` without `local`, `LocalBoxFuture` with `local` +// --------------------------------------------------------------------------- + +#[cfg(not(feature = "local"))] +#[doc(hidden)] +pub trait MaybeSend: Send + Sync {} +#[cfg(not(feature = "local"))] +impl MaybeSend for T {} + +#[cfg(feature = "local")] +#[doc(hidden)] +pub trait MaybeSend {} +#[cfg(feature = "local")] +impl MaybeSend for T {} + +#[cfg(not(feature = "local"))] +#[doc(hidden)] +pub trait MaybeSendFuture: Send {} +#[cfg(not(feature = "local"))] +impl MaybeSendFuture for T {} + +#[cfg(feature = "local")] +#[doc(hidden)] +pub trait MaybeSendFuture {} +#[cfg(feature = "local")] +impl MaybeSendFuture for T {} + +#[cfg(not(feature = "local"))] +pub(crate) type MaybeBoxFuture<'a, T> = BoxFuture<'a, T>; +#[cfg(feature = "local")] +pub(crate) type MaybeBoxFuture<'a, T> = LocalBoxFuture<'a, T>; + +#[cfg(feature = "server")] +use crate::model::ClientNotification; +#[cfg(feature = "server")] +use crate::model::ServerJsonRpcMessage; +#[cfg(feature = "client")] +use crate::model::ServerNotification; +use crate::{ + error::ErrorData as McpError, + model::{ + CancelledNotification, CancelledNotificationParam, ClientCapabilities, Extensions, + GetExtensions, GetMeta, Implementation, JsonRpcError, JsonRpcMessage, JsonRpcNotification, + JsonRpcRequest, JsonRpcResponse, NotificationMetaObject, NumberOrString, ProgressToken, + ProtocolVersion, RequestId, RequestMetaObject, + }, + transport::{DynamicTransportError, IntoTransport, Transport}, +}; +#[cfg(feature = "client")] +mod client; +#[cfg(feature = "client")] +pub use client::*; +#[cfg(feature = "server")] +mod server; +#[cfg(feature = "server")] +pub use server::*; +#[cfg(feature = "tower")] +mod tower; +use tokio_util::sync::{CancellationToken, DropGuard}; +#[cfg(feature = "tower")] +pub use tower::*; +use tracing::{Instrument as _, instrument}; +#[derive(Error, Debug)] +#[non_exhaustive] +pub enum ServiceError { + #[error("Mcp error: {0}")] + McpError(McpError), + #[error("Transport send error: {0}")] + TransportSend(DynamicTransportError), + #[error("Transport closed")] + TransportClosed, + #[error("Unexpected response type")] + UnexpectedResponse, + #[error("subscription consumer lagged behind its {capacity}-message buffer")] + SubscriptionLagged { capacity: usize }, + #[error("task cancelled for reason {}", reason.as_deref().unwrap_or(""))] + Cancelled { reason: Option }, + #[error("request timeout after {}", chrono::Duration::from_std(*timeout).unwrap_or_default())] + Timeout { timeout: Duration }, + /// The peer kept returning `input_required` beyond the configured round cap. + #[error("input_required did not complete within {max_rounds} MRTR rounds")] + InputRequiredRoundsExceeded { max_rounds: usize }, +} + +trait TransferObject: + std::fmt::Debug + Clone + serde::Serialize + serde::de::DeserializeOwned + Send + Sync + 'static +{ +} + +impl TransferObject for T where + T: std::fmt::Debug + + serde::Serialize + + serde::de::DeserializeOwned + + Send + + Sync + + 'static + + Clone +{ +} + +#[allow(private_bounds, reason = "there's no the third implementation")] +pub trait ServiceRole: std::fmt::Debug + Send + Sync + 'static + Copy + Clone { + type Req: TransferObject + GetMeta + GetExtensions; + type Resp: TransferObject; + type Not: TryInto + + From + + TransferObject; + type PeerReq: TransferObject + GetMeta + GetExtensions; + type PeerResp: TransferObject; + type PeerNot: TryInto + + From + + TransferObject + + GetMeta + + GetExtensions; + type InitializeError; + const IS_CLIENT: bool; + type Info: TransferObject; + type PeerInfo: TransferObject; + #[doc(hidden)] + fn configure_direct_peer(_peer: &Peer, _info: &Self::Info) {} + #[doc(hidden)] + fn peer_cancelled_params(_notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> { + None + } + /// Invalidate any response cache affected by an inbound peer notification. + /// + /// The serve loop calls this for every notification *before* subscription + /// routing, so cache invalidation still runs when a notification is + /// delivered through a `listen` subscription channel rather than the + /// [`Service::handle_notification`] callbacks. + #[doc(hidden)] + fn invalidate_response_cache( + _peer: &Peer, + _notification: &Self::PeerNot, + ) -> impl Future + MaybeSendFuture { + async {} + } + + #[doc(hidden)] + fn enforce_request_association( + _request: &Self::Req, + _peer_info: Option<&Self::PeerInfo>, + _in_request_handler_scope: bool, + ) -> Result<(), ServiceError> { + Ok(()) + } + + /// Receive-side counterpart of [`Self::enforce_request_association`]: + /// SEP-2260 says clients receiving a server-to-client request with no + /// associated outbound request should reject it with invalid params. An + /// error return is sent back to the peer instead of dispatching to the + /// handler. + #[doc(hidden)] + fn enforce_peer_request_association( + _peer_request: &Self::PeerReq, + _peer_info: Option<&Self::PeerInfo>, + _association: PeerRequestAssociation, + ) -> Result<(), McpError> { + Ok(()) + } +} + +/// How an inbound peer request relates to this side's in-flight outbound +/// requests (SEP-2260). +/// +/// SEP-2260 defines no wire field for association, so only stream-separating +/// transports (streamable HTTP) can observe it; other transports yield +/// [`Self::Unknown`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] +pub enum PeerRequestAssociation { + /// Arrived on the response stream of an in-flight outbound request. + Associated, + /// Arrived on a stream tied to no in-flight outbound request (e.g. the + /// streamable HTTP standalone GET stream). + Unassociated, + /// The transport cannot distinguish streams; only the coarse in-flight + /// signal is available. + Unknown { has_pending_outbound_request: bool }, +} + +pub(crate) fn uses_legacy_lifecycle( + protocol_version: Option<&ProtocolVersion>, + uses_discover_lifecycle: bool, +) -> bool { + !uses_discover_lifecycle + && protocol_version.is_none_or(|version| version < &ProtocolVersion::V_2026_07_28) +} + +pub(crate) fn peer_request_association( + request: &Req, + local_responder_pool: &std::collections::HashMap, +) -> PeerRequestAssociation { + match request.extensions().get::() { + None => PeerRequestAssociation::Unknown { + has_pending_outbound_request: !local_responder_pool.is_empty(), + }, + Some(InboundStreamOrigin::Unassociated) => PeerRequestAssociation::Unassociated, + Some(InboundStreamOrigin::OutboundRequest(id)) => { + if local_responder_pool.contains_key(id) { + PeerRequestAssociation::Associated + } else { + PeerRequestAssociation::Unassociated + } + } + } +} + +tokio::task_local! { + pub(crate) static ORIGINATING_REQUEST: RequestId; +} + +pub(crate) fn in_request_handler_scope() -> bool { + ORIGINATING_REQUEST.try_with(|_| ()).is_ok() +} + +/// Marker in an outbound request's non-serialized [`Extensions`] identifying +/// the in-flight peer request it was issued from (SEP-2260). Attached for both +/// roles whenever a request is sent from within a request handler; the +/// streamable HTTP server reads it to deliver server-initiated requests on the +/// originating request's SSE stream. Never on the wire (SEP-2260 defines no +/// wire field), so session managers that serialize messages between processes +/// lose it and such requests fall back to the standalone stream with a warning. +/// +/// # Caller requirements +/// +/// From protocol version `2026-07-28`, server-to-client sampling, roots, and +/// elicitation requests must be issued while handling a client request; +/// outside a handler they return an `invalid_request` error. The association +/// is task-local and does not cross `tokio::spawn`, so use the task manager +/// for long-running work. +/// +/// The client receive-side mirror is [`InboundStreamOrigin`]. +#[derive(Debug, Clone, PartialEq, Eq)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct OriginatingRequestId(pub RequestId); + +/// Marker in an inbound request's non-serialized [`Extensions`] recording +/// which HTTP response stream it arrived on: the receive-side mirror of +/// [`OriginatingRequestId`]. Never on the wire (SEP-2260 defines no wire +/// field); when absent, the coarse in-flight check applies. +#[derive(Debug, Clone, PartialEq, Eq)] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] +pub enum InboundStreamOrigin { + /// The standalone GET stream, or a POST response stream not tied to an + /// outbound request. + Unassociated, + /// The SSE response stream of the POST that carried this outbound request. + OutboundRequest(RequestId), +} + +pub type TxJsonRpcMessage = + JsonRpcMessage<::Req, ::Resp, ::Not>; +pub type RxJsonRpcMessage = JsonRpcMessage< + ::PeerReq, + ::PeerResp, + ::PeerNot, +>; + +#[cfg(not(feature = "local"))] +pub trait Service: Send + Sync + 'static { + fn handle_request( + &self, + request: R::PeerReq, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_; + fn handle_notification( + &self, + notification: R::PeerNot, + context: NotificationContext, + ) -> impl Future> + MaybeSendFuture + '_; + fn get_info(&self) -> R::Info; + /// The protocol versions this service can speak, bounding what `initialize` + /// negotiation may agree to. + /// + /// Servers normally override + /// [`ServerHandler::supported_protocol_versions`] instead of this method; + /// the blanket `Service` impl forwards to it. This method exists so the + /// transport and handshake layers, which see only a `Service`, can read the + /// list and avoid agreeing to a version the server cannot serve. + /// + /// [`ServerHandler::supported_protocol_versions`]: crate::handler::server::ServerHandler::supported_protocol_versions + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS) + } +} + +#[cfg(feature = "local")] +pub trait Service: 'static { + fn handle_request( + &self, + request: R::PeerReq, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_; + fn handle_notification( + &self, + notification: R::PeerNot, + context: NotificationContext, + ) -> impl Future> + MaybeSendFuture + '_; + fn get_info(&self) -> R::Info; + /// The protocol versions this service can speak. + /// + /// See the non-`local` variant of this trait for details. + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS) + } +} + +pub trait ServiceExt: Service + Sized { + /// Convert this service to a dynamic boxed service + /// + /// This could be very helpful when you want to store the services in a collection + fn into_dyn(self) -> Box> { + Box::new(self) + } + fn serve( + self, + transport: T, + ) -> impl Future, R::InitializeError>> + MaybeSendFuture + where + T: IntoTransport, + E: std::error::Error + Send + Sync + 'static, + Self: Sized, + { + Self::serve_with_ct(self, transport, Default::default()) + } + fn serve_with_ct( + self, + transport: T, + ct: CancellationToken, + ) -> impl Future, R::InitializeError>> + MaybeSendFuture + where + T: IntoTransport, + E: std::error::Error + Send + Sync + 'static, + Self: Sized; +} + +impl Service for Box> { + fn handle_request( + &self, + request: R::PeerReq, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + DynService::handle_request(self.as_ref(), request, context) + } + + fn handle_notification( + &self, + notification: R::PeerNot, + context: NotificationContext, + ) -> impl Future> + MaybeSendFuture + '_ { + DynService::handle_notification(self.as_ref(), notification, context) + } + + fn get_info(&self) -> R::Info { + DynService::get_info(self.as_ref()) + } + + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + DynService::supported_protocol_versions(self.as_ref()) + } +} + +#[cfg(not(feature = "local"))] +pub trait DynService: Send + Sync { + fn handle_request( + &self, + request: R::PeerReq, + context: RequestContext, + ) -> MaybeBoxFuture<'_, Result>; + fn handle_notification( + &self, + notification: R::PeerNot, + context: NotificationContext, + ) -> MaybeBoxFuture<'_, Result<(), McpError>>; + fn get_info(&self) -> R::Info; + /// See [`Service::supported_protocol_versions`]. + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS) + } +} + +#[cfg(feature = "local")] +pub trait DynService { + fn handle_request( + &self, + request: R::PeerReq, + context: RequestContext, + ) -> MaybeBoxFuture<'_, Result>; + fn handle_notification( + &self, + notification: R::PeerNot, + context: NotificationContext, + ) -> MaybeBoxFuture<'_, Result<(), McpError>>; + fn get_info(&self) -> R::Info; + /// See [`Service::supported_protocol_versions`]. + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS) + } +} + +impl> DynService for S { + fn handle_request( + &self, + request: R::PeerReq, + context: RequestContext, + ) -> MaybeBoxFuture<'_, Result> { + Box::pin(self.handle_request(request, context)) + } + fn handle_notification( + &self, + notification: R::PeerNot, + context: NotificationContext, + ) -> MaybeBoxFuture<'_, Result<(), McpError>> { + Box::pin(self.handle_notification(notification, context)) + } + fn get_info(&self) -> R::Info { + self.get_info() + } + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Service::supported_protocol_versions(self) + } +} + +use std::{ + collections::{HashMap, VecDeque}, + ops::Deref, + sync::{Arc, atomic::AtomicU64}, + time::Duration, +}; + +use tokio::sync::mpsc; + +pub trait RequestIdProvider: Send + Sync + 'static { + fn next_request_id(&self) -> RequestId; +} + +pub trait ProgressTokenProvider: Send + Sync + 'static { + fn next_progress_token(&self) -> ProgressToken; +} + +pub type AtomicU32RequestIdProvider = AtomicU32Provider; +pub type AtomicU32ProgressTokenProvider = AtomicU32Provider; + +pub(crate) fn remove_pending_request( + pending_requests: &mut HashMap, + response_id: &RequestId, +) -> Option { + pending_requests.remove(response_id).or_else(|| { + response_id + .numeric_string_value() + .and_then(|id| pending_requests.remove(&RequestId::Number(id))) + }) +} + +#[derive(Debug, Default)] +pub struct AtomicU32Provider { + id: AtomicU64, +} + +impl RequestIdProvider for AtomicU32Provider { + fn next_request_id(&self) -> RequestId { + let id = self.id.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + // Safe conversion: we start at 0 and increment by 1, so we won't overflow i64::MAX in practice + RequestId::Number(id as i64) + } +} + +impl ProgressTokenProvider for AtomicU32Provider { + fn next_progress_token(&self) -> ProgressToken { + let id = self.id.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + ProgressToken(NumberOrString::Number(id as i64)) + } +} + +#[doc(hidden)] +pub trait ProgressNotificationToken { + fn progress_token(&self) -> Option<&ProgressToken>; +} + +#[cfg(feature = "server")] +impl ProgressNotificationToken for ClientNotification { + fn progress_token(&self) -> Option<&ProgressToken> { + match self { + ClientNotification::ProgressNotification(notification) => { + Some(¬ification.params.progress_token) + } + _ => None, + } + } +} + +#[cfg(feature = "client")] +impl ProgressNotificationToken for ServerNotification { + fn progress_token(&self) -> Option<&ProgressToken> { + match self { + ServerNotification::ProgressNotification(notification) => { + Some(¬ification.params.progress_token) + } + _ => None, + } + } +} + +type Responder = tokio::sync::oneshot::Sender; +type ProgressTimeoutWatchers = Arc>>>; +type SubscriptionChannel = (mpsc::Sender, usize); +type SubscriptionChannelMap = HashMap>; + +/// A handle to a remote request +/// +/// You can cancel it by call [`RequestHandle::cancel`] with a reason, +/// +/// or wait for response by call [`RequestHandle::await_response`] +#[derive(Debug)] +#[non_exhaustive] +pub struct RequestHandle { + pub rx: tokio::sync::oneshot::Receiver>, + pub options: PeerRequestOptions, + pub peer: Peer, + pub id: RequestId, + pub progress_token: ProgressToken, + progress_reset_rx: Option>, +} + +impl RequestHandle { + pub const REQUEST_TIMEOUT_REASON: &str = "request timeout"; + pub const REQUEST_MAX_TOTAL_TIMEOUT_REASON: &str = "maximum total timeout exceeded"; + + pub async fn await_response(mut self) -> Result { + let timeout = self.options.timeout; + let max_total_timeout = self.options.max_total_timeout; + let reset_timeout_on_progress = self.options.reset_timeout_on_progress; + + let has_progress_reset_rx = self.progress_reset_rx.is_some(); + let progress_token = self.progress_token.clone(); + + let result = match (timeout, max_total_timeout, reset_timeout_on_progress) { + (Some(timeout), None, false) => match tokio::time::timeout(timeout, &mut self.rx).await + { + Ok(response) => response.map_err(|_e| ServiceError::TransportClosed)?, + Err(_) => { + let error = Err(ServiceError::Timeout { timeout }); + // cancel this request + self.send_timeout_cancel_notification(Self::REQUEST_TIMEOUT_REASON) + .await; + error + } + }, + (None, None, _) => (&mut self.rx) + .await + .map_err(|_e| ServiceError::TransportClosed)?, + _ => { + self.await_response_with_progress_timeout( + timeout, + max_total_timeout, + reset_timeout_on_progress, + ) + .await + } + }; + + Self::cleanup_progress_timeout_watcher( + &self.peer.progress_timeout_watchers, + &progress_token, + has_progress_reset_rx, + ) + .await; + self.peer.unregister_subscription(&self.id); + result + } + + async fn send_timeout_cancel_notification(&self, reason: &str) { + self.peer.unregister_subscription(&self.id); + let notification = CancelledNotification { + params: CancelledNotificationParam { + request_id: Some(self.id.clone()), + reason: Some(reason.to_owned()), + meta: None, + }, + method: crate::model::CancelledNotificationMethod, + extensions: Default::default(), + }; + let _ = self.peer.send_notification(notification.into()).await; + } + + async fn await_response_with_progress_timeout( + &mut self, + timeout: Option, + max_total_timeout: Option, + reset_timeout_on_progress: bool, + ) -> Result { + let mut idle_sleep = + timeout.map(|timeout| (timeout, Box::pin(tokio::time::sleep(timeout)))); + let mut max_total_sleep = + max_total_timeout.map(|timeout| (timeout, Box::pin(tokio::time::sleep(timeout)))); + + loop { + tokio::select! { + biased; + + response = &mut self.rx => { + return response.map_err(|_e| ServiceError::TransportClosed)?; + } + _ = async { + if let Some((_, sleep)) = idle_sleep.as_mut() { + sleep.as_mut().await; + } + }, if idle_sleep.is_some() => { + if let Some((timeout, _)) = idle_sleep.as_ref() { + self.send_timeout_cancel_notification(Self::REQUEST_TIMEOUT_REASON).await; + return Err(ServiceError::Timeout { timeout: *timeout }); + } + } + _ = async { + if let Some((_, sleep)) = max_total_sleep.as_mut() { + sleep.as_mut().await; + } + }, if max_total_sleep.is_some() => { + if let Some((timeout, _)) = max_total_sleep.as_ref() { + self.send_timeout_cancel_notification(Self::REQUEST_MAX_TOTAL_TIMEOUT_REASON).await; + return Err(ServiceError::Timeout { timeout: *timeout }); + } + } + progress = async { + match self.progress_reset_rx.as_mut() { + Some(rx) => rx.recv().await, + None => None, + } + }, if reset_timeout_on_progress && idle_sleep.is_some() && self.progress_reset_rx.is_some() => { + if progress.is_some() + && let Some((timeout, sleep)) = idle_sleep.as_mut() { + sleep.as_mut().reset(tokio::time::Instant::now() + *timeout); + } + } + } + } + } + + /// Cancel this request + pub async fn cancel(self, reason: Option) -> Result<(), ServiceError> { + Self::cleanup_progress_timeout_watcher( + &self.peer.progress_timeout_watchers, + &self.progress_token, + self.progress_reset_rx.is_some(), + ) + .await; + self.peer.unregister_subscription(&self.id); + let notification = CancelledNotification { + params: CancelledNotificationParam { + request_id: Some(self.id), + reason, + meta: None, + }, + method: crate::model::CancelledNotificationMethod, + extensions: Default::default(), + }; + self.peer.send_notification(notification.into()).await?; + Ok(()) + } + + async fn cleanup_progress_timeout_watcher( + progress_timeout_watchers: &ProgressTimeoutWatchers, + progress_token: &ProgressToken, + has_progress_reset_rx: bool, + ) { + if has_progress_reset_rx { + progress_timeout_watchers + .write() + .await + .remove(progress_token); + } + } +} + +#[derive(Debug)] +pub(crate) enum PeerSinkMessage { + Request { + request: R::Req, + id: RequestId, + responder: Responder>, + }, + Notification { + notification: R::Not, + responder: Responder>, + }, +} + +#[derive(Debug, Clone)] +pub(crate) struct ClientRequestMetadata { + pub protocol_version: ProtocolVersion, + pub client_info: Implementation, + pub client_capabilities: ClientCapabilities, +} + +/// An interface to fetch the remote client or server +/// +/// For general purpose, call [`Peer::send_request`] or [`Peer::send_notification`] to send message to remote peer. +/// +/// To create a cancellable request, call [`Peer::send_request_with_option`]. +pub struct Peer { + tx: mpsc::Sender>, + request_id_provider: Arc, + progress_token_provider: Arc, + progress_timeout_watchers: ProgressTimeoutWatchers, + info: Arc>>>, + client_request_metadata: Arc>, + request_metadata_required: Arc, + subscription_channels: Arc>>, + #[cfg(feature = "client")] + response_cache: client::cache::PeerResponseCache, +} + +impl Clone for Peer +where + R::PeerInfo: Clone, +{ + fn clone(&self) -> Peer { + Self { + tx: self.tx.clone(), + request_id_provider: self.request_id_provider.clone(), + progress_token_provider: self.progress_token_provider.clone(), + progress_timeout_watchers: self.progress_timeout_watchers.clone(), + info: self.info.clone(), + client_request_metadata: self.client_request_metadata.clone(), + request_metadata_required: self.request_metadata_required.clone(), + subscription_channels: self.subscription_channels.clone(), + #[cfg(feature = "client")] + response_cache: self.response_cache.clone(), + } + } +} + +impl std::fmt::Debug for Peer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PeerSink") + .field("tx", &self.tx) + .field("is_client", &R::IS_CLIENT) + .finish() + } +} + +type ProxyOutbound = mpsc::Receiver>; + +#[derive(Debug, Default)] +#[non_exhaustive] +pub struct PeerRequestOptions { + pub timeout: Option, + pub meta: Option, + /// Reset the request timeout when a matching progress notification is received. + pub reset_timeout_on_progress: bool, + /// Maximum total time to wait for the request, regardless of progress notifications. + pub max_total_timeout: Option, +} + +impl PeerRequestOptions { + pub fn no_options() -> Self { + Self::default() + } + + pub fn with_timeout(timeout: Duration) -> Self { + Self { + timeout: Some(timeout), + ..Self::default() + } + } + + /// Adds request metadata while preserving any other configured options. + /// + /// Explicit values take precedence over discover-lifecycle metadata defaults. + pub fn with_meta(mut self, meta: RequestMetaObject) -> Self { + self.meta = Some(meta); + self + } + + pub fn reset_timeout_on_progress(mut self) -> Self { + self.reset_timeout_on_progress = true; + self + } + + pub fn with_max_total_timeout(mut self, timeout: Duration) -> Self { + self.max_total_timeout = Some(timeout); + self + } +} + +impl Peer { + const CLIENT_CHANNEL_BUFFER_SIZE: usize = 1024; + pub(crate) fn new( + request_id_provider: Arc, + peer_info: Option, + ) -> (Peer, ProxyOutbound) { + let (tx, rx) = mpsc::channel(Self::CLIENT_CHANNEL_BUFFER_SIZE); + ( + Self { + tx, + request_id_provider, + progress_token_provider: Arc::new(AtomicU32ProgressTokenProvider::default()), + progress_timeout_watchers: Default::default(), + info: Arc::new(std::sync::RwLock::new(peer_info.map(Arc::new))), + client_request_metadata: Default::default(), + request_metadata_required: Default::default(), + subscription_channels: Default::default(), + #[cfg(feature = "client")] + response_cache: Default::default(), + }, + rx, + ) + } + pub async fn send_notification(&self, notification: R::Not) -> Result<(), ServiceError> { + let (responder, receiver) = tokio::sync::oneshot::channel(); + self.tx + .send(PeerSinkMessage::Notification { + notification, + responder, + }) + .await + .map_err(|_m| ServiceError::TransportClosed)?; + receiver.await.map_err(|_e| ServiceError::TransportClosed)? + } + pub async fn send_request(&self, request: R::Req) -> Result { + self.send_request_with_option(request, PeerRequestOptions::no_options()) + .await? + .await_response() + .await + } + + pub async fn send_cancellable_request( + &self, + request: R::Req, + options: PeerRequestOptions, + ) -> Result, ServiceError> { + self.send_request_with_option(request, options).await + } + + pub async fn send_request_with_option( + &self, + request: R::Req, + options: PeerRequestOptions, + ) -> Result, ServiceError> { + self.send_request_with_option_and_subscription(request, options, None) + .await + } + + async fn send_request_with_option_and_subscription( + &self, + mut request: R::Req, + options: PeerRequestOptions, + subscription_sender: Option>, + ) -> Result, ServiceError> { + R::enforce_request_association( + &request, + self.peer_info().as_deref(), + in_request_handler_scope(), + )?; + if let Ok(originating) = ORIGINATING_REQUEST.try_with(|id| id.clone()) { + request + .extensions_mut() + .insert(OriginatingRequestId(originating)); + } + let id = self.request_id_provider.next_request_id(); + let progress_token = self.progress_token_provider.next_progress_token(); + if let Some(metadata) = self.client_request_metadata.get() { + let meta = request.get_meta_mut(); + meta.set_protocol_version(metadata.protocol_version.clone()); + meta.set_client_info(metadata.client_info.clone()); + meta.set_client_capabilities(metadata.client_capabilities.clone()); + } + if let Some(meta) = options.meta.clone() { + request.get_meta_mut().extend(meta); + } + request + .get_meta_mut() + .set_progress_token(progress_token.clone()); + let (responder, receiver) = tokio::sync::oneshot::channel(); + let progress_reset_rx = if options.reset_timeout_on_progress && options.timeout.is_some() { + let (sender, receiver) = mpsc::channel(1); + self.progress_timeout_watchers + .write() + .await + .insert(progress_token.clone(), sender); + Some(receiver) + } else { + None + }; + if let Some(channel) = subscription_sender { + self.subscription_channels_write() + .insert(id.clone(), channel); + } + if self + .tx + .send(PeerSinkMessage::Request { + request, + id: id.clone(), + responder, + }) + .await + .is_err() + { + if progress_reset_rx.is_some() { + self.progress_timeout_watchers + .write() + .await + .remove(&progress_token); + } + self.unregister_subscription(&id); + return Err(ServiceError::TransportClosed); + } + Ok(RequestHandle { + id, + rx: receiver, + progress_token, + options, + peer: self.clone(), + progress_reset_rx, + }) + } + + #[cfg(feature = "client")] + pub(crate) async fn send_subscription_request( + &self, + request: R::Req, + options: PeerRequestOptions, + channel_capacity: usize, + ) -> Result<(RequestHandle, mpsc::Receiver), ServiceError> { + let (sender, receiver) = mpsc::channel(channel_capacity); + let handle = self + .send_request_with_option_and_subscription( + request, + options, + Some((sender, channel_capacity)), + ) + .await?; + Ok((handle, receiver)) + } + + fn subscription_sender(&self, id: &RequestId) -> Option> { + self.subscription_channels_read().get(id).cloned() + } + + pub(crate) fn unregister_subscription(&self, id: &RequestId) { + self.subscription_channels_write().remove(id); + } + + fn subscription_channels_read( + &self, + ) -> std::sync::RwLockReadGuard<'_, SubscriptionChannelMap> { + match self.subscription_channels.read() { + Ok(channels) => channels, + Err(poisoned) => poisoned.into_inner(), + } + } + + fn subscription_channels_write( + &self, + ) -> std::sync::RwLockWriteGuard<'_, SubscriptionChannelMap> { + match self.subscription_channels.write() { + Ok(channels) => channels, + Err(poisoned) => poisoned.into_inner(), + } + } + + pub(crate) fn try_cancel_request(&self, id: RequestId, reason: Option) { + let notification = CancelledNotification { + params: CancelledNotificationParam { + request_id: Some(id), + reason, + meta: None, + }, + method: crate::model::CancelledNotificationMethod, + extensions: Default::default(), + }; + let (responder, _receiver) = tokio::sync::oneshot::channel(); + let _ = self.tx.try_send(PeerSinkMessage::Notification { + notification: notification.into(), + responder, + }); + } + + async fn notify_progress_timeout_watcher(&self, progress_token: &ProgressToken) { + let sender = self + .progress_timeout_watchers + .read() + .await + .get(progress_token) + .cloned(); + if let Some(sender) = sender { + match sender.try_send(()) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + tracing::trace!(?progress_token, "progress timeout watcher channel is full"); + } + Err(mpsc::error::TrySendError::Closed(_)) => { + self.progress_timeout_watchers + .write() + .await + .remove(progress_token); + } + } + } + } + + /// Snapshot of the peer's handshake info. + pub fn peer_info(&self) -> Option> { + self.info.read().expect("peer info lock poisoned").clone() + } + + /// Stores the peer's handshake info, overwriting any previous value. + pub fn set_peer_info(&self, info: R::PeerInfo) { + *self.info.write().expect("peer info lock poisoned") = Some(Arc::new(info)); + } + + pub(crate) fn set_client_request_metadata(&self, metadata: ClientRequestMetadata) { + let result = self.client_request_metadata.set(metadata); + debug_assert!(result.is_ok(), "client request metadata set more than once"); + } + + pub(crate) fn require_request_metadata(&self) { + self.request_metadata_required + .store(true, std::sync::atomic::Ordering::Release); + } + + pub(crate) fn request_metadata_required(&self) -> bool { + self.request_metadata_required + .load(std::sync::atomic::Ordering::Acquire) + } + + pub fn is_transport_closed(&self) -> bool { + self.tx.is_closed() + } +} + +#[derive(Debug)] +pub struct RunningService> { + service: Arc, + peer: Peer, + handle: Option>, + cancellation_token: CancellationToken, + dg: DropGuard, +} +impl> Deref for RunningService { + type Target = Peer; + + fn deref(&self) -> &Self::Target { + &self.peer + } +} + +impl> RunningService { + #[inline] + pub fn peer(&self) -> &Peer { + &self.peer + } + #[inline] + pub fn service(&self) -> &S { + self.service.as_ref() + } + #[inline] + pub fn cancellation_token(&self) -> RunningServiceCancellationToken { + RunningServiceCancellationToken(self.cancellation_token.clone()) + } + + /// Returns true if the service has been closed or cancelled. + #[inline] + pub fn is_closed(&self) -> bool { + self.handle.is_none() || self.cancellation_token.is_cancelled() + } + + /// Wait for the service to complete. + /// + /// This will block until the service loop terminates (either due to + /// cancellation, transport closure, or an error). + #[inline] + pub async fn waiting(mut self) -> Result { + match self.handle.take() { + Some(handle) => handle.await, + None => Ok(QuitReason::Closed), + } + } + + /// Gracefully close the connection and wait for cleanup to complete. + /// + /// This method cancels the service, waits for the background task to finish + /// (which includes calling `transport.close()`), and ensures all cleanup + /// operations complete before returning. + /// + /// Unlike [`cancel`](Self::cancel), this method takes `&mut self` and can be + /// called without consuming the `RunningService`. After calling this method, + /// the service is considered closed and subsequent operations will fail. + /// + /// # Example + /// + /// ```rust,ignore + /// let mut client = ().serve(transport).await?; + /// // ... use the client ... + /// client.close().await?; + /// ``` + pub async fn close(&mut self) -> Result { + if let Some(handle) = self.handle.take() { + // Disarm the drop guard so it doesn't try to cancel again + // We need to cancel manually and wait for completion + self.cancellation_token.cancel(); + handle.await + } else { + // Already closed + Ok(QuitReason::Closed) + } + } + + /// Gracefully close the connection with a timeout. + /// + /// Similar to [`close`](Self::close), but returns after the specified timeout + /// if the cleanup doesn't complete in time. This is useful for ensuring + /// a bounded shutdown time. + /// + /// Returns `Ok(Some(reason))` if shutdown completed within the timeout, + /// `Ok(None)` if the timeout was reached, or `Err` if there was a join error. + pub async fn close_with_timeout( + &mut self, + timeout: Duration, + ) -> Result, tokio::task::JoinError> { + if let Some(handle) = self.handle.take() { + self.cancellation_token.cancel(); + match tokio::time::timeout(timeout, handle).await { + Ok(result) => result.map(Some), + Err(_elapsed) => { + tracing::warn!( + "close_with_timeout: cleanup did not complete within {:?}", + timeout + ); + Ok(None) + } + } + } else { + Ok(Some(QuitReason::Closed)) + } + } + + /// Cancel the service and wait for cleanup to complete. + /// + /// This consumes the `RunningService` and ensures the connection is properly + /// closed. For a non-consuming alternative, see [`close`](Self::close). + pub async fn cancel(mut self) -> Result { + // Disarm the drop guard since we're handling cancellation explicitly + let _ = std::mem::replace(&mut self.dg, self.cancellation_token.clone().drop_guard()); + self.close().await + } +} + +impl> Drop for RunningService { + fn drop(&mut self) { + if self.handle.is_some() && !self.cancellation_token.is_cancelled() { + tracing::debug!( + "RunningService dropped without explicit close(). \ + The connection will be closed asynchronously. \ + For guaranteed cleanup, call close() or cancel() before dropping." + ); + } + // The DropGuard will handle cancellation + } +} + +// use a wrapper type so we can tweak the implementation if needed +pub struct RunningServiceCancellationToken(CancellationToken); + +impl RunningServiceCancellationToken { + pub fn cancel(self) { + self.0.cancel(); + } +} + +#[derive(Debug)] +#[non_exhaustive] +pub enum QuitReason { + Cancelled, + Closed, + JoinError(tokio::task::JoinError), +} + +/// Request execution context +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct RequestContext { + /// this token will be cancelled when the [`CancelledNotification`] is received. + pub ct: CancellationToken, + pub id: RequestId, + pub meta: RequestMetaObject, + pub extensions: Extensions, + /// An interface to fetch the remote client or server + pub peer: Peer, +} + +impl RequestContext { + /// Create a new RequestContext. + pub fn new(id: RequestId, peer: Peer) -> Self { + Self { + ct: CancellationToken::new(), + id, + meta: RequestMetaObject::default(), + extensions: Extensions::default(), + peer, + } + } +} + +#[cfg(feature = "server")] +impl RequestContext { + /// The current request's protocol version, falling back to legacy handshake state. + pub fn protocol_version(&self) -> Option { + self.meta.protocol_version().or_else(|| { + self.peer + .peer_info() + .map(|info| info.protocol_version.clone()) + }) + } + + /// The current request's client implementation, falling back only for legacy sessions. + pub fn client_info(&self) -> Option { + if self.peer.request_metadata_required() { + self.meta.client_info() + } else { + self.meta + .client_info() + .or_else(|| self.peer.peer_info().map(|info| info.client_info.clone())) + } + } + + /// The current request's client capabilities, falling back only for legacy sessions. + pub fn client_capabilities(&self) -> Option { + if self.peer.request_metadata_required() { + self.meta.client_capabilities() + } else { + self.meta + .client_capabilities() + .or_else(|| self.peer.peer_info().map(|info| info.capabilities.clone())) + } + } +} + +/// Request execution context +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct NotificationContext { + pub meta: NotificationMetaObject, + pub extensions: Extensions, + /// An interface to fetch the remote client or server + pub peer: Peer, +} + +/// Use this function to skip initialization process +pub fn serve_directly( + service: S, + transport: T, + peer_info: Option, +) -> RunningService +where + R: ServiceRole, + R::PeerNot: ProgressNotificationToken, + S: Service, + T: IntoTransport, + E: std::error::Error + Send + Sync + 'static, +{ + serve_directly_with_ct(service, transport, peer_info, Default::default()) +} + +/// Use this function to skip initialization process +pub fn serve_directly_with_ct( + service: S, + transport: T, + peer_info: Option, + ct: CancellationToken, +) -> RunningService +where + R: ServiceRole, + R::PeerNot: ProgressNotificationToken, + S: Service, + T: IntoTransport, + E: std::error::Error + Send + Sync + 'static, +{ + let (peer, peer_rx) = Peer::new(Arc::new(AtomicU32RequestIdProvider::default()), peer_info); + R::configure_direct_peer(&peer, &service.get_info()); + serve_inner(service, transport.into_transport(), peer, peer_rx, ct) +} + +/// Spawn a task that may hold `!Send` state when the `local` feature is active. +/// +/// Without the `local` feature this is `tokio::spawn` (requires `Future: Send + 'static`). +/// With `local` it uses `tokio::task::spawn_local` (requires only `Future: 'static`). +#[cfg(not(feature = "local"))] +fn spawn_service_task(future: F) -> tokio::task::JoinHandle +where + F: Future + Send + 'static, + F::Output: Send + 'static, +{ + tokio::spawn(future) +} + +#[cfg(feature = "local")] +fn spawn_service_task(future: F) -> tokio::task::JoinHandle +where + F: Future + 'static, + F::Output: 'static, +{ + tokio::task::spawn_local(future) +} + +#[instrument(skip_all)] +fn serve_inner( + service: S, + transport: T, + peer: Peer, + mut peer_rx: tokio::sync::mpsc::Receiver>, + ct: CancellationToken, +) -> RunningService +where + R: ServiceRole, + R::PeerNot: ProgressNotificationToken, + S: Service, + T: Transport + 'static, +{ + const SINK_PROXY_BUFFER_SIZE: usize = 64; + let (sink_proxy_tx, mut sink_proxy_rx) = + tokio::sync::mpsc::channel::>(SINK_PROXY_BUFFER_SIZE); + let peer_info = peer.peer_info(); + if R::IS_CLIENT { + tracing::info!(?peer_info, "Service initialized as client"); + } else { + tracing::info!(?peer_info, "Service initialized as server"); + } + + let mut local_responder_pool = + HashMap::>>::new(); + let mut local_ct_pool = HashMap::::new(); + let shared_service = Arc::new(service); + // for return + let service = shared_service.clone(); + + // let message_sink = tokio::sync:: + // let mut stream = std::pin::pin!(stream); + let serve_loop_ct = ct.child_token(); + let peer_return: Peer = peer.clone(); + let current_span = tracing::Span::current(); + let handle = spawn_service_task(async move { + let mut transport = transport.into_transport(); + let mut batch_messages = VecDeque::>::new(); + let mut send_task_set = tokio::task::JoinSet::::new(); + let mut response_send_tasks = tokio::task::JoinSet::<()>::new(); + #[derive(Debug)] + enum SendTaskResult { + Request { + id: RequestId, + result: Result<(), DynamicTransportError>, + }, + Notification { + responder: Responder>, + cancellation_param: Option, + result: Result<(), DynamicTransportError>, + }, + } + #[derive(Debug)] + enum Event { + ProxyMessage(PeerSinkMessage), + PeerMessage(RxJsonRpcMessage), + ToSink(TxJsonRpcMessage), + SendTaskResult(SendTaskResult), + ResponseSendTaskResult(Result<(), tokio::task::JoinError>), + } + + let quit_reason = loop { + let evt = if let Some(m) = batch_messages.pop_front() { + Event::PeerMessage(m) + } else { + tokio::select! { + m = sink_proxy_rx.recv(), if !sink_proxy_rx.is_closed() => { + if let Some(m) = m { + Event::ToSink(m) + } else { + continue + } + } + m = transport.receive() => { + if let Some(m) = m { + Event::PeerMessage(m) + } else { + // input stream closed + tracing::info!("input stream terminated"); + break QuitReason::Closed + } + } + m = peer_rx.recv(), if !peer_rx.is_closed() => { + if let Some(m) = m { + Event::ProxyMessage(m) + } else { + continue + } + } + m = send_task_set.join_next(), if !send_task_set.is_empty() => { + let Some(result) = m else { + continue + }; + match result { + Err(e) => { + // join error, which is serious, we should quit. + tracing::error!(%e, "send request task encounter a tokio join error"); + break QuitReason::JoinError(e) + } + Ok(result) => { + Event::SendTaskResult(result) + } + } + } + result = response_send_tasks.join_next(), if !response_send_tasks.is_empty() => { + Event::ResponseSendTaskResult( + result.expect("non-empty response send task set") + ) + } + _ = serve_loop_ct.cancelled() => { + tracing::info!("task cancelled"); + break QuitReason::Cancelled + } + } + }; + + tracing::trace!(?evt, "new event"); + match evt { + Event::SendTaskResult(SendTaskResult::Request { id, result }) => { + if let Err(e) = result + && let Some(responder) = local_responder_pool.remove(&id) { + let _ = responder.send(Err(ServiceError::TransportSend(e))); + } + } + Event::SendTaskResult(SendTaskResult::Notification { + responder, + result, + cancellation_param, + }) => { + let response = if let Err(e) = result { + Err(ServiceError::TransportSend(e)) + } else { + Ok(()) + }; + let _ = responder.send(response); + if let Some(param) = cancellation_param + && let Some(request_id) = ¶m.request_id + && let Some(responder) = local_responder_pool.remove(request_id) { + tracing::info!(id = %request_id, reason = param.reason, "cancelled"); + let _response_result = responder.send(Err(ServiceError::Cancelled { + reason: param.reason.clone(), + })); + } + } + Event::ResponseSendTaskResult(result) => { + if let Err(error) = result { + tracing::error!(%error, "response send task failed"); + } + } + // response and error + Event::ToSink(m) => { + if let Some(id) = match &m { + JsonRpcMessage::Response(response) => Some(&response.id), + JsonRpcMessage::Error(error) => error.id.as_ref(), + _ => None, + } { + let Some(ct) = local_ct_pool.remove(id) else { + tracing::debug!(%id, "dropping response for cancelled request"); + continue; + }; + ct.cancel(); + let send = transport.send(m); + let current_span = tracing::Span::current(); + response_send_tasks.spawn(async move { + let send_result = send.await; + if let Err(error) = send_result { + tracing::error!(%error, "fail to response message"); + } + }.instrument(current_span)); + } + } + Event::ProxyMessage(PeerSinkMessage::Request { + request, + id, + responder, + }) => { + local_responder_pool.insert(id.clone(), responder); + let send = transport.send(JsonRpcMessage::request(request, id.clone())); + { + let id = id.clone(); + let current_span = tracing::Span::current(); + send_task_set.spawn(send.map(move |r| SendTaskResult::Request { + id, + result: r.map_err(DynamicTransportError::new::), + }).instrument(current_span)); + } + } + Event::ProxyMessage(PeerSinkMessage::Notification { + notification, + responder, + }) => { + // catch cancellation notification + let mut cancellation_param = None; + let notification = match notification.try_into() { + Ok::(cancelled) => { + cancellation_param.replace(cancelled.params.clone()); + cancelled.into() + } + Err(notification) => notification, + }; + let send = transport.send(JsonRpcMessage::notification(notification)); + let current_span = tracing::Span::current(); + send_task_set.spawn(send.map(move |result| SendTaskResult::Notification { + responder, + cancellation_param, + result: result.map_err(DynamicTransportError::new::), + }).instrument(current_span)); + } + Event::PeerMessage(JsonRpcMessage::Request(JsonRpcRequest { + id, + mut request, + .. + })) => { + tracing::debug!(%id, ?request, "received request"); + if let Err(error) = R::enforce_peer_request_association( + &request, + peer.peer_info().as_deref(), + peer_request_association(&request, &local_responder_pool), + ) { + tracing::warn!(%id, message = %error.message, "rejected peer request"); + // send directly: the sink proxy path would drop the + // error since the request was never registered in + // local_ct_pool + let send = transport.send(JsonRpcMessage::error(error, Some(id))); + let current_span = tracing::Span::current(); + response_send_tasks.spawn(async move { + if let Err(error) = send.await { + tracing::error!(%error, "fail to send rejection error"); + } + }.instrument(current_span)); + continue; + } + { + let service = shared_service.clone(); + let sink = sink_proxy_tx.clone(); + let request_ct = serve_loop_ct.child_token(); + let context_ct = request_ct.child_token(); + local_ct_pool.insert(id.clone(), request_ct); + let mut extensions = Extensions::new(); + let mut meta = RequestMetaObject::new(); + // avoid clone + // swap meta firstly, otherwise progress token will be lost + std::mem::swap(&mut meta, request.get_meta_mut()); + std::mem::swap(&mut extensions, request.extensions_mut()); + let context = RequestContext { + ct: context_ct, + id: id.clone(), + peer: peer.clone(), + meta, + extensions, + }; + let current_span = tracing::Span::current(); + let handler_id = id.clone(); + spawn_service_task(async move { + let result = ORIGINATING_REQUEST + .scope(handler_id, service.handle_request(request, context)) + .await; + let response = match result { + Ok(result) => { + tracing::debug!(%id, ?result, "response message"); + JsonRpcMessage::response(result, id) + } + Err(error) => { + tracing::warn!(%id, ?error, "response error"); + JsonRpcMessage::error(error, Some(id)) + } + }; + let _send_result = sink.send(response).await; + }.instrument(current_span)); + } + } + Event::PeerMessage(JsonRpcMessage::Notification(JsonRpcNotification { + notification, + .. + })) => { + tracing::info!(?notification, "received notification"); + R::invalidate_response_cache(&peer, ¬ification).await; + let cancellation_request_id = + if let Some(cancelled) = R::peer_cancelled_params(¬ification) { + let request_id = cancelled.request_id.clone(); + if let Some(request_id) = request_id.as_ref() { + if R::IS_CLIENT { + if let Some(responder) = + local_responder_pool.remove(request_id) + { + let _ = responder.send(Err(ServiceError::Cancelled { + reason: cancelled.reason.clone(), + })); + } + } else if let Some(ct) = local_ct_pool.remove(request_id) { + tracing::info!(id = %request_id, reason = cancelled.reason, "cancelled"); + ct.cancel(); + } + } + request_id + } else { + None + }; + let subscription_id = notification + .get_meta() + .subscription_id() + .or(cancellation_request_id); + if let Some(subscription_id) = subscription_id + && let Some((sender, capacity)) = + peer.subscription_sender(&subscription_id) + { + match sender.try_send(notification) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + tracing::warn!( + id = %subscription_id, + capacity, + "subscription notification buffer full" + ); + if R::IS_CLIENT + && let Some(responder) = + local_responder_pool.remove(&subscription_id) + { + let _ = responder + .send(Err(ServiceError::SubscriptionLagged { capacity })); + } + peer.unregister_subscription(&subscription_id); + peer.try_cancel_request( + subscription_id, + Some("subscription notification buffer full".to_owned()), + ); + } + Err(mpsc::error::TrySendError::Closed(_)) => { + peer.unregister_subscription(&subscription_id); + peer.try_cancel_request( + subscription_id, + Some("subscription notification receiver closed".to_owned()), + ); + } + } + continue; + } + let mut notification = notification; + if let Some(progress_token) = notification.progress_token() { + peer.notify_progress_timeout_watcher(progress_token).await; + } + { + let service = shared_service.clone(); + let mut extensions = Extensions::new(); + let mut meta = NotificationMetaObject::new(); + // avoid clone + std::mem::swap(&mut extensions, notification.extensions_mut()); + std::mem::swap(&mut meta, notification.get_meta_mut()); + let context = NotificationContext { + peer: peer.clone(), + meta, + extensions, + }; + let current_span = tracing::Span::current(); + spawn_service_task(async move { + let result = service.handle_notification(notification, context).await; + if let Err(error) = result { + tracing::warn!(%error, "Error sending notification"); + } + }.instrument(current_span)); + } + } + Event::PeerMessage(JsonRpcMessage::Response(JsonRpcResponse { + result, + id, + .. + })) => { + if let Some(responder) = + remove_pending_request(&mut local_responder_pool, &id) + { + let response_result = responder.send(Ok(result)); + if let Err(_error) = response_result { + tracing::warn!(%id, "Error sending response"); + } + } + } + Event::PeerMessage(JsonRpcMessage::Error(JsonRpcError { error, id, .. })) => { + let Some(id) = id else { + // MCP error responses without an id (e.g. Parse error / Invalid Request) + // can't be routed back to a pending request — log and drop. + tracing::debug!(?error, "received id-less peer error"); + continue; + }; + if let Some(responder) = + remove_pending_request(&mut local_responder_pool, &id) + { + let service_error = if error.is_transport_closed() { + ServiceError::TransportClosed + } else { + ServiceError::McpError(error) + }; + let _response_result = responder.send(Err(service_error)); + if let Err(_error) = _response_result { + tracing::warn!(%id, "Error sending response"); + } + } + } + } + }; + + // Drain in-flight handler responses before closing the transport. + // When stdin EOF or cancellation arrives, spawned handler tasks may still + // be finishing. We need to: + // 1. Wait for response sends that were already spawned in the main loop + // 2. Drain any remaining handler responses from the channel + let drain_timeout = match &quit_reason { + QuitReason::Closed => Some(Duration::from_secs(5)), + QuitReason::Cancelled => Some(Duration::from_secs(2)), + _ => None, + }; + if let Some(timeout_duration) = drain_timeout { + // Drop our sender so the channel closes once all handler task + // clones finish sending their responses (or are dropped). + drop(sink_proxy_tx); + let drain_result = tokio::time::timeout(timeout_duration, async { + // First, wait for any response sends already dispatched by the + // main loop (these hold transport write futures). + while let Some(result) = response_send_tasks.join_next().await { + if let Err(error) = result { + tracing::error!(%error, "response send task failed during drain"); + } + } + // Then drain any handler responses still in the channel + // (handlers that finished after the loop broke). + while let Some(m) = sink_proxy_rx.recv().await { + if let Err(error) = transport.send(m).await { + tracing::error!(%error, "failed to send pending response during drain"); + break; + } + } + }) + .await; + if drain_result.is_err() { + tracing::warn!("timed out draining in-flight responses"); + } + } + + let sink_close_result = transport.close().await; + if let Err(e) = sink_close_result { + tracing::error!(%e, "fail to close sink"); + } + tracing::info!(?quit_reason, "serve finished"); + quit_reason + }.instrument(current_span)); + RunningService { + service, + peer: peer_return, + handle: Some(handle), + cancellation_token: ct.clone(), + dg: ct.drop_guard(), + } +} + +#[cfg(all(test, feature = "server"))] +mod sep2260_marker_tests { + use std::sync::Arc; + + use super::*; + use crate::model::{PingRequest, RequestId, ServerRequest}; + + fn ping() -> ServerRequest { + ServerRequest::PingRequest(PingRequest { + method: Default::default(), + extensions: Default::default(), + }) + } + + async fn send_and_capture(scope: Option) -> ::Req { + // peer_info None keeps enforcement non-strict; only the sink message matters. + let (peer, mut rx) = + Peer::::new(Arc::new(AtomicU32RequestIdProvider::default()), None); + let send = peer.send_request_with_option(ping(), PeerRequestOptions::no_options()); + let _handle = match scope { + Some(id) => ORIGINATING_REQUEST.scope(id, send).await.unwrap(), + None => send.await.unwrap(), + }; + let PeerSinkMessage::Request { request, .. } = rx.recv().await.expect("sink message") + else { + panic!("expected a request sink message"); + }; + request + } + + #[tokio::test] + async fn outbound_request_carries_originating_id_when_in_scope() { + let request = send_and_capture(Some(RequestId::Number(7))).await; + let marker = request + .extensions() + .get::() + .expect("marker attached"); + assert_eq!(marker.0, RequestId::Number(7)); + } + + #[tokio::test] + async fn outbound_request_has_no_marker_outside_scope() { + let request = send_and_capture(None).await; + assert!(request.extensions().get::().is_none()); + } + + #[test] + #[expect( + deprecated, + reason = "Sampling is deprecated by SEP-2577 but remains the canonical restricted request" + )] + fn peer_request_association_maps_stream_origin() { + use std::collections::HashMap; + + use crate::model::{ + CreateMessageRequest, CreateMessageRequestParams, SamplingMessage, ServerRequest, + }; + + fn sampling(origin: Option) -> ServerRequest { + let mut request = CreateMessageRequest::new(CreateMessageRequestParams::new( + vec![SamplingMessage::user_text("hi")], + 16, + )); + if let Some(origin) = origin { + request.extensions.insert(origin); + } + ServerRequest::CreateMessageRequest(request) + } + + let empty: HashMap = HashMap::new(); + let in_flight: HashMap = HashMap::from([(RequestId::Number(7), ())]); + + // No marker (stdio): coarse signal. + assert_eq!( + peer_request_association(&sampling(None), &in_flight), + PeerRequestAssociation::Unknown { + has_pending_outbound_request: true + } + ); + assert_eq!( + peer_request_association(&sampling(None), &empty), + PeerRequestAssociation::Unknown { + has_pending_outbound_request: false + } + ); + // Standalone GET stream: unassociated even with requests in flight. + assert_eq!( + peer_request_association( + &sampling(Some(InboundStreamOrigin::Unassociated)), + &in_flight + ), + PeerRequestAssociation::Unassociated + ); + // Originating POST stream of an in-flight request: associated. + assert_eq!( + peer_request_association( + &sampling(Some(InboundStreamOrigin::OutboundRequest( + RequestId::Number(7) + ))), + &in_flight + ), + PeerRequestAssociation::Associated + ); + // Stream of a request that is no longer in flight: unassociated. + assert_eq!( + peer_request_association( + &sampling(Some(InboundStreamOrigin::OutboundRequest( + RequestId::Number(8) + ))), + &in_flight + ), + PeerRequestAssociation::Unassociated + ); + } +} diff --git a/vendor/rmcp/src/service/client.rs b/vendor/rmcp/src/service/client.rs new file mode 100644 index 000000000..2e25b13a3 --- /dev/null +++ b/vendor/rmcp/src/service/client.rs @@ -0,0 +1,2302 @@ +// Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected. +#![expect(deprecated)] +pub(super) mod cache; + +use std::{borrow::Cow, num::NonZeroUsize, sync::Arc, time::Duration}; + +use cache::CacheGeneration; +pub use cache::{ClientCacheConfig, MAX_CLIENT_CACHE_TTL}; +use thiserror::Error; + +use super::*; +use crate::{ + model::{ + ArgumentInfo, CacheScope, CallToolRequest, CallToolRequestParams, CallToolResponse, + CallToolResult, CancelTaskParams, CancelTaskRequest, CancelledNotification, + CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, ClientNotification, + ClientRequest, ClientResult, CompleteRequest, CompleteRequestParams, CompleteResult, + CompletionContext, CompletionInfo, DEFAULT_MRTR_MAX_ROUNDS, DiscoverRequest, + DiscoverRequestParams, DiscoverResult, ErrorData, GetExtensions, GetMeta, GetPromptRequest, + GetPromptRequestParams, GetPromptResponse, GetPromptResult, GetTaskParams, GetTaskRequest, + GetTaskResult, InitializeRequest, InitializedNotification, InputRequest, + InputRequiredResult, InputResponses, JsonRpcResponse, ListPromptsRequest, + ListPromptsResult, ListResourceTemplatesRequest, ListResourceTemplatesResult, + ListResourcesRequest, ListResourcesResult, ListToolsRequest, ListToolsResult, + NumberOrString, PaginatedRequestParams, ProgressNotification, ProgressNotificationParam, + ProtocolVersion, ReadResourceRequest, ReadResourceRequestParams, ReadResourceResponse, + ReadResourceResult, Reference, RequestId, RequestMetaObject, RootsListChangedNotification, + ServerJsonRpcMessage, ServerNotification, ServerPeerInfo, ServerRequest, ServerResult, + SetLevelRequest, SetLevelRequestParams, SubscribeRequest, SubscribeRequestParams, + SubscriptionFilter, SubscriptionsListenRequest, SubscriptionsListenRequestParams, + SubscriptionsListenResult, UnsubscribeRequest, UnsubscribeRequestParams, UpdateTaskParams, + UpdateTaskRequest, + }, + transport::DynamicTransportError, +}; + +/// It represents the error that may occur when serving the client. +/// +/// if you want to handle the error, you can use `serve_client_with_ct` or `serve_client` with `Result, ClientError>` +#[derive(Error, Debug)] +#[non_exhaustive] +pub enum ClientInitializeError { + #[error("expect initialized response, but received: {0:?}")] + ExpectedInitResponse(Option), + + #[error("expect initialized result, but received: {0:?}")] + ExpectedInitResult(Option), + + #[error("conflict initialized response id: expected {0}, got {1}")] + ConflictInitResponseId(RequestId, RequestId), + + #[error("connection closed: {0}")] + ConnectionClosed(String), + + #[error("Send message error {error}, when {context}")] + TransportError { + error: DynamicTransportError, + context: Cow<'static, str>, + }, + + #[error("JSON-RPC error: {0}")] + JsonRpcError(ErrorData), + + #[error( + "no compatible protocol version (client: {client_supported:?}, server: {server_supported:?})" + )] + NoCompatibleProtocolVersion { + client_supported: Vec, + server_supported: Vec, + }, + + #[error("discover startup requires at least one preferred protocol version")] + NoPreferredProtocolVersion, + + #[error("Cancelled")] + Cancelled, +} + +impl ClientInitializeError { + pub fn transport + 'static>( + error: T::Error, + context: impl Into>, + ) -> Self { + Self::TransportError { + error: DynamicTransportError::new::(error), + context: context.into(), + } + } + + /// The `WWW-Authenticate` challenge from the 401/403 the transport hit + /// during initialization, if that is why initialization failed. + /// + /// This is the trigger of the reactive OAuth flow: feed the challenge to + /// `AuthorizationRequest::with_challenge` to authorize, then reconnect. + #[cfg(feature = "transport-streamable-http-client")] + pub fn auth_challenge(&self) -> Option<&str> { + use crate::transport::streamable_http_client::{AuthRequiredError, InsufficientScopeError}; + + let Self::TransportError { error, .. } = self else { + return None; + }; + let mut source: Option<&(dyn std::error::Error + 'static)> = Some(error.error.as_ref()); + while let Some(current) = source { + if let Some(auth_required) = current.downcast_ref::() { + return Some(&auth_required.www_authenticate_header); + } + if let Some(insufficient_scope) = current.downcast_ref::() { + return Some(&insufficient_scope.www_authenticate_header); + } + source = current.source(); + } + None + } + + /// Returns whether client initialization failed because authorization is required. + /// + /// This covers both missing or expired local OAuth authorization and an HTTP + /// authorization challenge from the MCP server. + pub fn is_authorization_required(&self) -> bool { + matches!( + self, + Self::TransportError { error, .. } if error.is_authorization_required() + ) + } +} + +/// Helper function to get the next message from the stream +async fn expect_next_message( + transport: &mut T, + context: &str, +) -> Result +where + T: Transport, +{ + transport + .receive() + .await + .ok_or_else(|| ClientInitializeError::ConnectionClosed(context.to_string())) +} + +/// Helper function to expect a response from the stream +async fn expect_response( + transport: &mut T, + context: &str, + service: &S, + peer: Peer, +) -> Result<(ServerResult, RequestId), ClientInitializeError> +where + T: Transport, + S: Service, +{ + loop { + let message = expect_next_message(transport, context).await?; + match message { + // Expected message to complete the initialization + ServerJsonRpcMessage::Response(JsonRpcResponse { id, result, .. }) => { + break Ok((result, id)); + } + // Handle JSON-RPC error responses + ServerJsonRpcMessage::Error(error) => { + break Err(ClientInitializeError::JsonRpcError(error.error)); + } + // Server could send logging messages before handshake + ServerJsonRpcMessage::Notification(mut notification) => { + let ServerNotification::LoggingMessageNotification(logging) = + &mut notification.notification + else { + tracing::warn!(?notification, "Received unexpected message"); + continue; + }; + + let mut context = NotificationContext { + peer: peer.clone(), + meta: NotificationMetaObject::default(), + extensions: Extensions::default(), + }; + + if let Some(meta) = logging.extensions.get_mut::() { + std::mem::swap(&mut context.meta, meta); + } + std::mem::swap(&mut context.extensions, &mut logging.extensions); + + if let Err(error) = service + .handle_notification(notification.notification, context) + .await + { + tracing::warn!(?error, "Handle logging before handshake failed."); + } + } + // Server could send pings before handshake + ServerJsonRpcMessage::Request(ref request) + if matches!(request.request, ServerRequest::PingRequest(_)) => + { + tracing::trace!("Received ping request. Ignored.") + } + // Server SHOULD NOT send any other messages before handshake. We ignore them anyway + _ => tracing::warn!(?message, "Received unexpected message"), + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct RoleClient; + +/// Select the first client-preferred protocol version supported by the server. +/// +/// Returns `None` when no version is shared. +pub fn select_protocol_version( + client_preference: &[ProtocolVersion], + server_supported: &[ProtocolVersion], +) -> Option { + client_preference + .iter() + .find(|version| server_supported.contains(version)) + .cloned() +} + +impl ServiceRole for RoleClient { + type Req = ClientRequest; + type Resp = ClientResult; + type Not = ClientNotification; + type PeerReq = ServerRequest; + type PeerResp = ServerResult; + type PeerNot = ServerNotification; + type Info = ClientInfo; + type PeerInfo = ServerPeerInfo; + type InitializeError = ClientInitializeError; + const IS_CLIENT: bool = true; + + fn configure_direct_peer(peer: &Peer, info: &Self::Info) { + let Some(server_info) = peer.peer_info() else { + return; + }; + if server_info.protocol_version.as_str() < ProtocolVersion::V_2026_07_28.as_str() { + return; + } + peer.set_client_request_metadata(ClientRequestMetadata { + protocol_version: server_info.protocol_version.clone(), + client_info: info.client_info.clone(), + client_capabilities: info.capabilities.clone(), + }); + } + + fn peer_cancelled_params(notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> { + match notification { + ServerNotification::CancelledNotification(notification) => Some(¬ification.params), + _ => None, + } + } + + // SEP-2260: reject restricted server requests that arrived unassociated + // with any in-flight outbound request. Without stream separation + // (`Unknown`) the coarse in-flight check under-approximates the SHOULD. + fn enforce_peer_request_association( + peer_request: &Self::PeerReq, + peer_info: Option<&Self::PeerInfo>, + association: PeerRequestAssociation, + ) -> Result<(), ErrorData> { + let restricted = matches!( + peer_request, + ServerRequest::CreateMessageRequest(_) + | ServerRequest::ListRootsRequest(_) + | ServerRequest::ElicitRequest(_) + ); + if !restricted { + return Ok(()); + } + let strict = + peer_info.is_some_and(|info| info.protocol_version >= ProtocolVersion::V_2026_07_28); + if !strict { + return Ok(()); + } + let associated = match association { + PeerRequestAssociation::Associated => true, + PeerRequestAssociation::Unassociated => false, + PeerRequestAssociation::Unknown { + has_pending_outbound_request, + } => has_pending_outbound_request, + }; + if associated { + Ok(()) + } else { + Err(ErrorData::invalid_params( + "SEP-2260: server-to-client requests must be associated with an in-flight client request", + None, + )) + } + } + + async fn invalidate_response_cache(peer: &Peer, notification: &Self::PeerNot) { + match notification { + ServerNotification::ResourceUpdatedNotification(notification) => { + peer.invalidate_resource_read_cache(¬ification.params.uri) + .await; + } + ServerNotification::ResourceListChangedNotification(_) => { + peer.invalidate_resource_list_cache().await; + } + ServerNotification::ToolListChangedNotification(_) => { + peer.invalidate_tool_cache().await; + } + ServerNotification::PromptListChangedNotification(_) => { + peer.invalidate_prompt_cache().await; + } + _ => {} + } + } +} + +pub type ServerSink = Peer; + +/// Default number of notifications buffered for one subscription. +pub const DEFAULT_SUBSCRIPTION_CHANNEL_CAPACITY: usize = 64; + +/// How a client-side subscription stream ended. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum SubscriptionEnd { + /// The server returned a final `SubscriptionsListenResult`. + Graceful(SubscriptionsListenResult), + /// The transport closed without a final result. Call `Peer::listen` again + /// after reconnecting; subscription streams are not resumable. + Abrupt, + /// The subscription was explicitly cancelled by either peer. + Cancelled, + /// The consumer did not drain notifications before the channel filled. + Lagged { capacity: usize }, +} + +/// Handle for one active `subscriptions/listen` request. +#[derive(Debug)] +pub struct Subscription { + id: RequestId, + acknowledged: SubscriptionFilter, + notifications: tokio::sync::mpsc::Receiver, + request: Option>, + end: Option, +} + +type SubscriptionResponse = + Result, tokio::sync::oneshot::error::RecvError>; + +struct PendingSubscriptionRequest { + handle: Option>, +} + +impl PendingSubscriptionRequest { + fn new(handle: RequestHandle) -> Self { + Self { + handle: Some(handle), + } + } + + async fn recv(&mut self) -> Option { + let handle = self.handle.as_mut()?; + Some((&mut handle.rx).await) + } + + fn take(&mut self) -> Option> { + self.handle.take() + } + + fn unregister(&self, id: &RequestId) { + if let Some(handle) = self.handle.as_ref() { + handle.peer.unregister_subscription(id); + } + } + + fn disarm(&mut self) { + self.handle.take(); + } + + async fn cancel(&mut self, reason: &'static str) { + if let Some(handle) = self.handle.take() { + let _ = handle.cancel(Some(reason.to_owned())).await; + } + } +} + +impl Drop for PendingSubscriptionRequest { + fn drop(&mut self) { + let Some(handle) = self.handle.take() else { + return; + }; + handle.peer.unregister_subscription(&handle.id); + handle.peer.try_cancel_request( + handle.id, + Some("subscription establishment cancelled".to_owned()), + ); + } +} + +impl Subscription { + /// Return the originating listen request ID. + pub fn id(&self) -> &RequestId { + &self.id + } + + /// Return the notification filter accepted by the server. + pub fn acknowledged(&self) -> &SubscriptionFilter { + &self.acknowledged + } + + /// Return the terminal state after this subscription has ended. + pub fn end(&self) -> Option<&SubscriptionEnd> { + self.end.as_ref() + } + + /// Receive the next notification, or `None` after the subscription ends. + /// + /// A graceful final result and an abrupt transport close are distinguished + /// through [`Self::end`]. + /// + /// # Errors + /// + /// Returns a service or protocol error when the stream carries an invalid + /// message, an unexpected final result, or another request failure. + pub async fn next(&mut self) -> Result, ServiceError> { + if self.end.is_some() { + return Ok(None); + } + let Some(request) = self.request.as_mut() else { + self.end = Some(SubscriptionEnd::Abrupt); + return Ok(None); + }; + + tokio::select! { + biased; + notification = self.notifications.recv() => { + let Some(notification) = notification else { + let response = (&mut request.rx).await; + return self.handle_response(response); + }; + if let ServerNotification::CancelledNotification(cancelled) = ¬ification { + if cancelled.params.request_id.as_ref() != Some(&self.id) { + self.cancel_as_abrupt("subscription cancellation ID mismatch") + .await; + return Err(ServiceError::UnexpectedResponse); + } + self.finish(SubscriptionEnd::Cancelled); + return Ok(None); + } + if notification.get_meta().subscription_id().as_ref() != Some(&self.id) { + self.cancel_as_abrupt("subscription notification ID mismatch") + .await; + return Err(ServiceError::UnexpectedResponse); + } + if !self.accepts(¬ification) { + self.cancel_as_abrupt( + "subscription notification was outside the acknowledged filter", + ) + .await; + return Err(ServiceError::UnexpectedResponse); + } + Ok(Some(notification)) + } + response = &mut request.rx => { + self.handle_response(response) + } + } + } + + /// Cancel this subscription. + /// + /// # Errors + /// + /// Returns a transport error when the cancellation signal cannot be sent. + pub async fn cancel(&mut self) -> Result<(), ServiceError> { + self.cancel_with_reason(None).await + } + + /// Cancel this subscription with a diagnostic reason. + /// + /// # Errors + /// + /// Returns a transport error when the cancellation signal cannot be sent. + pub async fn cancel_with_reason(&mut self, reason: Option) -> Result<(), ServiceError> { + let Some(request) = self.request.take() else { + return Ok(()); + }; + request.cancel(reason).await?; + self.end = Some(SubscriptionEnd::Cancelled); + Ok(()) + } + + fn finish(&mut self, end: SubscriptionEnd) { + if let Some(request) = self.request.take() { + request.peer.unregister_subscription(&self.id); + } + self.end = Some(end); + } + + async fn cancel_as_abrupt(&mut self, reason: &'static str) { + if let Some(request) = self.request.take() { + let _ = request.cancel(Some(reason.to_owned())).await; + } + self.end = Some(SubscriptionEnd::Abrupt); + } + + fn accepts(&self, notification: &ServerNotification) -> bool { + match notification { + ServerNotification::ToolListChangedNotification(_) => { + self.acknowledged.tools_list_changed == Some(true) + } + ServerNotification::PromptListChangedNotification(_) => { + self.acknowledged.prompts_list_changed == Some(true) + } + ServerNotification::ResourceListChangedNotification(_) => { + self.acknowledged.resources_list_changed == Some(true) + } + ServerNotification::ResourceUpdatedNotification(update) => self + .acknowledged + .resource_subscriptions + .as_ref() + .is_some_and(|uris| uris.contains(&update.params.uri)), + ServerNotification::SubscriptionsAcknowledgedNotification(_) + | ServerNotification::CancelledNotification(_) + | ServerNotification::ProgressNotification(_) + | ServerNotification::LoggingMessageNotification(_) + | ServerNotification::TaskStatusNotification(_) + | ServerNotification::CustomNotification(_) => false, + } + } + + fn handle_response( + &mut self, + response: SubscriptionResponse, + ) -> Result, ServiceError> { + let response = match response { + Ok(response) => response, + Err(_) => { + self.finish(SubscriptionEnd::Abrupt); + return Ok(None); + } + }; + let response = match response { + Ok(response) => response, + Err(ServiceError::TransportClosed) => { + self.finish(SubscriptionEnd::Abrupt); + return Ok(None); + } + Err(ServiceError::SubscriptionLagged { capacity }) => { + self.finish(SubscriptionEnd::Lagged { capacity }); + return Ok(None); + } + Err(error) => { + self.finish(SubscriptionEnd::Abrupt); + return Err(error); + } + }; + let ServerResult::SubscriptionsListenResult(result) = response else { + self.finish(SubscriptionEnd::Abrupt); + return Err(ServiceError::UnexpectedResponse); + }; + if !result.result_type.is_complete() + || result.meta.subscription_id().as_ref() != Some(&self.id) + { + self.finish(SubscriptionEnd::Abrupt); + return Err(ServiceError::UnexpectedResponse); + } + self.finish(SubscriptionEnd::Graceful(result)); + Ok(None) + } +} + +impl Drop for Subscription { + fn drop(&mut self) { + let Some(request) = self.request.take() else { + return; + }; + request.peer.unregister_subscription(&self.id); + request.peer.try_cancel_request( + self.id.clone(), + Some("subscription handle dropped".to_owned()), + ); + } +} + +/// Selects how a client establishes its MCP lifecycle. +/// +/// Existing [`ServiceExt::serve`] behavior remains legacy initialization. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum ClientLifecycleMode { + /// Use the legacy `initialize` / `notifications/initialized` handshake. + Initialize, + /// Use `server/discover` and send self-contained per-request metadata. + Discover { + preferred_versions: Vec, + }, + /// Probe with `server/discover`, falling back only when the peer proves it is legacy. + Auto { + preferred_versions: Vec, + legacy_version: Option, + }, +} + +/// Client-specific lifecycle entry points. +pub trait ClientServiceExt: Service + Sized { + fn serve_with_lifecycle( + self, + transport: T, + lifecycle: ClientLifecycleMode, + ) -> impl Future, ClientInitializeError>> + + MaybeSendFuture + where + T: IntoTransport, + E: std::error::Error + Send + Sync + 'static, + { + serve_client_with_lifecycle(self, transport, lifecycle) + } +} + +impl> ClientServiceExt for S {} + +impl> ServiceExt for S { + fn serve_with_ct( + self, + transport: T, + ct: CancellationToken, + ) -> impl Future, ClientInitializeError>> + + MaybeSendFuture + where + T: IntoTransport, + E: std::error::Error + Send + Sync + 'static, + Self: Sized, + { + serve_client_with_ct(self, transport, ct) + } +} + +pub async fn serve_client( + service: S, + transport: T, +) -> Result, ClientInitializeError> +where + S: Service, + T: IntoTransport, + E: std::error::Error + Send + Sync + 'static, +{ + serve_client_with_lifecycle_and_ct( + service, + transport, + ClientLifecycleMode::Initialize, + Default::default(), + ) + .await +} + +pub async fn serve_client_with_ct( + service: S, + transport: T, + ct: CancellationToken, +) -> Result, ClientInitializeError> +where + S: Service, + T: IntoTransport, + E: std::error::Error + Send + Sync + 'static, +{ + serve_client_with_lifecycle_and_ct(service, transport, ClientLifecycleMode::Initialize, ct) + .await +} + +pub async fn serve_client_with_lifecycle( + service: S, + transport: T, + lifecycle: ClientLifecycleMode, +) -> Result, ClientInitializeError> +where + S: Service, + T: IntoTransport, + E: std::error::Error + Send + Sync + 'static, +{ + serve_client_with_lifecycle_and_ct(service, transport, lifecycle, Default::default()).await +} + +pub async fn serve_client_with_lifecycle_and_ct( + service: S, + transport: T, + lifecycle: ClientLifecycleMode, + ct: CancellationToken, +) -> Result, ClientInitializeError> +where + S: Service, + T: IntoTransport, + E: std::error::Error + Send + Sync + 'static, +{ + tokio::select! { + result = serve_client_with_ct_inner(service, transport.into_transport(), lifecycle, ct.clone()) => { result } + _ = ct.cancelled() => { + Err(ClientInitializeError::Cancelled) + } + } +} + +async fn serve_client_with_ct_inner( + service: S, + transport: T, + lifecycle: ClientLifecycleMode, + ct: CancellationToken, +) -> Result, ClientInitializeError> +where + S: Service, + T: Transport + 'static, +{ + let mut transport = transport.into_transport(); + let id_provider = >::default(); + let (peer, peer_rx) = Peer::new(id_provider.clone(), None); + let client_info = service.get_info(); + + match lifecycle { + ClientLifecycleMode::Initialize => { + legacy_startup(&service, &mut transport, &id_provider, &peer, client_info).await?; + } + ClientLifecycleMode::Discover { preferred_versions } => { + discover_startup( + &service, + &mut transport, + &id_provider, + &peer, + &client_info, + preferred_versions, + ) + .await?; + } + ClientLifecycleMode::Auto { + preferred_versions, + legacy_version, + } => { + let discover_result = discover_startup( + &service, + &mut transport, + &id_provider, + &peer, + &client_info, + preferred_versions, + ) + .await; + match discover_result { + Ok(()) => {} + Err(ClientInitializeError::JsonRpcError(error)) + if error.code == crate::model::ErrorCode::METHOD_NOT_FOUND => + { + let mut legacy_info = client_info; + if let Some(version) = legacy_version { + legacy_info.protocol_version = version; + } + legacy_startup(&service, &mut transport, &id_provider, &peer, legacy_info) + .await?; + } + Err(error) => return Err(error), + } + } + } + Ok(serve_inner(service, transport, peer, peer_rx, ct)) +} + +async fn legacy_startup( + service: &S, + transport: &mut T, + id_provider: &Arc, + peer: &Peer, + client_info: ClientInfo, +) -> Result<(), ClientInitializeError> +where + S: Service, + T: Transport + 'static, +{ + let id = id_provider.next_request_id(); + let init_request = InitializeRequest { + method: Default::default(), + params: client_info, + extensions: Default::default(), + }; + transport + .send(ClientJsonRpcMessage::request( + ClientRequest::InitializeRequest(init_request), + id.clone(), + )) + .await + .map_err(|error| ClientInitializeError::TransportError { + error: DynamicTransportError::new::(error), + context: "send initialize request".into(), + })?; + + let (response, response_id) = + expect_response(transport, "initialize response", service, peer.clone()).await?; + + if !id.matches_response_id(&response_id) { + return Err(ClientInitializeError::ConflictInitResponseId( + id, + response_id, + )); + } + + let ServerResult::InitializeResult(initialize_result) = response else { + return Err(ClientInitializeError::ExpectedInitResult(Some(response))); + }; + peer.set_peer_info(initialize_result.into()); + + // send notification + let notification = ClientJsonRpcMessage::notification( + ClientNotification::InitializedNotification(InitializedNotification { + method: Default::default(), + extensions: Default::default(), + }), + ); + transport.send(notification).await.map_err(|error| { + ClientInitializeError::transport::(error, "send initialized notification") + })?; + Ok(()) +} + +async fn discover_startup( + service: &S, + transport: &mut T, + id_provider: &Arc, + peer: &Peer, + client_info: &ClientInfo, + preferred_versions: Vec, +) -> Result<(), ClientInitializeError> +where + S: Service, + T: Transport + 'static, +{ + if preferred_versions.is_empty() { + return Err(ClientInitializeError::NoPreferredProtocolVersion); + } + + let mut attempted = Vec::new(); + let mut candidate = preferred_versions[0].clone(); + loop { + attempted.push(candidate.clone()); + + let meta = RequestMetaObject::with_client_context( + candidate.clone(), + client_info.client_info.clone(), + client_info.capabilities.clone(), + ); + let mut discover = DiscoverRequest::new(DiscoverRequestParams {}); + discover.extensions.insert(meta); + let id = id_provider.next_request_id(); + transport + .send(ClientJsonRpcMessage::request( + ClientRequest::DiscoverRequest(discover), + id.clone(), + )) + .await + .map_err(|error| { + ClientInitializeError::transport::(error, "send discover request") + })?; + + match expect_response(transport, "discover response", service, peer.clone()).await { + Ok((ServerResult::DiscoverResult(result), response_id)) => { + if !id.matches_response_id(&response_id) { + return Err(ClientInitializeError::ConflictInitResponseId( + id, + response_id, + )); + } + let Some(selected) = + select_protocol_version(&preferred_versions, &result.supported_versions) + else { + return Err(ClientInitializeError::NoCompatibleProtocolVersion { + client_supported: preferred_versions, + server_supported: result.supported_versions, + }); + }; + peer.set_peer_info(ServerPeerInfo::from_discover_result( + selected.clone(), + result, + )); + peer.set_client_request_metadata(ClientRequestMetadata { + protocol_version: selected, + client_info: client_info.client_info.clone(), + client_capabilities: client_info.capabilities.clone(), + }); + return Ok(()); + } + Ok((response, _)) => { + return Err(ClientInitializeError::ExpectedInitResult(Some(response))); + } + Err(ClientInitializeError::JsonRpcError(error)) + if error.code == crate::model::ErrorCode::UNSUPPORTED_PROTOCOL_VERSION => + { + let supported = error + .data + .as_ref() + .and_then(|data| data.get("supported")) + .cloned() + .and_then(|value| serde_json::from_value::>(value).ok()) + .unwrap_or_default(); + let may_retry_current = attempted + .iter() + .filter(|version| *version == &candidate) + .count() + == 1; + let next = preferred_versions + .iter() + .find(|version| { + supported.contains(version) + && (!attempted.contains(version) + || (may_retry_current && *version == &candidate)) + }) + .cloned(); + let Some(next) = next else { + return Err(ClientInitializeError::NoCompatibleProtocolVersion { + client_supported: preferred_versions, + server_supported: supported, + }); + }; + candidate = next; + } + Err(error) => return Err(error), + } + } +} + +const DISCOVER_CACHE_PREFIX: &str = "server/discover:"; +const TOOL_LIST_CACHE_PREFIX: &str = "tools/list:"; +const PROMPT_LIST_CACHE_PREFIX: &str = "prompts/list:"; +const RESOURCE_LIST_CACHE_PREFIX: &str = "resources/list:"; +const RESOURCE_TEMPLATE_LIST_CACHE_PREFIX: &str = "resources/templates/list:"; +const RESOURCE_READ_CACHE_PREFIX: &str = "resources/read:"; + +// Cache keys are built only from the request method plus the parameters that +// affect the result (SEP-2549). Request `_meta` (progress tokens, trace +// context, etc.) does not affect the result, so it is deliberately excluded to +// avoid fragmenting the cache across otherwise-identical requests. +fn discover_cache_key() -> String { + // `server/discover` carries no result-affecting parameters. + DISCOVER_CACHE_PREFIX.to_string() +} + +fn list_response_cache_key(prefix: &str, params: &Option) -> String { + // Only the pagination cursor affects which page is returned. + let cursor = params.as_ref().and_then(|params| params.cursor.as_deref()); + let cursor = + serde_json::to_string(&cursor).expect("serializing a pagination cursor cannot fail"); + format!("{prefix}{cursor}") +} + +fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option { + // MRTR retries depend on inputs that are not part of the cache key and MUST + // NOT be cached. + if params.input_responses.is_some() || params.request_state.is_some() { + return None; + } + // Only the URI affects the result. + Some(resource_read_cache_prefix_for_uri(¶ms.uri)) +} + +fn resource_read_cache_prefix_for_uri(uri: &str) -> String { + let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail"); + format!("{RESOURCE_READ_CACHE_PREFIX}{uri}:") +} + +fn request_uses_cursor(params: &Option) -> bool { + params + .as_ref() + .and_then(|params| params.cursor.as_ref()) + .is_some() +} + +macro_rules! method { + ($(#[$meta:meta])* peer_req $method:ident $Req:ident() => $Resp: ident ) => { + $(#[$meta])* + pub async fn $method(&self) -> Result<$Resp, ServiceError> { + let result = self + .send_request(ClientRequest::$Req($Req { + method: Default::default(), + })) + .await?; + match result { + ServerResult::$Resp(result) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } + } + }; + ($(#[$meta:meta])* peer_req $method:ident $Req:ident($Param: ident) => $Resp: ident ) => { + $(#[$meta])* + pub async fn $method(&self, params: $Param) -> Result<$Resp, ServiceError> { + let result = self + .send_request(ClientRequest::$Req($Req { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await?; + match result { + ServerResult::$Resp(result) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } + } + }; + ($(#[$meta:meta])* peer_req $method:ident $Req:ident($Param: ident)? => $Resp: ident ) => { + $(#[$meta])* + pub async fn $method(&self, params: Option<$Param>) -> Result<$Resp, ServiceError> { + let result = self + .send_request(ClientRequest::$Req($Req { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await?; + match result { + ServerResult::$Resp(result) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } + } + }; + ($(#[$meta:meta])* peer_req $method:ident $Req:ident($Param: ident)) => { + $(#[$meta])* + pub async fn $method(&self, params: $Param) -> Result<(), ServiceError> { + let result = self + .send_request(ClientRequest::$Req($Req { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await?; + match result { + ServerResult::EmptyResult(_) => Ok(()), + _ => Err(ServiceError::UnexpectedResponse), + } + } + }; + + ($(#[$meta:meta])* peer_not $method:ident $Not:ident($Param: ident)) => { + $(#[$meta])* + pub async fn $method(&self, params: $Param) -> Result<(), ServiceError> { + self.send_notification(ClientNotification::$Not($Not { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await?; + Ok(()) + } + }; + ($(#[$meta:meta])* peer_not $method:ident $Not:ident) => { + $(#[$meta])* + pub async fn $method(&self) -> Result<(), ServiceError> { + self.send_notification(ClientNotification::$Not($Not { + method: Default::default(), + extensions: Default::default(), + })) + .await?; + Ok(()) + } + }; +} + +impl Peer { + /// Open a long-lived notification subscription and wait for its acknowledgment. + /// + /// Notifications routed to the returned [`Subscription`] are not also + /// delivered through [`ClientHandler`](crate::ClientHandler) callbacks. + /// + /// # Errors + /// + /// Returns a service, transport, or protocol error when the request cannot + /// be established or the acknowledgment is invalid. + pub async fn listen( + &self, + notifications: SubscriptionFilter, + ) -> Result { + self.listen_with_channel_capacity_inner( + notifications, + DEFAULT_SUBSCRIPTION_CHANNEL_CAPACITY, + ) + .await + } + + /// Open a subscription with an explicit notification buffer capacity. + /// + /// Notifications routed to the returned [`Subscription`] are not also + /// delivered through [`ClientHandler`](crate::ClientHandler) callbacks. + /// + /// # Errors + /// + /// Returns a service, transport, or protocol error when the request cannot + /// be established or the acknowledgment is invalid. + pub async fn listen_with_capacity( + &self, + notifications: SubscriptionFilter, + channel_capacity: NonZeroUsize, + ) -> Result { + self.listen_with_channel_capacity_inner(notifications, channel_capacity.get()) + .await + } + + async fn listen_with_channel_capacity_inner( + &self, + notifications: SubscriptionFilter, + channel_capacity: usize, + ) -> Result { + let request = ClientRequest::SubscriptionsListenRequest(SubscriptionsListenRequest::new( + SubscriptionsListenRequestParams::new(notifications.clone()), + )); + let (handle, mut subscription_notifications) = self + .send_subscription_request(request, PeerRequestOptions::no_options(), channel_capacity) + .await?; + let id = handle.id.clone(); + let mut pending = PendingSubscriptionRequest::new(handle); + + tokio::select! { + biased; + notification = subscription_notifications.recv() => { + let Some(notification) = notification else { + pending.cancel("subscription stream closed before acknowledgment").await; + return Err(ServiceError::TransportClosed); + }; + if notification.get_meta().subscription_id().as_ref() != Some(&id) { + pending.cancel("subscription acknowledgment ID mismatch").await; + return Err(ServiceError::UnexpectedResponse); + } + let ServerNotification::SubscriptionsAcknowledgedNotification( + acknowledgment, + ) = notification else { + pending.cancel("notification received before subscription acknowledgment").await; + return Err(ServiceError::UnexpectedResponse); + }; + let accepted = acknowledgment.params.notifications; + if !accepted.is_subset_of(¬ifications) { + pending.cancel("subscription acknowledged an unrequested filter").await; + return Err(ServiceError::UnexpectedResponse); + } + let Some(handle) = pending.take() else { + return Err(ServiceError::TransportClosed); + }; + Ok(Subscription { + id, + acknowledged: accepted, + notifications: subscription_notifications, + request: Some(handle), + end: None, + }) + } + response = pending.recv() => { + pending.unregister(&id); + pending.disarm(); + let Some(response) = response else { + return Err(ServiceError::TransportClosed); + }; + match response { + Ok(Err(error)) => Err(error), + Ok(Ok(_)) => Err(ServiceError::UnexpectedResponse), + Err(_) => Err(ServiceError::TransportClosed), + } + } + } + } + + /// Discover the server's supported protocol versions and capabilities. + /// + /// The high-level client currently exposes this peer only after initialization; + /// pre-initialization probing is planned as follow-up work. + pub async fn discover(&self, meta: RequestMetaObject) -> Result { + let cache_key = discover_cache_key(); + if let Some(ServerResult::DiscoverResult(result)) = self.cached_response(&cache_key).await { + return Ok(result); + } + let generation = self.capture_response_cache_generation().await; + let mut request = DiscoverRequest::new(DiscoverRequestParams {}); + request.extensions.insert(meta); + let result = self + .send_request(ClientRequest::DiscoverRequest(request)) + .await; + let result = match result { + Ok(result) => result, + Err(error) => { + if let Some(ServerResult::DiscoverResult(result)) = + self.stale_cached_response(&cache_key).await + { + return Ok(result); + } + return Err(error); + } + }; + match result { + ServerResult::DiscoverResult(result) => { + self.cache_result( + Some(cache_key), + Some(result.ttl_ms), + Some(result.cache_scope), + generation, + ServerResult::DiscoverResult(result.clone()), + ) + .await; + Ok(result) + } + _ => Err(ServiceError::UnexpectedResponse), + } + } + + async fn cache_result( + &self, + cache_key: Option, + ttl_ms: Option, + cache_scope: Option, + generation: CacheGeneration, + result: ServerResult, + ) { + let Some(cache_key) = cache_key else { + return; + }; + self.cache_response_with_generation(cache_key, result, ttl_ms, cache_scope, generation) + .await; + } + + pub(crate) async fn invalidate_tool_cache(&self) { + self.invalidate_cached_responses(TOOL_LIST_CACHE_PREFIX) + .await; + } + + pub(crate) async fn invalidate_prompt_cache(&self) { + self.invalidate_cached_responses(PROMPT_LIST_CACHE_PREFIX) + .await; + } + + pub(crate) async fn invalidate_resource_list_cache(&self) { + self.invalidate_cached_responses(RESOURCE_LIST_CACHE_PREFIX) + .await; + self.invalidate_cached_responses(RESOURCE_TEMPLATE_LIST_CACHE_PREFIX) + .await; + } + + pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) { + self.invalidate_cached_responses(&resource_read_cache_prefix_for_uri(uri)) + .await; + } + + /// Send one `tools/call` request and return either a final result or an MRTR + /// `InputRequiredResult` without driving any follow-up rounds. + pub async fn call_tool_once( + &self, + params: CallToolRequestParams, + ) -> Result { + let result = self + .send_request(ClientRequest::CallToolRequest(CallToolRequest { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await?; + match result { + ServerResult::CallToolResult(result) => Ok(CallToolResponse::Complete(result)), + ServerResult::InputRequiredResult(result) => { + Ok(CallToolResponse::InputRequired(result)) + } + // SEP-2663 Tasks extension: the server materialized a task. + ServerResult::CreateTaskResult(result) => Ok(CallToolResponse::Task(result)), + _ => Err(ServiceError::UnexpectedResponse), + } + } + + /// SEP-2663 `tasks/get`: poll the current state of a task. + pub async fn get_task(&self, params: GetTaskParams) -> Result { + let result = self + .send_request(ClientRequest::GetTaskRequest(GetTaskRequest::new(params))) + .await?; + match result { + ServerResult::GetTaskResult(result) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } + } + + /// SEP-2663 `tasks/update`: deliver responses to outstanding in-task + /// input requests. The acknowledgement is eventually consistent. + pub async fn update_task(&self, params: UpdateTaskParams) -> Result<(), ServiceError> { + let result = self + .send_request(ClientRequest::UpdateTaskRequest(UpdateTaskRequest::new( + params, + ))) + .await?; + match result { + ServerResult::TaskAckResult(_) | ServerResult::EmptyResult(_) => Ok(()), + _ => Err(ServiceError::UnexpectedResponse), + } + } + + /// SEP-2663 `tasks/cancel`: signal intent to cancel a task. Cancellation + /// is cooperative; the ack does not guarantee the task stops. + pub async fn cancel_task(&self, params: CancelTaskParams) -> Result<(), ServiceError> { + let result = self + .send_request(ClientRequest::CancelTaskRequest(CancelTaskRequest::new( + params, + ))) + .await?; + match result { + ServerResult::TaskAckResult(_) | ServerResult::EmptyResult(_) => Ok(()), + _ => Err(ServiceError::UnexpectedResponse), + } + } + + /// Send one `prompts/get` request and return either a final result or an MRTR + /// `InputRequiredResult` without driving any follow-up rounds. + pub async fn get_prompt_once( + &self, + params: GetPromptRequestParams, + ) -> Result { + let result = self + .send_request(ClientRequest::GetPromptRequest(GetPromptRequest { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await?; + match result { + ServerResult::GetPromptResult(result) => Ok(GetPromptResponse::Complete(result)), + ServerResult::InputRequiredResult(result) => { + Ok(GetPromptResponse::InputRequired(result)) + } + _ => Err(ServiceError::UnexpectedResponse), + } + } + + /// Send one `resources/read` request and return either a final result or an + /// MRTR `InputRequiredResult` without driving any follow-up rounds. + pub async fn read_resource_once( + &self, + params: ReadResourceRequestParams, + ) -> Result { + let cache_key = resource_read_cache_key(¶ms); + if let Some(key) = cache_key.as_deref() + && let Some(ServerResult::ReadResourceResult(result)) = self.cached_response(key).await + { + return Ok(ReadResourceResponse::Complete(result)); + } + + let generation = self.capture_response_cache_generation().await; + let result = self + .send_request(ClientRequest::ReadResourceRequest(ReadResourceRequest { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await; + let result = match result { + Ok(result) => result, + Err(error) => { + if let Some(key) = cache_key.as_deref() + && let Some(ServerResult::ReadResourceResult(result)) = + self.stale_cached_response(key).await + { + return Ok(ReadResourceResponse::Complete(result)); + } + return Err(error); + } + }; + match result { + ServerResult::ReadResourceResult(result) => { + self.cache_result( + cache_key, + result.ttl_ms, + result.cache_scope, + generation, + ServerResult::ReadResourceResult(result.clone()), + ) + .await; + Ok(ReadResourceResponse::Complete(result)) + } + ServerResult::InputRequiredResult(result) => { + Ok(ReadResourceResponse::InputRequired(result)) + } + _ => Err(ServiceError::UnexpectedResponse), + } + } + + method!(peer_req complete CompleteRequest(CompleteRequestParams) => CompleteResult); + method!( + #[deprecated( + since = "1.8.0", + note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" + )] + peer_req set_level SetLevelRequest(SetLevelRequestParams) + ); + method!(peer_req get_prompt GetPromptRequest(GetPromptRequestParams) => GetPromptResult); + method!( + #[deprecated( + note = "resources/subscribe is legacy-only; use Peer::listen for protocol version 2026-07-28" + )] + peer_req subscribe SubscribeRequest(SubscribeRequestParams) + ); + method!( + #[deprecated( + note = "resources/unsubscribe is legacy-only; cancel the Subscription handle instead" + )] + peer_req unsubscribe UnsubscribeRequest(UnsubscribeRequestParams) + ); + method!(peer_req call_tool CallToolRequest(CallToolRequestParams) => CallToolResult); + + pub async fn list_prompts( + &self, + params: Option, + ) -> Result { + let cache_key = list_response_cache_key(PROMPT_LIST_CACHE_PREFIX, ¶ms); + if let Some(ServerResult::ListPromptsResult(result)) = + self.cached_response(&cache_key).await + { + return Ok(result); + } + let generation = self.capture_response_cache_generation().await; + let uses_cursor = request_uses_cursor(¶ms); + let result = self + .send_request(ClientRequest::ListPromptsRequest(ListPromptsRequest { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await; + let result = match result { + Ok(result) => result, + Err(error) => { + if uses_cursor { + self.invalidate_prompt_cache().await; + return Err(error); + } + if let Some(ServerResult::ListPromptsResult(result)) = + self.stale_cached_response(&cache_key).await + { + return Ok(result); + } + return Err(error); + } + }; + match result { + ServerResult::ListPromptsResult(result) => { + self.cache_result( + Some(cache_key), + result.ttl_ms, + result.cache_scope, + generation, + ServerResult::ListPromptsResult(result.clone()), + ) + .await; + Ok(result) + } + _ => Err(ServiceError::UnexpectedResponse), + } + } + + pub async fn list_resources( + &self, + params: Option, + ) -> Result { + let cache_key = list_response_cache_key(RESOURCE_LIST_CACHE_PREFIX, ¶ms); + if let Some(ServerResult::ListResourcesResult(result)) = + self.cached_response(&cache_key).await + { + return Ok(result); + } + let generation = self.capture_response_cache_generation().await; + let uses_cursor = request_uses_cursor(¶ms); + let result = self + .send_request(ClientRequest::ListResourcesRequest(ListResourcesRequest { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await; + let result = match result { + Ok(result) => result, + Err(error) => { + if uses_cursor { + self.invalidate_cached_responses(RESOURCE_LIST_CACHE_PREFIX) + .await; + return Err(error); + } + if let Some(ServerResult::ListResourcesResult(result)) = + self.stale_cached_response(&cache_key).await + { + return Ok(result); + } + return Err(error); + } + }; + match result { + ServerResult::ListResourcesResult(result) => { + self.cache_result( + Some(cache_key), + result.ttl_ms, + result.cache_scope, + generation, + ServerResult::ListResourcesResult(result.clone()), + ) + .await; + Ok(result) + } + _ => Err(ServiceError::UnexpectedResponse), + } + } + + pub async fn list_resource_templates( + &self, + params: Option, + ) -> Result { + let cache_key = list_response_cache_key(RESOURCE_TEMPLATE_LIST_CACHE_PREFIX, ¶ms); + if let Some(ServerResult::ListResourceTemplatesResult(result)) = + self.cached_response(&cache_key).await + { + return Ok(result); + } + let generation = self.capture_response_cache_generation().await; + let uses_cursor = request_uses_cursor(¶ms); + let result = self + .send_request(ClientRequest::ListResourceTemplatesRequest( + ListResourceTemplatesRequest { + method: Default::default(), + params, + extensions: Default::default(), + }, + )) + .await; + let result = match result { + Ok(result) => result, + Err(error) => { + if uses_cursor { + self.invalidate_cached_responses(RESOURCE_TEMPLATE_LIST_CACHE_PREFIX) + .await; + return Err(error); + } + if let Some(ServerResult::ListResourceTemplatesResult(result)) = + self.stale_cached_response(&cache_key).await + { + return Ok(result); + } + return Err(error); + } + }; + match result { + ServerResult::ListResourceTemplatesResult(result) => { + self.cache_result( + Some(cache_key), + result.ttl_ms, + result.cache_scope, + generation, + ServerResult::ListResourceTemplatesResult(result.clone()), + ) + .await; + Ok(result) + } + _ => Err(ServiceError::UnexpectedResponse), + } + } + + pub async fn read_resource( + &self, + params: ReadResourceRequestParams, + ) -> Result { + match self.read_resource_once(params).await? { + ReadResourceResponse::Complete(result) => Ok(result), + ReadResourceResponse::InputRequired(_) => Err(ServiceError::UnexpectedResponse), + } + } + + pub async fn list_tools( + &self, + params: Option, + ) -> Result { + let cache_key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + if let Some(ServerResult::ListToolsResult(result)) = self.cached_response(&cache_key).await + { + return Ok(result); + } + let generation = self.capture_response_cache_generation().await; + let uses_cursor = request_uses_cursor(¶ms); + let result = self + .send_request(ClientRequest::ListToolsRequest(ListToolsRequest { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await; + let result = match result { + Ok(result) => result, + Err(error) => { + if uses_cursor { + self.invalidate_tool_cache().await; + return Err(error); + } + if let Some(ServerResult::ListToolsResult(result)) = + self.stale_cached_response(&cache_key).await + { + return Ok(result); + } + return Err(error); + } + }; + match result { + ServerResult::ListToolsResult(result) => { + self.cache_result( + Some(cache_key), + result.ttl_ms, + result.cache_scope, + generation, + ServerResult::ListToolsResult(result.clone()), + ) + .await; + Ok(result) + } + _ => Err(ServiceError::UnexpectedResponse), + } + } + + method!(peer_not notify_cancelled CancelledNotification(CancelledNotificationParam)); + method!(peer_not notify_progress ProgressNotification(ProgressNotificationParam)); + method!(peer_not notify_initialized InitializedNotification); + method!(peer_not notify_roots_list_changed RootsListChangedNotification); +} + +impl Peer { + /// A wrapper method for [`Peer::list_tools`]. + /// + /// This function will call [`Peer::list_tools`] multiple times until all tools are listed. + pub async fn list_all_tools(&self) -> Result, ServiceError> { + let mut tools = Vec::new(); + let mut cursor = None; + loop { + let result = self + .list_tools(Some(PaginatedRequestParams { meta: None, cursor })) + .await?; + tools.extend(result.tools); + cursor = result.next_cursor; + if cursor.is_none() { + break; + } + } + Ok(tools) + } + + /// A wrapper method for [`Peer::list_prompts`]. + /// + /// This function will call [`Peer::list_prompts`] multiple times until all prompts are listed. + pub async fn list_all_prompts(&self) -> Result, ServiceError> { + let mut prompts = Vec::new(); + let mut cursor = None; + loop { + let result = self + .list_prompts(Some(PaginatedRequestParams { meta: None, cursor })) + .await?; + prompts.extend(result.prompts); + cursor = result.next_cursor; + if cursor.is_none() { + break; + } + } + Ok(prompts) + } + + /// A wrapper method for [`Peer::list_resources`]. + /// + /// This function will call [`Peer::list_resources`] multiple times until all resources are listed. + pub async fn list_all_resources(&self) -> Result, ServiceError> { + let mut resources = Vec::new(); + let mut cursor = None; + loop { + let result = self + .list_resources(Some(PaginatedRequestParams { meta: None, cursor })) + .await?; + resources.extend(result.resources); + cursor = result.next_cursor; + if cursor.is_none() { + break; + } + } + Ok(resources) + } + + /// A wrapper method for [`Peer::list_resource_templates`]. + /// + /// This function will call [`Peer::list_resource_templates`] multiple times until all resource templates are listed. + pub async fn list_all_resource_templates( + &self, + ) -> Result, ServiceError> { + let mut resource_templates = Vec::new(); + let mut cursor = None; + loop { + let result = self + .list_resource_templates(Some(PaginatedRequestParams { meta: None, cursor })) + .await?; + resource_templates.extend(result.resource_templates); + cursor = result.next_cursor; + if cursor.is_none() { + break; + } + } + Ok(resource_templates) + } + + /// Convenient method to get completion suggestions for a prompt argument + /// + /// # Arguments + /// * `prompt_name` - Name of the prompt being completed + /// * `argument_name` - Name of the argument being completed + /// * `current_value` - Current partial value of the argument + /// * `context` - Optional context with previously resolved arguments + /// + /// # Returns + /// CompletionInfo with suggestions for the specified prompt argument + pub async fn complete_prompt_argument( + &self, + prompt_name: impl Into, + argument_name: impl Into, + current_value: impl Into, + context: Option, + ) -> Result { + let request = CompleteRequestParams { + meta: None, + r#ref: Reference::for_prompt(prompt_name), + argument: ArgumentInfo { + name: argument_name.into(), + value: current_value.into(), + }, + context, + }; + + let result = self.complete(request).await?; + Ok(result.completion) + } + + /// Convenient method to get completion suggestions for a resource URI argument + /// + /// # Arguments + /// * `uri_template` - URI template pattern being completed + /// * `argument_name` - Name of the URI parameter being completed + /// * `current_value` - Current partial value of the parameter + /// * `context` - Optional context with previously resolved arguments + /// + /// # Returns + /// CompletionInfo with suggestions for the specified resource URI argument + pub async fn complete_resource_argument( + &self, + uri_template: impl Into, + argument_name: impl Into, + current_value: impl Into, + context: Option, + ) -> Result { + let request = CompleteRequestParams { + meta: None, + r#ref: Reference::for_resource(uri_template), + argument: ArgumentInfo { + name: argument_name.into(), + value: current_value.into(), + }, + context, + }; + + let result = self.complete(request).await?; + Ok(result.completion) + } + + /// Simple completion for a prompt argument without context + /// + /// This is a convenience wrapper around `complete_prompt_argument` for + /// simple completion scenarios that don't require context awareness. + pub async fn complete_prompt_simple( + &self, + prompt_name: impl Into, + argument_name: impl Into, + current_value: impl Into, + ) -> Result, ServiceError> { + let completion = self + .complete_prompt_argument(prompt_name, argument_name, current_value, None) + .await?; + Ok(completion.values) + } + + /// Simple completion for a resource URI argument without context + /// + /// This is a convenience wrapper around `complete_resource_argument` for + /// simple completion scenarios that don't require context awareness. + pub async fn complete_resource_simple( + &self, + uri_template: impl Into, + argument_name: impl Into, + current_value: impl Into, + ) -> Result, ServiceError> { + let completion = self + .complete_resource_argument(uri_template, argument_name, current_value, None) + .await?; + Ok(completion.values) + } +} + +impl RunningService +where + S: Service, +{ + /// Send one `tools/call` request without driving MRTR follow-up rounds. + pub async fn call_tool_once( + &self, + params: CallToolRequestParams, + ) -> Result { + self.peer.call_tool_once(params).await + } + + /// Send one `prompts/get` request without driving MRTR follow-up rounds. + pub async fn get_prompt_once( + &self, + params: GetPromptRequestParams, + ) -> Result { + self.peer.get_prompt_once(params).await + } + + /// Send one `resources/read` request without driving MRTR follow-up rounds. + pub async fn read_resource_once( + &self, + params: ReadResourceRequestParams, + ) -> Result { + self.peer.read_resource_once(params).await + } + + /// High-level `tools/call` helper that automatically fulfils SEP-2322 + /// `input_required` rounds through the local [`ClientHandler`](crate::ClientHandler) service. + /// + /// # Errors + /// + /// Returns [`ServiceError::InputRequiredRoundsExceeded`] if the peer does + /// not produce a final [`CallToolResult`] within the default MRTR round cap. + /// Other transport, protocol, and local input-handler errors are propagated. + pub async fn call_tool( + &self, + params: CallToolRequestParams, + ) -> Result { + self.call_tool_with_mrtr_max_rounds(params, DEFAULT_MRTR_MAX_ROUNDS) + .await + } + + /// Same as [`Self::call_tool`], with an explicit MRTR round cap. + /// + /// # Errors + /// + /// Returns [`ServiceError::InputRequiredRoundsExceeded`] once `max_rounds` + /// `input_required` responses have been driven without receiving a final + /// [`CallToolResult`]. Other transport, protocol, and local input-handler + /// errors are propagated. + pub async fn call_tool_with_mrtr_max_rounds( + &self, + mut params: CallToolRequestParams, + max_rounds: usize, + ) -> Result { + let mut state_only_rounds = 0usize; + for _round in 0..max_rounds { + match self.peer.call_tool_once(params.clone()).await? { + CallToolResponse::Complete(result) => return Ok(result), + CallToolResponse::InputRequired(result) => { + let (input_responses, request_state) = self + .prepare_input_required_retry(result, &mut state_only_rounds) + .await?; + params.input_responses = input_responses; + params.request_state = request_state; + } + // SEP-2663: this helper does not drive the task polling + // lifecycle. Callers that declare the tasks extension + // capability should use `call_tool_once` and poll `tasks/get`. + CallToolResponse::Task(_) => return Err(ServiceError::UnexpectedResponse), + } + } + Err(ServiceError::InputRequiredRoundsExceeded { max_rounds }) + } + + /// High-level `prompts/get` helper that automatically fulfils SEP-2322 + /// `input_required` rounds through the local [`ClientHandler`](crate::ClientHandler) service. + /// + /// # Errors + /// + /// Returns [`ServiceError::InputRequiredRoundsExceeded`] if the peer does + /// not produce a final [`GetPromptResult`] within the default MRTR round cap. + /// Other transport, protocol, and local input-handler errors are propagated. + pub async fn get_prompt( + &self, + params: GetPromptRequestParams, + ) -> Result { + self.get_prompt_with_mrtr_max_rounds(params, DEFAULT_MRTR_MAX_ROUNDS) + .await + } + + /// Same as [`Self::get_prompt`], with an explicit MRTR round cap. + /// + /// # Errors + /// + /// Returns [`ServiceError::InputRequiredRoundsExceeded`] once `max_rounds` + /// `input_required` responses have been driven without receiving a final + /// [`GetPromptResult`]. Other transport, protocol, and local input-handler + /// errors are propagated. + pub async fn get_prompt_with_mrtr_max_rounds( + &self, + mut params: GetPromptRequestParams, + max_rounds: usize, + ) -> Result { + let mut state_only_rounds = 0usize; + for _round in 0..max_rounds { + match self.peer.get_prompt_once(params.clone()).await? { + GetPromptResponse::Complete(result) => return Ok(result), + GetPromptResponse::InputRequired(result) => { + let (input_responses, request_state) = self + .prepare_input_required_retry(result, &mut state_only_rounds) + .await?; + params.input_responses = input_responses; + params.request_state = request_state; + } + } + } + Err(ServiceError::InputRequiredRoundsExceeded { max_rounds }) + } + + /// High-level `resources/read` helper that automatically fulfils SEP-2322 + /// `input_required` rounds through the local [`ClientHandler`](crate::ClientHandler) service. + /// + /// # Errors + /// + /// Returns [`ServiceError::InputRequiredRoundsExceeded`] if the peer does + /// not produce a final [`ReadResourceResult`] within the default MRTR round + /// cap. Other transport, protocol, and local input-handler errors are + /// propagated. + pub async fn read_resource( + &self, + params: ReadResourceRequestParams, + ) -> Result { + self.read_resource_with_mrtr_max_rounds(params, DEFAULT_MRTR_MAX_ROUNDS) + .await + } + + /// Same as [`Self::read_resource`], with an explicit MRTR round cap. + /// + /// # Errors + /// + /// Returns [`ServiceError::InputRequiredRoundsExceeded`] once `max_rounds` + /// `input_required` responses have been driven without receiving a final + /// [`ReadResourceResult`]. Other transport, protocol, and local input-handler + /// errors are propagated. + pub async fn read_resource_with_mrtr_max_rounds( + &self, + mut params: ReadResourceRequestParams, + max_rounds: usize, + ) -> Result { + let mut state_only_rounds = 0usize; + for _round in 0..max_rounds { + match self.peer.read_resource_once(params.clone()).await? { + ReadResourceResponse::Complete(result) => return Ok(result), + ReadResourceResponse::InputRequired(result) => { + let (input_responses, request_state) = self + .prepare_input_required_retry(result, &mut state_only_rounds) + .await?; + params.input_responses = input_responses; + params.request_state = request_state; + } + } + } + Err(ServiceError::InputRequiredRoundsExceeded { max_rounds }) + } + + async fn prepare_input_required_retry( + &self, + result: InputRequiredResult, + state_only_rounds: &mut usize, + ) -> Result<(Option, Option), ServiceError> { + let had_input_requests = result + .input_requests + .as_ref() + .is_some_and(|requests| !requests.is_empty()); + if !had_input_requests && result.request_state.is_none() { + return Err(ServiceError::UnexpectedResponse); + } + + let responses = self + .fulfill_input_requests(result.input_requests.unwrap_or_default()) + .await?; + if had_input_requests { + *state_only_rounds = 0; + } else { + Self::sleep_state_only_round(*state_only_rounds).await; + *state_only_rounds += 1; + } + + Ok(( + (!responses.is_empty()).then_some(responses), + result.request_state, + )) + } + + async fn fulfill_input_requests( + &self, + requests: crate::model::InputRequests, + ) -> Result { + let responses = futures::future::try_join_all( + requests + .into_iter() + .map(|(key, request)| self.fulfill_input_request(key, request)), + ) + .await?; + Ok(responses.into_iter().collect()) + } + + async fn fulfill_input_request( + &self, + key: String, + request: InputRequest, + ) -> Result<(String, serde_json::Value), ServiceError> { + let response = match request { + InputRequest::CreateMessage(request) => { + let mut request = ServerRequest::CreateMessageRequest(request); + let context = self.input_request_context(&key, &mut request); + match self + .service + .handle_request(request, context) + .await + .map_err(ServiceError::McpError)? + { + ClientResult::CreateMessageResult(result) => { + serde_json::to_value(result).map_err(Self::serde_to_service_error)? + } + _ => return Err(ServiceError::UnexpectedResponse), + } + } + InputRequest::Elicitation(request) => { + let mut request = ServerRequest::ElicitRequest(request); + let context = self.input_request_context(&key, &mut request); + match self + .service + .handle_request(request, context) + .await + .map_err(ServiceError::McpError)? + { + ClientResult::ElicitResult(result) => { + serde_json::to_value(result).map_err(Self::serde_to_service_error)? + } + _ => return Err(ServiceError::UnexpectedResponse), + } + } + InputRequest::ListRoots(request) => { + let mut request = ServerRequest::ListRootsRequest(request); + let context = self.input_request_context(&key, &mut request); + match self + .service + .handle_request(request, context) + .await + .map_err(ServiceError::McpError)? + { + ClientResult::ListRootsResult(result) => { + serde_json::to_value(result).map_err(Self::serde_to_service_error)? + } + _ => return Err(ServiceError::UnexpectedResponse), + } + } + }; + Ok((key, response)) + } + + fn input_request_context(&self, key: &str, request: &mut T) -> RequestContext + where + T: GetMeta + GetExtensions, + { + let mut meta = Default::default(); + let mut extensions = Default::default(); + std::mem::swap(&mut meta, request.get_meta_mut()); + std::mem::swap(&mut extensions, request.extensions_mut()); + RequestContext { + ct: tokio_util::sync::CancellationToken::new(), + id: NumberOrString::String(Arc::from(key)), + peer: self.peer.clone(), + meta, + extensions, + } + } + + async fn sleep_state_only_round(state_only_rounds: usize) { + let millis = (50u64.saturating_mul(1_u64 << state_only_rounds.min(3))).min(250); + tokio::time::sleep(Duration::from_millis(millis)).await; + } + + fn serde_to_service_error(error: serde_json::Error) -> ServiceError { + ServiceError::McpError(ErrorData::internal_error( + format!("failed to serialize MRTR input response: {error}"), + None, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn disconnected_peer() -> Peer { + let (peer, receiver) = + Peer::::new(Arc::new(AtomicU32RequestIdProvider::default()), None); + drop(receiver); + peer + } + + fn tools_result(ttl_ms: Option, cache_scope: Option) -> ListToolsResult { + let mut result = ListToolsResult::with_all_items(Vec::new()); + result.ttl_ms = ttl_ms; + result.cache_scope = cache_scope; + result + } + + #[tokio::test] + async fn fresh_cached_page_is_served_without_transport_io() { + let peer = disconnected_peer(); + let params = None::; + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + let expected = tools_result(Some(5_000), Some(CacheScope::Public)); + peer.cache_response( + key, + ServerResult::ListToolsResult(expected.clone()), + expected.ttl_ms, + expected.cache_scope, + ) + .await; + + assert_eq!(peer.list_tools(params).await.unwrap(), expected); + } + + #[tokio::test] + async fn expired_entry_falls_through_to_the_transport() { + let peer = disconnected_peer(); + peer.set_response_cache_config( + ClientCacheConfig::default().with_serve_stale_on_error(false), + ) + .await; + let params = None::; + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + peer.cache_response( + key, + ServerResult::ListToolsResult(tools_result(Some(1), Some(CacheScope::Public))), + Some(1), + Some(CacheScope::Public), + ) + .await; + tokio::time::sleep(Duration::from_millis(5)).await; + + assert!(matches!( + peer.list_tools(params).await, + Err(ServiceError::TransportClosed) + )); + } + + #[tokio::test] + async fn private_entries_are_isolated_between_authorization_partitions() { + let peer = disconnected_peer(); + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &None); + + peer.set_response_cache_config( + ClientCacheConfig::default().with_private_partition("auth-a"), + ) + .await; + peer.cache_response( + key.clone(), + ServerResult::ListToolsResult(tools_result(Some(5_000), Some(CacheScope::Private))), + Some(5_000), + Some(CacheScope::Private), + ) + .await; + assert!(peer.cached_response(&key).await.is_some()); + + // Switching to a different authorization context must not expose the + // first partition's private entry. + peer.set_response_cache_config( + ClientCacheConfig::default().with_private_partition("auth-b"), + ) + .await; + assert!(peer.cached_response(&key).await.is_none()); + } + + #[tokio::test] + async fn list_change_notification_discards_every_cached_page() { + let peer = disconnected_peer(); + for cursor in [None, Some("page-a".into()), Some("page-b".into())] { + let params = + cursor.map(|cursor| PaginatedRequestParams::default().with_cursor(Some(cursor))); + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + peer.cache_response( + key, + ServerResult::ListToolsResult(tools_result(Some(5_000), Some(CacheScope::Public))), + Some(5_000), + Some(CacheScope::Public), + ) + .await; + } + + peer.invalidate_tool_cache().await; + + for cursor in [None, Some("page-a".into()), Some("page-b".into())] { + let params = + cursor.map(|cursor| PaginatedRequestParams::default().with_cursor(Some(cursor))); + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + assert!(peer.cached_response(&key).await.is_none()); + } + } + + #[tokio::test] + async fn expired_entry_is_served_when_refetch_fails() { + let peer = disconnected_peer(); + let params = None::; + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + let expected = tools_result(Some(1), Some(CacheScope::Public)); + peer.cache_response( + key, + ServerResult::ListToolsResult(expected.clone()), + Some(1), + Some(CacheScope::Public), + ) + .await; + tokio::time::sleep(Duration::from_millis(5)).await; + + assert_eq!(peer.list_tools(params).await.unwrap(), expected); + } + + #[tokio::test] + async fn discover_serves_a_fresh_cached_response_without_transport_io() { + let peer = disconnected_peer(); + let meta = RequestMetaObject::default(); + let key = discover_cache_key(); + let expected = DiscoverResult::new(vec![ProtocolVersion::default()], Default::default()) + .with_server_info(crate::model::Implementation::from_build_env()) + .with_ttl_ms(5_000) + .with_cache_scope(CacheScope::Public); + peer.cache_response( + key, + ServerResult::DiscoverResult(expected.clone()), + Some(5_000), + Some(CacheScope::Public), + ) + .await; + + assert_eq!(peer.discover(meta).await.unwrap(), expected); + } +} + +#[cfg(test)] +mod sep2260_association_tests { + use super::*; + use crate::{ + model::{ + CreateMessageRequest, CreateMessageRequestParams, SamplingMessage, ServerCapabilities, + }, + service::PeerRequestAssociation, + }; + + fn sampling_request() -> ServerRequest { + ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new(vec![SamplingMessage::user_text("hi")], 16), + )) + } + + fn server_info(version: ProtocolVersion) -> ServerPeerInfo { + ServerPeerInfo::new(version, ServerCapabilities::default()) + } + + fn enforce( + info: &ServerPeerInfo, + association: PeerRequestAssociation, + ) -> Result<(), ErrorData> { + RoleClient::enforce_peer_request_association(&sampling_request(), Some(info), association) + } + + #[test] + fn strict_rejects_unassociated() { + let info = server_info(ProtocolVersion::V_2026_07_28); + let err = enforce(&info, PeerRequestAssociation::Unassociated).unwrap_err(); + assert_eq!(err.code, crate::model::ErrorCode::INVALID_PARAMS); + } + + #[test] + fn strict_accepts_associated() { + let info = server_info(ProtocolVersion::V_2026_07_28); + assert!(enforce(&info, PeerRequestAssociation::Associated).is_ok()); + } + + #[test] + fn strict_unknown_falls_back_to_coarse_check() { + let info = server_info(ProtocolVersion::V_2026_07_28); + assert!( + enforce( + &info, + PeerRequestAssociation::Unknown { + has_pending_outbound_request: true + } + ) + .is_ok() + ); + assert!( + enforce( + &info, + PeerRequestAssociation::Unknown { + has_pending_outbound_request: false + } + ) + .is_err() + ); + } + + #[test] + fn legacy_protocol_accepts_even_unassociated() { + let info = server_info(ProtocolVersion::V_2025_11_25); + assert!(enforce(&info, PeerRequestAssociation::Unassociated).is_ok()); + } +} diff --git a/vendor/rmcp/src/service/client/cache.rs b/vendor/rmcp/src/service/client/cache.rs new file mode 100644 index 000000000..f7137ccd6 --- /dev/null +++ b/vendor/rmcp/src/service/client/cache.rs @@ -0,0 +1,401 @@ +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, Instant}, +}; + +use super::RoleClient; +use crate::{ + model::CacheScope, + service::{Peer, ServiceRole}, +}; + +/// Maximum server-provided cache TTL honoured by the client response cache. +pub const MAX_CLIENT_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60); + +/// Configuration for the built-in MCP client response cache. +/// +/// A cache is allocated per client [`Peer`]. Public responses may be reused +/// throughout that client connection. Private responses are additionally +/// partitioned by `private_partition`; changing the partition drops every +/// private entry while preserving public entries. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct ClientCacheConfig { + /// Enables cache reads and writes. + pub enabled: bool, + /// TTL used when a backwards-compatible server omits `ttlMs`. + /// + /// The default is zero, which leaves such responses immediately stale. + pub default_ttl: Duration, + /// Upper bound applied to both server-provided and default TTLs. + pub max_ttl: Duration, + /// Stable opaque identity for the current authorization context. + /// + /// A single-principal client may leave this unset because each client owns + /// its own in-memory store. Gateways or clients that change principals on an + /// existing connection should set this value and update it whenever the + /// authorization context changes. + pub private_partition: Option, + /// Maximum number of responses retained by the in-memory cache. + /// + /// A value of zero disables the size limit. + pub max_entries: usize, + /// Serves an expired cached response when a re-fetch fails. + /// + /// SEP-2549 permits clients to serve stale responses if errors occur while + /// re-fetching (for example, network issues or server downtime). When this + /// is enabled the client retains expired entries so it can fall back to the + /// last known response instead of surfacing the transport or server error. + /// A successful re-fetch always overwrites the stale entry. + pub serve_stale_on_error: bool, +} + +impl Default for ClientCacheConfig { + fn default() -> Self { + Self { + enabled: true, + default_ttl: Duration::ZERO, + max_ttl: MAX_CLIENT_CACHE_TTL, + private_partition: None, + max_entries: 512, + serve_stale_on_error: true, + } + } +} + +impl ClientCacheConfig { + /// Returns a configuration that disables all cache reads and writes. + pub fn disabled() -> Self { + Self { + enabled: false, + ..Self::default() + } + } + + /// Sets the TTL used when a response omits `ttlMs`. + pub fn with_default_ttl(mut self, default_ttl: Duration) -> Self { + self.default_ttl = default_ttl; + self + } + + /// Sets the maximum TTL the client will honour. + pub fn with_max_ttl(mut self, max_ttl: Duration) -> Self { + self.max_ttl = max_ttl; + self + } + + /// Sets the stable partition for private responses. + pub fn with_private_partition(mut self, partition: impl Into) -> Self { + self.private_partition = Some(partition.into()); + self + } + + /// Sets the maximum number of retained responses. + pub fn with_max_entries(mut self, max_entries: usize) -> Self { + self.max_entries = max_entries; + self + } + + /// Controls whether an expired response may be served when a re-fetch fails. + pub fn with_serve_stale_on_error(mut self, serve_stale_on_error: bool) -> Self { + self.serve_stale_on_error = serve_stale_on_error; + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +enum CachePartition { + Public, + Private(Arc), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct CacheKey { + logical_key: String, + partition: CachePartition, +} + +#[derive(Debug, Clone)] +struct CachedPeerResponse { + value: T, + expires_at: Instant, + inserted_at: Instant, + scope: CacheScope, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct CacheGeneration(u64); + +#[derive(Debug)] +pub(crate) struct PeerResponseCacheState { + entries: HashMap>, + config: ClientCacheConfig, + generation: u64, +} + +impl Default for PeerResponseCacheState { + fn default() -> Self { + Self { + entries: HashMap::new(), + config: ClientCacheConfig::default(), + generation: 0, + } + } +} + +impl PeerResponseCacheState { + fn trim_to_limit(&mut self) { + while self.config.max_entries > 0 && self.entries.len() > self.config.max_entries { + let Some(oldest_key) = self + .entries + .iter() + .min_by_key(|(_, entry)| entry.inserted_at) + .map(|(key, _)| key.clone()) + else { + break; + }; + self.entries.remove(&oldest_key); + } + } +} + +pub(crate) type PeerResponseCache = Arc>>; + +impl Peer { + fn private_partition(config: &ClientCacheConfig) -> Arc { + Arc::from(config.private_partition.as_deref().unwrap_or("connection")) + } + + fn cache_key(logical_key: &str, partition: CachePartition) -> CacheKey { + CacheKey { + logical_key: logical_key.to_owned(), + partition, + } + } + + fn scoped_cache_key( + logical_key: &str, + scope: CacheScope, + config: &ClientCacheConfig, + ) -> CacheKey { + let partition = match scope { + CacheScope::Public => CachePartition::Public, + CacheScope::Private => CachePartition::Private(Self::private_partition(config)), + }; + Self::cache_key(logical_key, partition) + } + + /// Captures the cache generation before a request crosses the transport. + /// + /// Any configuration change, explicit clear, or notification invalidation + /// advances the generation. A response from an older generation is not + /// written back, preventing an in-flight stale response from undoing an + /// invalidation. + pub(crate) async fn capture_response_cache_generation(&self) -> CacheGeneration { + CacheGeneration(self.response_cache.read().await.generation) + } + + /// Returns a fresh cached response, preferring the current private partition + /// before the public partition. + /// + /// Expired entries are removed on access unless `serve_stale_on_error` is + /// enabled, in which case they are retained so a later re-fetch failure can + /// fall back to them via [`Peer::stale_cached_response`]. + pub(crate) async fn cached_response(&self, logical_key: &str) -> Option { + let now = Instant::now(); + let mut cache = self.response_cache.write().await; + if !cache.config.enabled { + return None; + } + let keep_stale = cache.config.serve_stale_on_error; + + let private_key = Self::cache_key( + logical_key, + CachePartition::Private(Self::private_partition(&cache.config)), + ); + let private_fresh = cache.entries.get(&private_key).and_then(|entry| { + (entry.expires_at > now && entry.scope == CacheScope::Private) + .then(|| entry.value.clone()) + }); + if let Some(value) = private_fresh { + return Some(value); + } + if !keep_stale { + cache.entries.remove(&private_key); + } + + let public_key = Self::cache_key(logical_key, CachePartition::Public); + let public_fresh = cache.entries.get(&public_key).and_then(|entry| { + (entry.expires_at > now && entry.scope == CacheScope::Public) + .then(|| entry.value.clone()) + }); + if let Some(value) = public_fresh { + return Some(value); + } + if !keep_stale { + cache.entries.remove(&public_key); + } + None + } + + /// Returns a cached response ignoring its TTL, for use as a fallback when a + /// re-fetch fails (SEP-2549 permits serving stale responses on error). + /// + /// Returns `None` when the cache is disabled or `serve_stale_on_error` is + /// turned off. The private partition is preferred over the public one. The + /// entry is left in place so repeated failures keep serving it until a + /// successful re-fetch overwrites it or a notification invalidates it. + pub(crate) async fn stale_cached_response(&self, logical_key: &str) -> Option { + let cache = self.response_cache.read().await; + if !cache.config.enabled || !cache.config.serve_stale_on_error { + return None; + } + + let private_key = Self::cache_key( + logical_key, + CachePartition::Private(Self::private_partition(&cache.config)), + ); + if let Some(entry) = cache.entries.get(&private_key) + && entry.scope == CacheScope::Private + { + return Some(entry.value.clone()); + } + + let public_key = Self::cache_key(logical_key, CachePartition::Public); + if let Some(entry) = cache.entries.get(&public_key) + && entry.scope == CacheScope::Public + { + return Some(entry.value.clone()); + } + None + } + + /// Stores a response when the configured effective TTL is positive. + /// + /// Missing `cacheScope` is treated as private. This is deliberately more + /// conservative than the model's backwards-compatible wire default and + /// prevents an older or malformed server response from becoming shareable. + pub(crate) async fn cache_response_with_generation( + &self, + logical_key: String, + value: R::PeerResp, + ttl_ms: Option, + cache_scope: Option, + generation: CacheGeneration, + ) { + let now = Instant::now(); + let mut cache = self.response_cache.write().await; + if !cache.config.enabled || generation.0 != cache.generation { + return; + } + + let requested_ttl = ttl_ms + .map(Duration::from_millis) + .unwrap_or(cache.config.default_ttl); + let ttl = requested_ttl.min(cache.config.max_ttl); + if ttl.is_zero() { + return; + } + let Some(expires_at) = now.checked_add(ttl) else { + return; + }; + let scope = cache_scope.unwrap_or(CacheScope::Private); + let target_key = Self::scoped_cache_key(&logical_key, scope, &cache.config); + let opposite_key = match scope { + CacheScope::Public => Self::cache_key( + &logical_key, + CachePartition::Private(Self::private_partition(&cache.config)), + ), + CacheScope::Private => Self::cache_key(&logical_key, CachePartition::Public), + }; + + if !cache.config.serve_stale_on_error { + cache.entries.retain(|_, entry| entry.expires_at > now); + } + cache.entries.remove(&opposite_key); + + if cache.config.max_entries > 0 + && !cache.entries.contains_key(&target_key) + && cache.entries.len() >= cache.config.max_entries + && let Some(oldest_key) = cache + .entries + .iter() + .min_by_key(|(_, entry)| entry.inserted_at) + .map(|(key, _)| key.clone()) + { + cache.entries.remove(&oldest_key); + } + + cache.entries.insert( + target_key, + CachedPeerResponse { + value, + expires_at, + inserted_at: now, + scope, + }, + ); + } + + #[cfg(test)] + pub(crate) async fn cache_response( + &self, + logical_key: String, + value: R::PeerResp, + ttl_ms: Option, + cache_scope: Option, + ) { + let generation = self.capture_response_cache_generation().await; + self.cache_response_with_generation(logical_key, value, ttl_ms, cache_scope, generation) + .await; + } + + pub(crate) async fn invalidate_cached_responses(&self, prefix: &str) { + let mut cache = self.response_cache.write().await; + cache.generation = cache.generation.wrapping_add(1); + cache + .entries + .retain(|key, _| !key.logical_key.starts_with(prefix)); + } +} + +impl Peer { + /// Replaces the response-cache configuration. + /// + /// Changing the private partition invalidates private entries from the old + /// authorization context. Disabling the cache clears every entry. Any + /// configuration change also suppresses writes from requests that were + /// already in flight under the previous configuration. + pub async fn set_response_cache_config(&self, config: ClientCacheConfig) { + let mut cache = self.response_cache.write().await; + let config_changed = cache.config != config; + let partition_changed = cache.config.private_partition != config.private_partition; + let ttl_policy_changed = cache.config.default_ttl != config.default_ttl + || cache.config.max_ttl != config.max_ttl; + cache.config = config; + if config_changed { + cache.generation = cache.generation.wrapping_add(1); + } + if !cache.config.enabled || ttl_policy_changed { + cache.entries.clear(); + } else if partition_changed { + cache + .entries + .retain(|_, entry| entry.scope == CacheScope::Public); + } + cache.trim_to_limit(); + } + + /// Returns a snapshot of the active response-cache configuration. + pub async fn response_cache_config(&self) -> ClientCacheConfig { + self.response_cache.read().await.config.clone() + } + + /// Clears every cached client response without changing the configuration. + pub async fn clear_response_cache(&self) { + let mut cache = self.response_cache.write().await; + cache.generation = cache.generation.wrapping_add(1); + cache.entries.clear(); + } +} diff --git a/vendor/rmcp/src/service/server.rs b/vendor/rmcp/src/service/server.rs new file mode 100644 index 000000000..8efbd34be --- /dev/null +++ b/vendor/rmcp/src/service/server.rs @@ -0,0 +1,1314 @@ +// Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected. +#![expect(deprecated)] +#[cfg(feature = "elicitation")] +use std::collections::HashSet; +use std::{borrow::Cow, sync::Arc}; + +use thiserror::Error; +#[cfg(feature = "elicitation")] +use url::Url; + +use super::*; +#[cfg(feature = "elicitation")] +use crate::model::{ElicitRequest, ElicitRequestParams, ElicitResult, ElicitationAction}; +use crate::{ + model::{ + CancelledNotification, CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, + ClientNotification, ClientRequest, ClientResult, CreateMessageRequest, + CreateMessageRequestParams, CreateMessageResult, EmptyResult, ErrorData, ListRootsRequest, + ListRootsResult, LoggingMessageNotification, LoggingMessageNotificationParam, + ProgressNotification, ProgressNotificationParam, PromptListChangedNotification, + ProtocolVersion, ResourceListChangedNotification, ResourceUpdatedNotification, + ResourceUpdatedNotificationParam, ServerInfo, ServerNotification, ServerRequest, + ServerResult, SubscriptionFilter, SubscriptionsAcknowledgedNotification, + SubscriptionsAcknowledgedNotificationParams, ToolListChangedNotification, + }, + transport::DynamicTransportError, +}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct RoleServer; + +impl ServiceRole for RoleServer { + type Req = ServerRequest; + type Resp = ServerResult; + type Not = ServerNotification; + type PeerReq = ClientRequest; + type PeerResp = ClientResult; + type PeerNot = ClientNotification; + type Info = ServerInfo; + type PeerInfo = ClientInfo; + + type InitializeError = ServerInitializeError; + const IS_CLIENT: bool = false; + + fn peer_cancelled_params(notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> { + match notification { + ClientNotification::CancelledNotification(notification) => Some(¬ification.params), + _ => None, + } + } + + fn enforce_request_association( + request: &Self::Req, + peer_info: Option<&Self::PeerInfo>, + in_request_handler_scope: bool, + ) -> Result<(), ServiceError> { + let restricted = matches!( + request, + ServerRequest::CreateMessageRequest(_) + | ServerRequest::ListRootsRequest(_) + | ServerRequest::ElicitRequest(_) + ); + if !restricted { + return Ok(()); + } + let strict = + peer_info.is_some_and(|info| info.protocol_version >= ProtocolVersion::V_2026_07_28); + if strict && !in_request_handler_scope { + return Err(ServiceError::McpError(ErrorData::invalid_request( + "SEP-2260: server-to-client requests must be associated with an originating client request", + None, + ))); + } + Ok(()) + } +} + +/// It represents the error that may occur when serving the server. +/// +/// if you want to handle the error, you can use `serve_server_with_ct` or `serve_server` with `Result, ServerError>` +#[derive(Error, Debug)] +#[non_exhaustive] +pub enum ServerInitializeError { + #[error("expect initialized request, but received: {0:?}")] + ExpectedInitializeRequest(Option), + + #[error("connection closed: {0}")] + ConnectionClosed(String), + + #[error("unexpected initialize result: {0:?}")] + UnexpectedInitializeResponse(ServerResult), + + #[error("initialize failed: {0}")] + InitializeFailed(ErrorData), + + #[error("Send message error {error}, when {context}")] + TransportError { + error: DynamicTransportError, + context: Cow<'static, str>, + }, + + #[error("Cancelled")] + Cancelled, +} + +impl ServerInitializeError { + pub fn transport + 'static>( + error: T::Error, + context: impl Into>, + ) -> Self { + Self::TransportError { + error: DynamicTransportError::new::(error), + context: context.into(), + } + } +} +pub type ClientSink = Peer; + +/// Failure to send a notification through a [`SubscriptionSink`]. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum SubscriptionSendError { + #[error("subscription is no longer active")] + SubscriptionClosed, + #[error("notification is not allowed on a subscription stream: {0}")] + UnsupportedNotification(&'static str), + #[error("notification was not accepted for this subscription: {0}")] + NotificationNotAccepted(&'static str), + #[error(transparent)] + Service(#[from] ServiceError), +} + +/// A server-side notification sink scoped to one `subscriptions/listen` request. +/// +/// The sink applies the accepted filter and adds the subscription request ID to +/// every notification it sends. +#[derive(Debug, Clone)] +pub struct SubscriptionSink { + peer: Peer, + id: RequestId, + accepted: Arc, + active: CancellationToken, +} + +impl SubscriptionSink { + fn new( + peer: Peer, + id: RequestId, + accepted: Arc, + active: CancellationToken, + ) -> Self { + Self { + peer, + id, + accepted, + active, + } + } + + /// Return the JSON-RPC ID of the originating listen request. + pub fn id(&self) -> &RequestId { + &self.id + } + + /// Return the filter accepted for this subscription. + pub fn accepted(&self) -> &SubscriptionFilter { + self.accepted.as_ref() + } + + /// Send an allowed change notification with subscription metadata attached. + /// + /// # Errors + /// + /// Returns [`SubscriptionSendError::SubscriptionClosed`] after the request + /// ends, a filter error for disallowed notifications, or a transport error. + pub async fn send( + &self, + mut notification: ServerNotification, + ) -> Result<(), SubscriptionSendError> { + if self.active.is_cancelled() { + return Err(SubscriptionSendError::SubscriptionClosed); + } + match ¬ification { + ServerNotification::ToolListChangedNotification(_) => { + if self.accepted.tools_list_changed != Some(true) { + return Err(SubscriptionSendError::NotificationNotAccepted( + "notifications/tools/list_changed", + )); + } + } + ServerNotification::PromptListChangedNotification(_) => { + if self.accepted.prompts_list_changed != Some(true) { + return Err(SubscriptionSendError::NotificationNotAccepted( + "notifications/prompts/list_changed", + )); + } + } + ServerNotification::ResourceListChangedNotification(_) => { + if self.accepted.resources_list_changed != Some(true) { + return Err(SubscriptionSendError::NotificationNotAccepted( + "notifications/resources/list_changed", + )); + } + } + ServerNotification::ResourceUpdatedNotification(update) => { + let accepted = self + .accepted + .resource_subscriptions + .as_ref() + .is_some_and(|uris| uris.contains(&update.params.uri)); + if !accepted { + return Err(SubscriptionSendError::NotificationNotAccepted( + "notifications/resources/updated", + )); + } + } + ServerNotification::SubscriptionsAcknowledgedNotification(_) => { + return Err(SubscriptionSendError::UnsupportedNotification( + "notifications/subscriptions/acknowledged", + )); + } + ServerNotification::CancelledNotification(_) => { + return Err(SubscriptionSendError::UnsupportedNotification( + "notifications/cancelled", + )); + } + ServerNotification::ProgressNotification(_) => { + return Err(SubscriptionSendError::UnsupportedNotification( + "notifications/progress", + )); + } + ServerNotification::LoggingMessageNotification(_) => { + return Err(SubscriptionSendError::UnsupportedNotification( + "notifications/message", + )); + } + // SEP-2663 task status notifications are not yet routable through + // `subscriptions/listen`: `SubscriptionFilter` has no `taskIds` + // field yet (the upstream conformance check for this flow is also + // still skipped, pending the subscriptions/listen rewrite). + // Clients currently observe task state by polling `tasks/get`. + ServerNotification::TaskStatusNotification(_) => { + return Err(SubscriptionSendError::UnsupportedNotification( + "notifications/tasks", + )); + } + ServerNotification::CustomNotification(_) => { + return Err(SubscriptionSendError::UnsupportedNotification( + "custom notification", + )); + } + } + + notification + .get_meta_mut() + .set_subscription_id(self.id.clone()); + self.peer.send_notification(notification).await?; + Ok(()) + } + + /// Send `notifications/tools/list_changed`. + /// + /// # Errors + /// + /// See [`Self::send`]. + pub async fn notify_tool_list_changed(&self) -> Result<(), SubscriptionSendError> { + self.send(ServerNotification::ToolListChangedNotification( + ToolListChangedNotification { + method: Default::default(), + extensions: Default::default(), + }, + )) + .await + } + + /// Send `notifications/prompts/list_changed`. + /// + /// # Errors + /// + /// See [`Self::send`]. + pub async fn notify_prompt_list_changed(&self) -> Result<(), SubscriptionSendError> { + self.send(ServerNotification::PromptListChangedNotification( + PromptListChangedNotification { + method: Default::default(), + extensions: Default::default(), + }, + )) + .await + } + + /// Send `notifications/resources/list_changed`. + /// + /// # Errors + /// + /// See [`Self::send`]. + pub async fn notify_resource_list_changed(&self) -> Result<(), SubscriptionSendError> { + self.send(ServerNotification::ResourceListChangedNotification( + ResourceListChangedNotification { + method: Default::default(), + extensions: Default::default(), + }, + )) + .await + } + + /// Send `notifications/resources/updated` for an accepted URI. + /// + /// # Errors + /// + /// See [`Self::send`]. + pub async fn notify_resource_updated( + &self, + uri: impl Into, + ) -> Result<(), SubscriptionSendError> { + self.send(ServerNotification::ResourceUpdatedNotification( + ResourceUpdatedNotification::new(ResourceUpdatedNotificationParam::new(uri)), + )) + .await + } +} + +/// Context for one established server-side notification subscription. +/// +/// The acknowledgment has already been sent before this context is handed to +/// [`ServerHandler::listen`](crate::ServerHandler::listen). +#[derive(Debug)] +pub struct SubscriptionContext { + request: RequestContext, + requested: SubscriptionFilter, + accepted: Arc, + sink: SubscriptionSink, + _active_guard: DropGuard, +} + +impl SubscriptionContext { + pub(crate) async fn establish( + request: RequestContext, + requested: SubscriptionFilter, + accepted: SubscriptionFilter, + ) -> Result { + let active = request.ct.child_token(); + let accepted = Arc::new(accepted); + let sink = SubscriptionSink::new( + request.peer.clone(), + request.id.clone(), + accepted.clone(), + active.clone(), + ); + let mut acknowledgment = SubscriptionsAcknowledgedNotification::new( + SubscriptionsAcknowledgedNotificationParams::new(accepted.as_ref().clone()), + ); + let mut meta = NotificationMetaObject::new(); + meta.set_subscription_id(request.id.clone()); + acknowledgment.extensions.insert(meta); + request + .peer + .send_notification(ServerNotification::SubscriptionsAcknowledgedNotification( + acknowledgment, + )) + .await + .map_err(|error| { + ErrorData::internal_error( + format!("failed to acknowledge subscription: {error}"), + None, + ) + })?; + Ok(Self { + request, + requested, + accepted, + sink, + _active_guard: active.drop_guard(), + }) + } + + /// Return the filter requested by the client. + pub fn requested(&self) -> &SubscriptionFilter { + &self.requested + } + + /// Return the subset accepted by the server. + pub fn accepted(&self) -> &SubscriptionFilter { + self.accepted.as_ref() + } + + /// Return a cloneable, filter-enforcing notification sink. + pub fn sink(&self) -> &SubscriptionSink { + &self.sink + } + + /// Wait until the subscription request is cancelled. + pub async fn cancelled(&self) { + self.request.ct.cancelled().await; + } + + /// Access the underlying request context. + pub fn request_context(&self) -> &RequestContext { + &self.request + } +} + +impl> ServiceExt for S { + fn serve_with_ct( + self, + transport: T, + ct: CancellationToken, + ) -> impl Future, ServerInitializeError>> + + MaybeSendFuture + where + T: IntoTransport, + E: std::error::Error + Send + Sync + 'static, + Self: Sized, + { + serve_server_with_ct(self, transport, ct) + } +} + +pub async fn serve_server( + service: S, + transport: T, +) -> Result, ServerInitializeError> +where + S: Service, + T: IntoTransport, + E: std::error::Error + Send + Sync + 'static, +{ + serve_server_with_ct(service, transport, CancellationToken::new()).await +} + +/// Helper function to get the next message from the stream +async fn expect_next_message( + transport: &mut T, + context: &str, +) -> Result +where + T: Transport, +{ + transport + .receive() + .await + .ok_or_else(|| ServerInitializeError::ConnectionClosed(context.to_string())) +} + +pub async fn serve_server_with_ct( + service: S, + transport: T, + ct: CancellationToken, +) -> Result, ServerInitializeError> +where + S: Service, + T: IntoTransport, + E: std::error::Error + Send + Sync + 'static, +{ + tokio::select! { + result = serve_server_with_ct_inner(service, transport.into_transport(), ct.clone()) => { result } + _ = ct.cancelled() => { + Err(ServerInitializeError::Cancelled) + } + } +} + +/// Echoes the client-requested version if the server supports it; otherwise +/// returns `server_fallback`. +/// +/// `server_supported` comes from [`Service::supported_protocol_versions`], so a +/// server that narrows that list is never made to answer `initialize` with a +/// version it cannot serve. +pub(crate) fn negotiate_protocol_version( + client_requested: &ProtocolVersion, + server_fallback: ProtocolVersion, + server_supported: &[ProtocolVersion], +) -> ProtocolVersion { + if server_supported.contains(client_requested) { + client_requested.clone() + } else { + tracing::warn!( + client_requested = %client_requested, + server_fallback = %server_fallback, + "client requested unsupported protocol version; falling back to server default" + ); + server_fallback + } +} + +async fn serve_server_with_ct_inner( + service: S, + transport: T, + ct: CancellationToken, +) -> Result, ServerInitializeError> +where + S: Service, + T: Transport + 'static, +{ + let mut transport = transport.into_transport(); + let id_provider = >::default(); + + // Get initialize request; the MCP spec permits ping before initialize. + // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization + let (request, id) = loop { + let msg = expect_next_message(&mut transport, "initialize request").await?; + match msg { + ClientJsonRpcMessage::Request(req) + if matches!(req.request, ClientRequest::PingRequest(_)) => + { + transport + .send(ServerJsonRpcMessage::response( + ServerResult::EmptyResult(EmptyResult {}), + req.id, + )) + .await + .map_err(|error| { + ServerInitializeError::transport::( + error, + "sending pre-init ping response", + ) + })?; + } + ClientJsonRpcMessage::Request(req) => break (req.request, req.id), + other => { + return Err(ServerInitializeError::ExpectedInitializeRequest(Some( + other, + ))); + } + } + }; + + let initialize_request = match request { + ClientRequest::InitializeRequest(request) => request, + mut request => { + if !request + .get_meta() + .missing_required_keys(&ProtocolVersion::V_2026_07_28) + .is_empty() + { + return Err(ServerInitializeError::ExpectedInitializeRequest(Some( + ClientJsonRpcMessage::request(request, id), + ))); + } + let (peer, peer_rx) = Peer::new(id_provider, None); + peer.require_request_metadata(); + let context = RequestContext { + ct: ct.child_token(), + id: id.clone(), + meta: std::mem::take(request.get_meta_mut()), + extensions: std::mem::take(request.extensions_mut()), + peer: peer.clone(), + }; + let response = match service.handle_request(request, context).await { + Ok(result) => ServerJsonRpcMessage::response(result, id), + Err(error) => ServerJsonRpcMessage::error(error, Some(id)), + }; + transport.send(response).await.map_err(|error| { + ServerInitializeError::transport::(error, "sending negotiated request response") + })?; + return Ok(serve_inner(service, transport, peer, peer_rx, ct)); + } + }; + let requested_protocol_version = initialize_request.params.protocol_version.clone(); + let mut negotiated_peer_info = initialize_request.params.clone(); + let (peer, peer_rx) = Peer::new(id_provider, Some(negotiated_peer_info.clone())); + let request = ClientRequest::InitializeRequest(initialize_request); + let context = RequestContext { + ct: ct.child_token(), + id: id.clone(), + meta: request.get_meta().clone(), + extensions: request.extensions().clone(), + peer: peer.clone(), + }; + // Send initialize response + let init_response = service.handle_request(request, context).await; + let mut init_response = match init_response { + Ok(ServerResult::InitializeResult(init_response)) => init_response, + Ok(result) => { + return Err(ServerInitializeError::UnexpectedInitializeResponse(result)); + } + Err(e) => { + transport + .send(ServerJsonRpcMessage::error(e.clone(), Some(id))) + .await + .map_err(|error| { + ServerInitializeError::transport::(error, "sending error response") + })?; + return Err(ServerInitializeError::InitializeFailed(e)); + } + }; + init_response.protocol_version = negotiate_protocol_version( + &requested_protocol_version, + init_response.protocol_version, + &service.supported_protocol_versions(), + ); + // Update peer_info so context.protocol_version() reflects the negotiated + // version in all subsequent request handlers. + negotiated_peer_info.protocol_version = init_response.protocol_version.clone(); + peer.set_peer_info(negotiated_peer_info); + transport + .send(ServerJsonRpcMessage::response( + ServerResult::InitializeResult(init_response), + id, + )) + .await + .map_err(|error| { + ServerInitializeError::transport::(error, "sending initialize response") + })?; + + // Enter the main service loop immediately after sending InitializeResult. + // The initialized notification will be handled as a regular notification by serve_inner. + // This matches the TypeScript SDK behavior: no init gate, no waiting for initialized. + // Streamable HTTP has no ordering guarantee between POSTs, and the MCP spec uses + // SHOULD NOT (not MUST NOT) for pre-initialized messages, so any request arriving + // before initialized is processed normally. + Ok(serve_inner(service, transport, peer, peer_rx, ct)) +} + +macro_rules! method { + ($(#[$meta:meta])* peer_req $method:ident $Req:ident() => $Resp: ident ) => { + $(#[$meta])* + pub async fn $method(&self) -> Result<$Resp, ServiceError> { + let result = self + .send_request(ServerRequest::$Req($Req { + method: Default::default(), + extensions: Default::default(), + })) + .await?; + match result { + ClientResult::$Resp(result) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } + } + }; + ($(#[$meta:meta])* peer_req $method:ident $Req:ident($Param: ident) => $Resp: ident ) => { + $(#[$meta])* + pub async fn $method(&self, params: $Param) -> Result<$Resp, ServiceError> { + let result = self + .send_request(ServerRequest::$Req($Req { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await?; + match result { + ClientResult::$Resp(result) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } + } + }; + ($(#[$meta:meta])* peer_req $method:ident $Req:ident($Param: ident)) => { + $(#[$meta])* + pub fn $method( + &self, + params: $Param, + ) -> impl Future> + Send + '_ { + async move { + let result = self + .send_request(ServerRequest::$Req($Req { + method: Default::default(), + params, + })) + .await?; + match result { + ClientResult::EmptyResult(_) => Ok(()), + _ => Err(ServiceError::UnexpectedResponse), + } + } + } + }; + + ($(#[$meta:meta])* peer_not $method:ident $Not:ident($Param: ident)) => { + $(#[$meta])* + pub async fn $method(&self, params: $Param) -> Result<(), ServiceError> { + self.send_notification(ServerNotification::$Not($Not { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await?; + Ok(()) + } + }; + ($(#[$meta:meta])* peer_not $method:ident $Not:ident) => { + $(#[$meta])* + pub async fn $method(&self) -> Result<(), ServiceError> { + self.send_notification(ServerNotification::$Not($Not { + method: Default::default(), + extensions: Default::default(), + })) + .await?; + Ok(()) + } + }; + + // Timeout-only variants (base method should be created separately with peer_req) + ($(#[$meta:meta])* peer_req_with_timeout $method_with_timeout:ident $Req:ident() => $Resp: ident) => { + $(#[$meta])* + pub async fn $method_with_timeout( + &self, + timeout: Option, + ) -> Result<$Resp, ServiceError> { + let request = ServerRequest::$Req($Req { + method: Default::default(), + extensions: Default::default(), + }); + let options = crate::service::PeerRequestOptions { + timeout, + meta: None, + reset_timeout_on_progress: false, + max_total_timeout: None, + }; + let result = self + .send_request_with_option(request, options) + .await? + .await_response() + .await?; + match result { + ClientResult::$Resp(result) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } + } + }; + + ($(#[$meta:meta])* peer_req_with_timeout $method_with_timeout:ident $Req:ident($Param: ident) => $Resp: ident) => { + $(#[$meta])* + pub async fn $method_with_timeout( + &self, + params: $Param, + timeout: Option, + ) -> Result<$Resp, ServiceError> { + let request = ServerRequest::$Req($Req { + method: Default::default(), + params, + extensions: Default::default(), + }); + let options = crate::service::PeerRequestOptions { + timeout, + meta: None, + reset_timeout_on_progress: false, + max_total_timeout: None, + }; + let result = self + .send_request_with_option(request, options) + .await? + .await_response() + .await?; + match result { + ClientResult::$Resp(result) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } + } + }; +} + +impl Peer { + /// Check if the client supports sampling tools capability. + pub fn supports_sampling_tools(&self) -> bool { + if let Some(client_info) = self.peer_info() { + client_info + .capabilities + .sampling + .as_ref() + .and_then(|s| s.tools.as_ref()) + .is_some() + } else { + false + } + } + + /// # SEP-2260: request association + /// + /// From protocol version `2026-07-28` this must be issued while handling a + /// client request; see [`OriginatingRequestId`]. + #[deprecated( + since = "1.8.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" + )] + pub async fn create_message( + &self, + params: CreateMessageRequestParams, + ) -> Result { + // MUST throw error when tools/toolChoice provided without capability + if (params.tools.is_some() || params.tool_choice.is_some()) + && !self.supports_sampling_tools() + { + return Err(ServiceError::McpError(ErrorData::invalid_params( + "tools or toolChoice provided but client does not support sampling tools capability", + None, + ))); + } + // Validate message structure + params + .validate() + .map_err(|e| ServiceError::McpError(ErrorData::invalid_params(e, None)))?; + let result = self + .send_request(ServerRequest::CreateMessageRequest(CreateMessageRequest { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await?; + match result { + ClientResult::CreateMessageResult(result) => Ok(*result), + _ => Err(ServiceError::UnexpectedResponse), + } + } + method!( + /// # SEP-2260: request association + /// + /// From protocol version `2026-07-28` this must be issued while handling a + /// client request; see [`OriginatingRequestId`]. + #[deprecated( + since = "1.8.0", + note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" + )] + peer_req list_roots ListRootsRequest() => ListRootsResult + ); + #[cfg(feature = "elicitation")] + method!( + /// # SEP-2260: request association + /// + /// From protocol version `2026-07-28` this must be issued while handling a + /// client request; see [`OriginatingRequestId`]. + peer_req create_elicitation ElicitRequest(ElicitRequestParams) => ElicitResult + ); + #[cfg(feature = "elicitation")] + method!( + /// # SEP-2260: request association + /// + /// From protocol version `2026-07-28` this must be issued while handling a + /// client request; see [`OriginatingRequestId`]. + peer_req_with_timeout create_elicitation_with_timeout ElicitRequest(ElicitRequestParams) => ElicitResult + ); + + method!(peer_not notify_cancelled CancelledNotification(CancelledNotificationParam)); + method!(peer_not notify_progress ProgressNotification(ProgressNotificationParam)); + method!( + #[deprecated( + since = "1.8.0", + note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" + )] + peer_not notify_logging_message LoggingMessageNotification(LoggingMessageNotificationParam) + ); + method!(peer_not notify_resource_updated ResourceUpdatedNotification(ResourceUpdatedNotificationParam)); + method!(peer_not notify_resource_list_changed ResourceListChangedNotification); + method!(peer_not notify_tool_list_changed ToolListChangedNotification); + method!(peer_not notify_prompt_list_changed PromptListChangedNotification); +} + +// ============================================================================= +// ELICITATION CONVENIENCE METHODS +// These methods are specific to server role and provide typed elicitation functionality +// ============================================================================= + +/// Errors that can occur during typed elicitation operations +#[cfg(feature = "elicitation")] +#[derive(Error, Debug)] +#[non_exhaustive] +pub enum ElicitationError { + /// The elicitation request failed at the service level + #[error("Service error: {0}")] + Service(#[from] ServiceError), + + /// User explicitly declined to provide the requested information + /// This indicates a conscious decision by the user to reject the request + /// (e.g., clicked "Reject", "Decline", "No", etc.) + #[error("User explicitly declined the request")] + UserDeclined, + + /// User dismissed the request without making an explicit choice + /// This indicates the user cancelled without explicitly declining + /// (e.g., closed dialog, clicked outside, pressed Escape, etc.) + #[error("User cancelled/dismissed the request")] + UserCancelled, + + /// The response data could not be parsed into the requested type + #[error("Failed to parse response data: {error}\nReceived data: {data}")] + ParseError { + error: serde_json::Error, + data: serde_json::Value, + }, + + /// No response content was provided by the user + #[error("No response content provided")] + NoContent, + + /// Client does not support elicitation capability + #[error("Client does not support elicitation - capability not declared during initialization")] + CapabilityNotSupported, +} + +/// Marker trait to ensure that elicitation types generate object-type JSON schemas. +/// +/// This trait provides compile-time safety to ensure that types used with +/// `elicit()` methods will generate JSON schemas of type "object", which +/// aligns with MCP client expectations for structured data input. +/// +/// # Type Safety Rationale +/// +/// MCP clients typically expect JSON objects for elicitation schemas to +/// provide structured forms and validation. This trait prevents common +/// mistakes like: +/// +/// ```compile_fail +/// // These would not compile due to missing ElicitationSafe bound: +/// let name: String = server.elicit("Enter name").await?; // Primitive +/// let items: Vec = server.elicit("Enter items").await?; // Array +/// ``` +#[cfg(feature = "elicitation")] +pub trait ElicitationSafe: schemars::JsonSchema {} + +/// Macro to mark types as safe for elicitation by verifying they generate object schemas. +/// +/// This macro automatically implements the `ElicitationSafe` trait for struct types +/// that should be used with `elicit()` methods. +/// +/// # Example +/// +/// ```rust +/// use rmcp::elicit_safe; +/// use schemars::JsonSchema; +/// use serde::{Deserialize, Serialize}; +/// +/// #[derive(Serialize, Deserialize, JsonSchema)] +/// struct UserProfile { +/// name: String, +/// email: String, +/// } +/// +/// elicit_safe!(UserProfile); +/// +/// // Now safe to use in async context: +/// // let profile: UserProfile = server.elicit("Enter profile").await?; +/// ``` +#[cfg(feature = "elicitation")] +#[macro_export] +macro_rules! elicit_safe { + ($($t:ty),* $(,)?) => { + $( + impl $crate::service::ElicitationSafe for $t {} + )* + }; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum ElicitationMode { + Form, + Url, +} + +#[cfg(feature = "elicitation")] +impl Peer { + /// Check if the client supports elicitation capability + /// + /// Returns true if the client declared elicitation capability during initialization, + /// false otherwise. According to MCP 2025-06-18 specification, clients that support + /// elicitation MUST declare the capability during initialization. + pub fn supported_elicitation_modes(&self) -> HashSet { + if let Some(client_info) = self.peer_info() { + if let Some(elicit_capability) = &client_info.capabilities.elicitation { + let mut modes = HashSet::new(); + // Backward compatibility: if neither form nor url is specified, assume form + if elicit_capability.form.is_none() && elicit_capability.url.is_none() { + modes.insert(ElicitationMode::Form); + } else { + if elicit_capability.form.is_some() { + modes.insert(ElicitationMode::Form); + } + if elicit_capability.url.is_some() { + modes.insert(ElicitationMode::Url); + } + } + modes + } else { + HashSet::new() + } + } else { + HashSet::new() + } + } + + /// Request typed data from the user with automatic schema generation. + /// + /// This method automatically generates the JSON schema from the Rust type using `schemars`, + /// eliminating the need to manually create schemas. The response is automatically parsed + /// into the requested type. + /// + /// **Requires the `elicitation` feature to be enabled.** + /// + /// # Type Requirements + /// The type `T` must implement: + /// - `schemars::JsonSchema` - for automatic schema generation + /// - `serde::Deserialize` - for parsing the response + /// + /// # Arguments + /// * `message` - The prompt message for the user + /// + /// # Returns + /// * `Ok(Some(data))` if user provided valid data that matches type T + /// * `Err(ElicitationError::UserDeclined)` if user explicitly declined the request + /// * `Err(ElicitationError::UserCancelled)` if user cancelled/dismissed the request + /// * `Err(ElicitationError::ParseError { .. })` if response data couldn't be parsed into type T + /// * `Err(ElicitationError::NoContent)` if no response content was provided + /// * `Err(ElicitationError::Service(_))` if the underlying service call failed + /// + /// # Example + /// + /// Add to your `Cargo.toml`: + /// ```toml + /// [dependencies] + /// rmcp = { version = "0.3", features = ["elicitation"] } + /// serde = { version = "1.0", features = ["derive"] } + /// schemars = "1.0" + /// ``` + /// + /// ```rust,no_run + /// # use rmcp::*; + /// # use rmcp::service::ElicitationError; + /// # use serde::{Deserialize, Serialize}; + /// # use schemars::JsonSchema; + /// # + /// #[derive(Debug, Serialize, Deserialize, JsonSchema)] + /// struct UserProfile { + /// #[schemars(description = "Full name")] + /// name: String, + /// #[schemars(description = "Email address")] + /// email: String, + /// #[schemars(description = "Age")] + /// age: u8, + /// } + /// + /// // Mark as safe for elicitation (generates object schema) + /// rmcp::elicit_safe!(UserProfile); + /// + /// # async fn example(peer: Peer) -> Result<(), Box> { + /// match peer.elicit::("Please enter your profile information").await { + /// Ok(Some(profile)) => { + /// println!("Name: {}, Email: {}, Age: {}", profile.name, profile.email, profile.age); + /// } + /// Ok(None) => { + /// println!("User provided no content"); + /// } + /// Err(ElicitationError::UserDeclined) => { + /// println!("User explicitly declined to provide information"); + /// // Handle explicit decline - perhaps offer alternatives + /// } + /// Err(ElicitationError::UserCancelled) => { + /// println!("User cancelled the request"); + /// // Handle cancellation - perhaps prompt again later + /// } + /// Err(ElicitationError::ParseError { error, data }) => { + /// println!("Failed to parse response: {}\nData: {}", error, data); + /// } + /// Err(e) => return Err(e.into()), + /// } + /// # Ok(()) + /// # } + /// ``` + /// + /// # SEP-2260: request association + /// + /// From protocol version `2026-07-28` this must be issued while handling a + /// client request; see [`OriginatingRequestId`]. + #[cfg(all(feature = "schemars", feature = "elicitation"))] + pub async fn elicit(&self, message: impl Into) -> Result, ElicitationError> + where + T: ElicitationSafe + for<'de> serde::Deserialize<'de>, + { + self.elicit_with_timeout(message, None).await + } + + /// Request typed data from the user with custom timeout. + /// + /// Same as `elicit()` but allows specifying a custom timeout for the request. + /// If the user doesn't respond within the timeout, the request will be cancelled. + /// + /// # Arguments + /// * `message` - The prompt message for the user + /// * `timeout` - Optional timeout duration. If None, uses default timeout behavior + /// + /// # Returns + /// Same as `elicit()` but may also return `ServiceError::Timeout` if timeout expires + /// + /// # Example + /// ```rust,no_run + /// # use rmcp::*; + /// # use rmcp::service::ElicitationError; + /// # use serde::{Deserialize, Serialize}; + /// # use schemars::JsonSchema; + /// # use std::time::Duration; + /// # + /// #[derive(Debug, Serialize, Deserialize, JsonSchema)] + /// struct QuickResponse { + /// answer: String, + /// } + /// + /// // Mark as safe for elicitation + /// rmcp::elicit_safe!(QuickResponse); + /// + /// # async fn example(peer: Peer) -> Result<(), Box> { + /// // Give user 30 seconds to respond + /// let timeout = Some(Duration::from_secs(30)); + /// match peer.elicit_with_timeout::( + /// "Quick question - what's your answer?", + /// timeout + /// ).await { + /// Ok(Some(response)) => println!("Got answer: {}", response.answer), + /// Ok(None) => println!("User provided no content"), + /// Err(ElicitationError::UserDeclined) => { + /// println!("User explicitly declined"); + /// // Handle explicit decline + /// } + /// Err(ElicitationError::UserCancelled) => { + /// println!("User cancelled/dismissed"); + /// // Handle cancellation + /// } + /// Err(ElicitationError::Service(ServiceError::Timeout { .. })) => { + /// println!("User didn't respond in time"); + /// } + /// Err(e) => return Err(e.into()), + /// } + /// # Ok(()) + /// # } + /// ``` + /// + /// # SEP-2260: request association + /// + /// From protocol version `2026-07-28` this must be issued while handling a + /// client request; see [`OriginatingRequestId`]. + #[cfg(all(feature = "schemars", feature = "elicitation"))] + pub async fn elicit_with_timeout( + &self, + message: impl Into, + timeout: Option, + ) -> Result, ElicitationError> + where + T: ElicitationSafe + for<'de> serde::Deserialize<'de>, + { + // Check if client supports form elicitation capability + if !self + .supported_elicitation_modes() + .contains(&ElicitationMode::Form) + { + return Err(ElicitationError::CapabilityNotSupported); + } + + // Generate schema automatically from type + let schema = crate::model::ElicitationSchema::from_type::().map_err(|e| { + ElicitationError::Service(ServiceError::McpError(crate::ErrorData::invalid_params( + format!( + "Invalid schema for type {}: {}", + std::any::type_name::(), + e + ), + None, + ))) + })?; + + let response = self + .create_elicitation_with_timeout( + ElicitRequestParams::FormElicitationParams { + meta: None, + message: message.into(), + requested_schema: schema, + }, + timeout, + ) + .await?; + + match response.action { + crate::model::ElicitationAction::Accept => { + if let Some(value) = response.content { + match serde_json::from_value::(value.clone()) { + Ok(parsed) => Ok(Some(parsed)), + Err(error) => Err(ElicitationError::ParseError { error, data: value }), + } + } else { + Err(ElicitationError::NoContent) + } + } + crate::model::ElicitationAction::Decline => Err(ElicitationError::UserDeclined), + crate::model::ElicitationAction::Cancel => Err(ElicitationError::UserCancelled), + } + } + + /// Request the user to visit a URL and confirm completion. + /// + /// This method sends a URL elicitation request to the client, prompting the user + /// to visit the specified URL and confirm completion. It returns the user's action + /// (accept/decline/cancel) without any additional data. + /// **Requires the `elicitation` feature to be enabled.** + /// + /// # Arguments + /// * `message` - The prompt message for the user + /// * `url` - The URL the user is requested to visit + /// * `elicitation_id` - A unique identifier for this elicitation request + /// # Returns + /// * `Ok(action)` indicating the user's response action + /// * `Err(ElicitationError::CapabilityNotSupported)` if client does not support elicitation via URL + /// * `Err(ElicitationError::Service(_))` if the underlying service call failed + /// # Example + /// ```rust,no_run + /// # use rmcp::*; + /// # use rmcp::model::ElicitationAction; + /// # use url::Url; + /// + /// async fn example(peer: Peer) -> Result<(), Box> { + /// let elicit_result = peer.elicit_url("Please visit the following URL to complete the action", + /// Url::parse("https://example.com/complete_action")?, "elicit_123").await?; + /// match elicit_result { + /// ElicitationAction::Accept => { + /// println!("User accepted and confirmed completion"); + /// } + /// ElicitationAction::Decline => { + /// println!("User declined the request"); + /// } + /// ElicitationAction::Cancel => { + /// println!("User cancelled/dismissed the request"); + /// } + /// _ => {} + /// } + /// Ok(()) + /// } + /// ``` + /// + /// # SEP-2260: request association + /// + /// From protocol version `2026-07-28` this must be issued while handling a + /// client request; see [`OriginatingRequestId`]. + #[cfg(feature = "elicitation")] + pub async fn elicit_url( + &self, + message: impl Into, + url: impl Into, + elicitation_id: impl Into, + ) -> Result { + self.elicit_url_with_timeout(message, url, elicitation_id, None) + .await + } + + /// Request the user to visit a URL and confirm completion. + /// + /// Same as `elicit_url()` but allows specifying a custom timeout for the request. + /// + /// # Arguments + /// * `message` - The prompt message for the user + /// * `url` - The URL the user is requested to visit + /// * `elicitation_id` - A unique identifier for this elicitation request + /// * `timeout` - Optional timeout duration. If None, uses default timeout behavior + /// # Returns + /// * `Ok(action)` indicating the user's response action + /// * `Err(ElicitationError::CapabilityNotSupported)` if client does not support elicitation via URL + /// * `Err(ElicitationError::Service(_))` if the underlying service call failed + /// # Example + /// ```rust,no_run + /// # use std::time::Duration; + /// use rmcp::*; + /// # use rmcp::model::ElicitationAction; + /// # use url::Url; + /// + /// async fn example(peer: Peer) -> Result<(), Box> { + /// let elicit_result = peer.elicit_url_with_timeout("Please visit the following URL to complete the action", + /// Url::parse("https://example.com/complete_action")?, + /// "elicit_123", + /// Some(Duration::from_secs(30))).await?; + /// match elicit_result { + /// ElicitationAction::Accept => { + /// println!("User accepted and confirmed completion"); + /// } + /// ElicitationAction::Decline => { + /// println!("User declined the request"); + /// } + /// ElicitationAction::Cancel => { + /// println!("User cancelled/dismissed the request"); + /// } + /// _ => {} + /// } + /// Ok(()) + /// } + /// ``` + /// + /// # SEP-2260: request association + /// + /// From protocol version `2026-07-28` this must be issued while handling a + /// client request; see [`OriginatingRequestId`]. + #[cfg(feature = "elicitation")] + pub async fn elicit_url_with_timeout( + &self, + message: impl Into, + url: impl Into, + elicitation_id: impl Into, + timeout: Option, + ) -> Result { + // Check if client supports url elicitation + if !self + .supported_elicitation_modes() + .contains(&ElicitationMode::Url) + { + return Err(ElicitationError::CapabilityNotSupported); + } + + let action = self + .create_elicitation_with_timeout( + ElicitRequestParams::UrlElicitationParams { + meta: None, + message: message.into(), + url: url.into().to_string(), + elicitation_id: elicitation_id.into(), + }, + timeout, + ) + .await? + .action; + Ok(action) + } +} diff --git a/vendor/rmcp/src/service/tower.rs b/vendor/rmcp/src/service/tower.rs new file mode 100644 index 000000000..867d3e7fa --- /dev/null +++ b/vendor/rmcp/src/service/tower.rs @@ -0,0 +1,54 @@ +use std::{future::poll_fn, marker::PhantomData}; + +use tower_service::Service as TowerService; + +use super::NotificationContext; +use crate::service::{MaybeSendFuture, RequestContext, Service, ServiceRole}; + +pub struct TowerHandler { + pub service: S, + pub info: R::Info, + role: PhantomData, +} + +impl TowerHandler { + pub fn new(service: S, info: R::Info) -> Self { + Self { + service, + role: PhantomData, + info, + } + } +} + +impl Service for TowerHandler +where + S: TowerService + Sync + Send + Clone + 'static, + S::Error: Into, + S::Future: Send, +{ + async fn handle_request( + &self, + request: R::PeerReq, + _context: RequestContext, + ) -> Result { + let mut service = self.service.clone(); + poll_fn(|cx| service.poll_ready(cx)) + .await + .map_err(Into::into)?; + let resp = service.call(request).await.map_err(Into::into)?; + Ok(resp) + } + + fn handle_notification( + &self, + _notification: R::PeerNot, + _context: NotificationContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Ok(())) + } + + fn get_info(&self) -> R::Info { + self.info.clone() + } +} diff --git a/vendor/rmcp/src/task_manager.rs b/vendor/rmcp/src/task_manager.rs new file mode 100644 index 000000000..0e8339c0a --- /dev/null +++ b/vendor/rmcp/src/task_manager.rs @@ -0,0 +1,998 @@ +//! Server-side runtime for the MCP Tasks extension (SEP-2663, +//! `io.modelcontextprotocol/tasks`). +//! +//! [`TaskManager`] owns the durable state for tasks a server has materialized +//! in response to task-eligible requests (currently `tools/call`). It: +//! +//! - spawns the underlying operation and tracks its lifecycle as a +//! [`DetailedTask`] (`working` → terminal, optionally via `input_required`), +//! - answers `tasks/get` with the current state (including in-flight +//! `inputRequests` and terminal `result`/`error` payloads), +//! - accepts `tasks/update` `inputResponses` and routes them to the running +//! operation (ignoring unknown or already-answered keys per spec), +//! - handles cooperative `tasks/cancel`, +//! - enforces TTL-based expiry (`ttl_ms`), marking overdue tasks `failed`. +//! +//! Tasks are only durably observable once [`TaskManager::spawn`] returns, +//! satisfying the spec requirement that a server not return `CreateTaskResult` +//! before `tasks/get` for that id would resolve. + +use std::{ + collections::HashMap, + pin::Pin, + sync::{Arc, Mutex}, + time::Instant, +}; + +use futures::Future; +use tokio::sync::oneshot; + +use crate::{ + error::ErrorData as McpError, + model::{ + CallToolResult, DetailedTask, InputRequest, InputRequests, JsonObject, Task, TaskPayload, + TaskStatus, + }, +}; + +/// Default TTL (5 minutes, in milliseconds) applied when none is specified. +pub const DEFAULT_TASK_TTL_MS: u64 = 300_000; + +/// Default suggested polling interval, in milliseconds. +pub const DEFAULT_POLL_INTERVAL_MS: u64 = 1_000; + +/// Helper to generate an ISO 8601 timestamp for task metadata. +pub fn current_timestamp() -> String { + chrono::Utc::now().to_rfc3339() +} + +/// Handle passed to a running task operation, allowing it to surface +/// server-to-client requests (elicitation, sampling, roots) mid-task and +/// await the client's `tasks/update` response. +#[derive(Clone)] +pub struct TaskContext { + task_id: String, + inner: Arc>, +} + +impl TaskContext { + /// The id of the task this context belongs to. + pub fn task_id(&self) -> &str { + &self.task_id + } + + /// Surface a server-to-client request under `key` and wait for the + /// client's response delivered via `tasks/update`. + /// + /// While at least one request is outstanding the task reports + /// `input_required` from `tasks/get`, with all outstanding requests in + /// `inputRequests`. Keys must be unique over the lifetime of the task; + /// reusing a key returns an error. + pub async fn request_input( + &self, + key: impl Into, + request: InputRequest, + ) -> Result { + let key = key.into(); + let (tx, rx) = oneshot::channel(); + { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + let entry = inner.tasks.get_mut(&self.task_id).ok_or_else(|| { + TaskExit::Error(McpError::internal_error( + "task no longer exists".to_string(), + None, + )) + })?; + if !entry.used_input_keys.insert(key.clone()) { + return Err(TaskExit::Error(McpError::internal_error( + format!("inputRequests key {key:?} was already used for this task"), + None, + ))); + } + entry.pending_inputs.insert(key.clone(), (request, tx)); + entry.touch(); + } + // The sender is dropped when `tasks/cancel` clears pending inputs. + rx.await.map_err(|_| TaskExit::Cancelled) + } + + /// Update the task's human-readable status message. + pub fn set_status_message(&self, message: impl Into) { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + if let Some(entry) = inner.tasks.get_mut(&self.task_id) { + entry.task.status_message = Some(message.into()); + entry.touch(); + } + } + + /// Returns `true` if `tasks/cancel` has been received for this task. + /// Cooperative: operations should check this and stop when set. + pub fn is_cancel_requested(&self) -> bool { + let inner = self.inner.lock().expect("task manager lock poisoned"); + inner + .tasks + .get(&self.task_id) + .is_some_and(|e| e.cancel_requested) + } + + /// Resolves once `tasks/cancel` has been received for this task (or + /// immediately, if it already has). Cooperative: pair with + /// `tokio::select!` around long-running work to implement a cancellation + /// exit path. + /// + /// An operation that stops in response should return + /// [`TaskExit::Cancelled`] so the task settles as `cancelled`. Returning + /// [`TaskExit::Error`] settles as `failed`, and finishing the work + /// anyway settles as `completed` — per SEP-2663 cancellation is + /// cooperative and a task may reach a non-`cancelled` terminal status. + pub async fn cancelled(&self) { + let mut rx = { + let inner = self.inner.lock().expect("task manager lock poisoned"); + let Some(entry) = inner.tasks.get(&self.task_id) else { + return; + }; + if entry.cancel_requested { + return; + } + entry.cancel_signal.subscribe() + }; + // Wait until the watch flips to true; a closed channel means the + // manager dropped the entry, which also unblocks the operation. + while !*rx.borrow_and_update() { + if rx.changed().await.is_err() { + return; + } + } + } +} + +/// How a task operation finished without producing a result. +#[expect( + clippy::exhaustive_enums, + reason = "error variant for task exit may only be due to error or cancellation" +)] +#[derive(Debug)] +pub enum TaskExit { + /// The operation is exiting in response to a cancellation request; + /// the task settles as terminal `cancelled`. + Cancelled, + /// A real failure; the task settles as terminal `failed` with the + /// error inlined, even after `tasks/cancel` was received. + Error(McpError), +} + +impl From for TaskExit { + fn from(error: McpError) -> Self { + TaskExit::Error(error) + } +} + +/// Boxed future representing the async operation backing a task. +pub type TaskFuture = Pin> + Send>>; + +struct TaskEntry { + task: Task, + /// Terminal payload, if the task has finished. + terminal: Option, + /// When the task reached its terminal state; drives retention eviction. + terminal_at: Option, + /// Outstanding input requests keyed by their unique identifier. + pending_inputs: HashMap)>, + /// Every key ever used, to enforce uniqueness across the task lifetime. + used_input_keys: std::collections::HashSet, + cancel_requested: bool, + /// Signals the running operation that cancellation was requested + /// (`true` once `tasks/cancel` arrives). Cooperative: the operation + /// decides whether and how to stop. + cancel_signal: tokio::sync::watch::Sender, + created: Instant, + join_handle: Option>, +} + +impl TaskEntry { + fn touch(&mut self) { + self.task.last_updated_at = current_timestamp(); + } + + fn current_status(&self) -> TaskStatus { + match &self.terminal { + Some(payload) => payload.status(), + None if !self.pending_inputs.is_empty() => TaskStatus::InputRequired, + None => TaskStatus::Working, + } + } + + fn detailed(&self) -> DetailedTask { + let payload = match &self.terminal { + Some(p) => p.clone(), + None if !self.pending_inputs.is_empty() => TaskPayload::InputRequired { + input_requests: self + .pending_inputs + .iter() + .map(|(k, (req, _))| (k.clone(), req.clone())) + .collect::(), + }, + None => TaskPayload::Working, + }; + DetailedTask::new(self.task.clone(), payload) + } +} + +#[derive(Default)] +struct TaskManagerInner { + tasks: HashMap, +} + +/// Options controlling a spawned task. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct TaskOptions { + /// TTL in milliseconds; `None` means unlimited retention. + pub ttl_ms: Option, + /// Suggested polling interval in milliseconds. + pub poll_interval_ms: Option, + /// Initial status message. + pub status_message: Option, +} + +impl Default for TaskOptions { + fn default() -> Self { + Self { + ttl_ms: Some(DEFAULT_TASK_TTL_MS), + poll_interval_ms: Some(DEFAULT_POLL_INTERVAL_MS), + status_message: None, + } + } +} + +impl TaskOptions { + pub fn new() -> Self { + Self::default() + } + + /// Set the TTL in milliseconds. `None` means unlimited retention. + pub fn with_ttl_ms(mut self, ttl_ms: impl Into>) -> Self { + self.ttl_ms = ttl_ms.into(); + self + } + + /// Set the suggested polling interval in milliseconds. + pub fn with_poll_interval_ms(mut self, poll_interval_ms: u64) -> Self { + self.poll_interval_ms = Some(poll_interval_ms); + self + } + + /// Set the initial status message. + pub fn with_status_message(mut self, message: impl Into) -> Self { + self.status_message = Some(message.into()); + self + } +} + +/// Server-side task store and executor for the SEP-2663 Tasks extension. +/// +/// Cheaply cloneable; all clones share the same state. +/// +/// # Retention +/// +/// Entries are swept opportunistically on every `spawn` / `get_task` / +/// `update_task` / `cancel_task` / `running_task_count` call: non-terminal +/// tasks whose `ttl_ms` has elapsed are marked `failed` (their operation is +/// aborted), and terminal tasks are evicted after being retained for one +/// further `ttl_ms` window past their terminal transition so pollers can +/// observe the final state. +/// +/// Note that the retention window intentionally extends past the +/// creation-based lifetime that `ttl_ms` advertises on the wire: a task that +/// runs to its TTL deadline is marked `failed` around `created + ttl_ms` and +/// stays observable until roughly `created + 2 × ttl_ms`. This is compliant — +/// SEP-2663 lets servers delete expired tasks *at any time* after the TTL, +/// so retaining them longer as an observation grace period is a server-side +/// policy choice, not a wire-contract change. Clients may treat the task as +/// unusable after `createdAt + ttlMs` regardless. +/// +/// Tasks with `ttl_ms: None` are retained for the lifetime of the manager +/// (spec: unlimited retention) — bound task creation or call +/// [`Self::shutdown`] yourself if you spawn such tasks in a long-lived +/// server. There is no background sweeper; an idle manager holds its entries +/// until the next call. +#[derive(Clone, Default)] +pub struct TaskManager { + inner: Arc>, +} + +impl TaskManager { + pub fn new() -> Self { + Self::default() + } + + /// Spawn an operation as a task and return its seed [`Task`] state for a + /// `CreateTaskResult`. The task is durably observable via + /// [`Self::get_task`] before this method returns. + /// + /// `make_future` receives a [`TaskContext`] for mid-task input requests, + /// status messages, and cooperative cancellation checks. + pub fn spawn(&self, options: TaskOptions, make_future: F) -> Task + where + F: FnOnce(TaskContext) -> TaskFuture, + { + let task_id = uuid::Uuid::new_v4().to_string(); + let now = current_timestamp(); + let mut task = Task::new(task_id.clone(), TaskStatus::Working, now.clone(), now); + task.ttl_ms = options.ttl_ms; + task.poll_interval_ms = options.poll_interval_ms; + task.status_message = options.status_message; + + let entry = TaskEntry { + task: task.clone(), + terminal: None, + terminal_at: None, + pending_inputs: HashMap::new(), + used_input_keys: std::collections::HashSet::new(), + cancel_requested: false, + cancel_signal: tokio::sync::watch::channel(false).0, + created: Instant::now(), + join_handle: None, + }; + { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + // Opportunistic TTL sweep on every task creation, so terminal + // entries are evicted even if clients never poll again. + Self::sweep_expired(&mut inner); + inner.tasks.insert(task_id.clone(), entry); + } + + let context = TaskContext { + task_id: task_id.clone(), + inner: self.inner.clone(), + }; + let future = make_future(context); + let inner = self.inner.clone(); + let id_for_task = task_id.clone(); + let originating_request = crate::service::ORIGINATING_REQUEST + .try_with(|id| id.clone()) + .ok(); + let handle = tokio::spawn(async move { + let result = run_task_operation(originating_request, future).await; + let mut inner = inner.lock().expect("task manager lock poisoned"); + if let Some(entry) = inner.tasks.get_mut(&id_for_task) { + if entry.terminal.is_none() { + entry.terminal = Some(match result { + Ok(result) => TaskPayload::Completed { + result: result_to_object(&result), + }, + Err(TaskExit::Cancelled) => TaskPayload::Cancelled, + Err(TaskExit::Error(error)) => TaskPayload::Failed { + error: error_to_object(&error), + }, + }); + entry.terminal_at = Some(Instant::now()); + entry.pending_inputs.clear(); + entry.touch(); + entry.task.status = entry.current_status(); + } + // The operation has finished; drop the JoinHandle so it is + // not retained for the rest of the retention window. + entry.join_handle = None; + } + }); + match self + .inner + .lock() + .expect("task manager lock poisoned") + .tasks + .get_mut(&task_id) + { + // Only store the handle while the operation is still running: if + // it already settled, the completion path above ran first and a + // stored handle would never be cleared. + Some(entry) => { + if entry.terminal.is_none() { + entry.join_handle = Some(handle); + } + } + // The entry is gone: shutdown() drained the map between the + // insert and here. Abort rather than leak a detached operation. + None => handle.abort(), + } + task + } + + /// Handle `tasks/get`: return the current [`DetailedTask`] state. + pub fn get_task(&self, task_id: &str) -> Result { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + Self::sweep_expired(&mut inner); + let entry = inner + .tasks + .get_mut(task_id) + .ok_or_else(|| unknown_task(task_id))?; + entry.task.status = entry.current_status(); + Ok(entry.detailed()) + } + + /// Handle `tasks/update`: deliver `inputResponses` to the running + /// operation. Unknown, already-answered, or superseded keys are ignored + /// per spec; a partial set of responses is accepted. + pub fn update_task( + &self, + task_id: &str, + input_responses: impl IntoIterator, + ) -> Result<(), McpError> { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + Self::sweep_expired(&mut inner); + let entry = inner + .tasks + .get_mut(task_id) + .ok_or_else(|| unknown_task(task_id))?; + for (key, value) in input_responses { + if let Some((_, tx)) = entry.pending_inputs.remove(&key) { + // Receiver dropped means the operation moved on; ignore. + let _ = tx.send(value); + } + } + entry.touch(); + entry.task.status = entry.current_status(); + Ok(()) + } + + /// Handle `tasks/cancel`: cooperative cancellation (SEP-2663). + /// + /// Records the cancellation *intent* and acknowledges immediately, but + /// does **not** abort the underlying future or force a terminal state. + /// The operation observes cancellation via + /// [`TaskContext::is_cancel_requested`] / [`TaskContext::cancelled`], or + /// via the error returned from a pending [`TaskContext::request_input`] + /// call (whose response channel is dropped here), and decides its own + /// terminal status: + /// + /// - stops with [`TaskExit::Cancelled`] → recorded as `cancelled`, + /// - stops with [`TaskExit::Error`] → recorded as `failed` with the + /// error inlined (a real failure after a cancel request is not masked), + /// - finishes its work anyway → recorded as `completed` — per the spec, + /// "the task may still reach a non-`cancelled` terminal status". + pub fn cancel_task(&self, task_id: &str) -> Result<(), McpError> { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + Self::sweep_expired(&mut inner); + let entry = inner + .tasks + .get_mut(task_id) + .ok_or_else(|| unknown_task(task_id))?; + entry.cancel_requested = true; + let _ = entry.cancel_signal.send(true); + if entry.terminal.is_none() { + // Wake any operation parked on `request_input`: dropping the + // response senders resolves those awaits with an error, giving + // parked operations a cooperative exit path. The task leaves + // `input_required` and reports `working` until it settles. + entry.pending_inputs.clear(); + entry.touch(); + entry.task.status = entry.current_status(); + } + Ok(()) + } + + /// Number of tasks currently in a non-terminal state. + pub fn running_task_count(&self) -> usize { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + Self::sweep_expired(&mut inner); + inner + .tasks + .values() + .filter(|e| e.terminal.is_none()) + .count() + } + + /// Abort all running tasks and clear all task state. + pub fn shutdown(&self) { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + for (_, mut entry) in inner.tasks.drain() { + if let Some(handle) = entry.join_handle.take() { + handle.abort(); + } + } + } + + /// TTL sweep, run from every `TaskManager` entry point (SEP-2663: servers + /// MAY mark a task `failed` any time after its TTL elapses, and + /// subsequently delete it at any time; `ttl_ms: None` means unlimited + /// retention). + /// + /// Two phases per entry: + /// 1. A non-terminal task whose TTL has elapsed is marked `failed` (its + /// operation is aborted — the TTL is the SDK's hard-stop safety valve, + /// unlike cooperative `tasks/cancel`). + /// 2. A *terminal* task is evicted once it has been retained for a full + /// TTL window after reaching its terminal state, so well-behaved + /// pollers get a chance to observe the final status before late + /// `tasks/get` calls start returning `-32602`. + fn sweep_expired(inner: &mut TaskManagerInner) { + // Phase 1: fail overdue non-terminal tasks. + for entry in inner.tasks.values_mut() { + if entry.terminal.is_none() + && let Some(ttl_ms) = entry.task.ttl_ms + && entry.created.elapsed().as_millis() >= u128::from(ttl_ms) + { + if let Some(handle) = entry.join_handle.take() { + handle.abort(); + } + entry.terminal = Some(TaskPayload::Failed { + error: error_to_object(&McpError::internal_error( + "task expired: TTL elapsed before completion".to_string(), + None, + )), + }); + entry.terminal_at = Some(Instant::now()); + entry.pending_inputs.clear(); + entry.touch(); + entry.task.status = TaskStatus::Failed; + } + } + // Phase 2: evict terminal tasks whose retention window has passed. + inner.tasks.retain(|_, entry| { + let (Some(ttl_ms), Some(terminal_at)) = (entry.task.ttl_ms, entry.terminal_at) else { + return true; + }; + terminal_at.elapsed().as_millis() < u128::from(ttl_ms) + }); + } +} + +fn unknown_task(task_id: &str) -> McpError { + McpError::invalid_params(format!("unknown task: {task_id}"), None) +} + +async fn run_task_operation( + originating_request: Option, + future: TaskFuture, +) -> Result { + match originating_request { + Some(id) => crate::service::ORIGINATING_REQUEST.scope(id, future).await, + None => future.await, + } +} + +fn result_to_object(result: &CallToolResult) -> JsonObject { + match serde_json::to_value(result) { + Ok(serde_json::Value::Object(map)) => map, + _ => JsonObject::new(), + } +} + +fn error_to_object(error: &McpError) -> JsonObject { + match serde_json::to_value(error) { + Ok(serde_json::Value::Object(map)) => map, + _ => JsonObject::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::ContentBlock; + + fn ok_result(text: &str) -> CallToolResult { + CallToolResult::success(vec![ContentBlock::text(text.to_string())]) + } + + #[tokio::test] + async fn task_completes_and_result_is_inlined() { + let manager = TaskManager::new(); + let task = manager.spawn(TaskOptions::default(), |_ctx| { + Box::pin(async { Ok(ok_result("42")) }) + }); + assert_eq!(task.status, TaskStatus::Working); + + // Durable immediately. + manager.get_task(&task.task_id).unwrap(); + + // Wait for completion. + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let detailed = manager.get_task(&task.task_id).unwrap(); + if detailed.status() == TaskStatus::Completed { + match detailed.payload { + TaskPayload::Completed { result } => { + assert!(result.contains_key("content")); + return; + } + other => panic!("unexpected payload: {other:?}"), + } + } + } + panic!("task did not complete"); + } + + #[tokio::test] + async fn cancel_settles_to_cancelled_when_operation_honors_it() { + let manager = TaskManager::new(); + let task = manager.spawn(TaskOptions::default(), |ctx| { + Box::pin(async move { + tokio::select! { + _ = ctx.cancelled() => Err(TaskExit::Cancelled), + _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => { + Ok(ok_result("never")) + } + } + }) + }); + manager.cancel_task(&task.task_id).unwrap(); + + // The ack is immediate but the terminal state is set by the + // operation; poll until it settles. + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let detailed = manager.get_task(&task.task_id).unwrap(); + if detailed.status().is_terminal() { + assert_eq!(detailed.status(), TaskStatus::Cancelled); + return; + } + } + panic!("task did not settle after cancel"); + } + + #[tokio::test] + async fn post_cancel_unrelated_error_settles_as_failed() { + let manager = TaskManager::new(); + let task = manager.spawn(TaskOptions::default(), |ctx| { + Box::pin(async move { + // Fail for an unrelated reason after observing the cancel: + // must be recorded as `failed`, not masked as `cancelled`. + ctx.cancelled().await; + Err(TaskExit::Error(McpError::internal_error( + "database write failed", + None, + ))) + }) + }); + manager.cancel_task(&task.task_id).unwrap(); + + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let detailed = manager.get_task(&task.task_id).unwrap(); + if detailed.status().is_terminal() { + assert_eq!(detailed.status(), TaskStatus::Failed); + match detailed.payload { + TaskPayload::Failed { error } => { + assert!( + error.get("message").is_some_and(|m| m + .as_str() + .is_some_and(|s| s.contains("database write failed"))), + "error payload should be preserved: {error:?}" + ); + } + other => panic!("unexpected payload: {other:?}"), + } + return; + } + } + panic!("task did not settle after cancel"); + } + + #[tokio::test] + async fn cancel_is_cooperative_and_lets_the_operation_clean_up() { + let manager = TaskManager::new(); + let (cleanup_tx, cleanup_rx) = oneshot::channel::<&'static str>(); + let task = manager.spawn(TaskOptions::default(), |ctx| { + Box::pin(async move { + // Wait for cancellation, then run cleanup and finish the + // work anyway (spec: a task may still reach a + // non-`cancelled` terminal status). + ctx.cancelled().await; + let _ = cleanup_tx.send("cleaned up"); + Ok(ok_result("finished despite cancel")) + }) + }); + + manager.cancel_task(&task.task_id).unwrap(); + + // The ack is immediate and does not force a terminal state. + let detailed = manager.get_task(&task.task_id).unwrap(); + assert!( + !detailed.status().is_terminal(), + "cancel must not force terminal state" + ); + + // The operation observes the cancel and performs cleanup. + let cleanup = tokio::time::timeout(std::time::Duration::from_secs(5), cleanup_rx) + .await + .expect("cleanup should not time out") + .expect("cleanup channel should not be dropped"); + assert_eq!(cleanup, "cleaned up"); + + // The operation chose to complete: the task settles as `completed`. + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let detailed = manager.get_task(&task.task_id).unwrap(); + if detailed.status().is_terminal() { + assert_eq!(detailed.status(), TaskStatus::Completed); + return; + } + } + panic!("task did not settle after cancel"); + } + + #[tokio::test] + async fn cancel_wakes_parked_input_requests() { + let manager = TaskManager::new(); + let (exit_tx, exit_rx) = oneshot::channel::<&'static str>(); + let task = manager.spawn(TaskOptions::default(), |ctx| { + Box::pin(async move { + let request: InputRequest = serde_json::from_value(serde_json::json!({ + "method": "elicitation/create", + "params": { + "message": "Waiting forever", + "requestedSchema": {"type": "object", "properties": {}} + } + })) + .map_err(|e| McpError::internal_error(e.to_string(), None))?; + // Parked on input; cancel must wake this await with an error. + let err = ctx.request_input("k1", request).await.unwrap_err(); + let _ = exit_tx.send("woken"); + Err(err) + }) + }); + + // Wait until the task is parked on the input request. + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + if manager.get_task(&task.task_id).unwrap().status() == TaskStatus::InputRequired { + break; + } + } + + manager.cancel_task(&task.task_id).unwrap(); + let woken = tokio::time::timeout(std::time::Duration::from_secs(5), exit_rx) + .await + .expect("parked operation should be woken by cancel") + .expect("exit channel should not be dropped"); + assert_eq!(woken, "woken"); + assert_eq!( + manager.get_task(&task.task_id).unwrap().status(), + TaskStatus::Cancelled + ); + } + + #[tokio::test] + async fn unknown_task_is_invalid_params() { + // SEP-2663 §Protocol Errors: invalid or nonexistent taskId is -32602 + // (Invalid params) — MUST for tasks/get, SHOULD for update/cancel. + let manager = TaskManager::new(); + for err in [ + manager.get_task("nope").unwrap_err(), + manager.cancel_task("nope").unwrap_err(), + manager.update_task("nope", []).unwrap_err(), + ] { + assert_eq!(err.code, crate::model::ErrorCode::INVALID_PARAMS); + } + } + + #[tokio::test] + async fn terminal_tasks_are_evicted_after_retention_window() { + let manager = TaskManager::new(); + let task = manager.spawn(TaskOptions::new().with_ttl_ms(50), |_ctx| { + Box::pin(async { Ok(ok_result("fast")) }) + }); + + // Wait for completion; the terminal state stays observable during + // the retention window. + let mut completed = false; + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + if manager.get_task(&task.task_id).unwrap().status() == TaskStatus::Completed { + completed = true; + break; + } + } + assert!(completed, "task should have completed"); + + // After a full TTL window past terminal, the entry is evicted and + // late polls get -32602. + tokio::time::sleep(std::time::Duration::from_millis(120)).await; + let err = manager.get_task(&task.task_id).unwrap_err(); + assert_eq!(err.code, crate::model::ErrorCode::INVALID_PARAMS); + assert_eq!(manager.running_task_count(), 0); + } + + #[tokio::test] + async fn abandoned_tasks_are_swept_by_other_entry_points() { + // A task nobody ever polls again must still be failed + evicted; the + // sweep runs from spawn() too, so activity on *other* tasks is enough. + let manager = TaskManager::new(); + let abandoned = manager.spawn(TaskOptions::new().with_ttl_ms(10), |_ctx| { + Box::pin(async { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + Ok(ok_result("never")) + }) + }); + + // Let TTL elapse (fails the task), then a second full window + // (evicts it), without ever calling get_task on the abandoned id. + tokio::time::sleep(std::time::Duration::from_millis(40)).await; + let _ = manager.spawn(TaskOptions::default(), |_ctx| { + Box::pin(async { Ok(ok_result("other")) }) + }); + tokio::time::sleep(std::time::Duration::from_millis(40)).await; + let _ = manager.spawn(TaskOptions::default(), |_ctx| { + Box::pin(async { Ok(ok_result("other2")) }) + }); + + let err = manager.get_task(&abandoned.task_id).unwrap_err(); + assert_eq!(err.code, crate::model::ErrorCode::INVALID_PARAMS); + } + + #[tokio::test] + async fn running_task_count_sweeps_expired_tasks() { + let manager = TaskManager::new(); + let _task = manager.spawn(TaskOptions::new().with_ttl_ms(10), |_ctx| { + Box::pin(async { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + Ok(ok_result("never")) + }) + }); + assert_eq!(manager.running_task_count(), 1); + tokio::time::sleep(std::time::Duration::from_millis(30)).await; + // The count itself must sweep: the overdue task is failed, not + // reported as running. + assert_eq!(manager.running_task_count(), 0); + } + + #[tokio::test] + async fn unlimited_ttl_tasks_are_retained() { + let manager = TaskManager::new(); + let task = manager.spawn(TaskOptions::new().with_ttl_ms(None), |_ctx| { + Box::pin(async { Ok(ok_result("kept")) }) + }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + // Sweeps triggered by other entry points must not evict it. + let _ = manager.spawn(TaskOptions::default(), |_ctx| { + Box::pin(async { Ok(ok_result("other")) }) + }); + assert_eq!( + manager.get_task(&task.task_id).unwrap().status(), + TaskStatus::Completed + ); + } + + #[tokio::test] + async fn ttl_expiry_fails_task() { + let manager = TaskManager::new(); + let task = manager.spawn( + TaskOptions { + ttl_ms: Some(10), + ..Default::default() + }, + |_ctx| { + Box::pin(async { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + Ok(ok_result("never")) + }) + }, + ); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let detailed = manager.get_task(&task.task_id).unwrap(); + assert_eq!(detailed.status(), TaskStatus::Failed); + } + + #[tokio::test] + async fn input_required_roundtrip() { + let manager = TaskManager::new(); + let task = manager.spawn(TaskOptions::default(), |ctx| { + Box::pin(async move { + let request: InputRequest = serde_json::from_value(serde_json::json!({ + "method": "elicitation/create", + "params": { + "message": "What is your name?", + "requestedSchema": {"type": "object", "properties": {}} + } + })) + .map_err(|e| McpError::internal_error(e.to_string(), None))?; + let response = ctx.request_input("name-1", request).await?; + let name = response + .get("content") + .and_then(|c| c.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + Ok(ok_result(&format!("hello {name}"))) + }) + }); + + // Wait for the task to surface the input request. + let mut saw_input_required = false; + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let detailed = manager.get_task(&task.task_id).unwrap(); + if let TaskPayload::InputRequired { input_requests } = &detailed.payload { + assert!(input_requests.contains_key("name-1")); + saw_input_required = true; + break; + } + } + assert!(saw_input_required, "task never reached input_required"); + + // Respond via tasks/update. + manager + .update_task( + &task.task_id, + [( + "name-1".to_string(), + serde_json::json!({"action": "accept", "content": {"name": "Ada"}}), + )], + ) + .unwrap(); + + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let detailed = manager.get_task(&task.task_id).unwrap(); + if detailed.status() == TaskStatus::Completed { + return; + } + } + panic!("task did not complete after input response"); + } + + #[tokio::test] + async fn task_operation_reestablishes_request_association_scope() { + use crate::{ + model::RequestId, + service::{ORIGINATING_REQUEST, in_request_handler_scope}, + }; + + let manager = TaskManager::new(); + let observed = Arc::new(Mutex::new(None::)); + let observed_in_task = observed.clone(); + + ORIGINATING_REQUEST + .scope(RequestId::Number(7), async { + manager.spawn(TaskOptions::default(), move |_ctx| { + let observed_in_task = observed_in_task.clone(); + Box::pin(async move { + *observed_in_task.lock().unwrap() = Some(in_request_handler_scope()); + Ok(ok_result("done")) + }) + }) + }) + .await; + + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + if let Some(scoped) = *observed.lock().unwrap() { + assert!( + scoped, + "task operation must run inside the originating request's association scope" + ); + return; + } + } + panic!("task operation did not run"); + } + + #[tokio::test] + async fn task_operation_without_originating_request_is_unscoped() { + use crate::service::in_request_handler_scope; + + let manager = TaskManager::new(); + let observed = Arc::new(Mutex::new(None::)); + let observed_in_task = observed.clone(); + + manager.spawn(TaskOptions::default(), move |_ctx| { + let observed_in_task = observed_in_task.clone(); + Box::pin(async move { + *observed_in_task.lock().unwrap() = Some(in_request_handler_scope()); + Ok(ok_result("done")) + }) + }); + + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + if let Some(scoped) = *observed.lock().unwrap() { + assert!( + !scoped, + "task operation started without an originating request must remain unscoped" + ); + return; + } + } + panic!("task operation did not run"); + } +} diff --git a/vendor/rmcp/src/transport.rs b/vendor/rmcp/src/transport.rs new file mode 100644 index 000000000..06fde8e51 --- /dev/null +++ b/vendor/rmcp/src/transport.rs @@ -0,0 +1,310 @@ +//! # Transport +//! The transport type must implemented [`Transport`] trait, which allow it send message concurrently and receive message sequentially. +//! +//! ## Standard Transport Types +//! There are 2 pairs of standard transport types: +//! +//! | transport | client | server | +//! |:-: |:-: |:-: | +//! | std IO | [`child_process::TokioChildProcess`] | [`io::stdio`] | +//! | streamable http | [`streamable_http_client::StreamableHttpClientTransport`] | `streamable_http_server::StreamableHttpService` | +//! +//!## Helper Transport Types +//! Thers are several helper transport types that can help you to create transport quickly. +//! +//! ### [Worker Transport](`worker::WorkerTransport`) +//! Which allows you to run a worker and process messages in another tokio task. +//! +//! ### [Async Read/Write Transport](`async_rw::AsyncRwTransport`) +//! You need to enable `transport-async-rw` feature to use this transport. +//! +//! This transport is used to create a transport from a byte stream which implemented [`tokio::io::AsyncRead`] and [`tokio::io::AsyncWrite`]. +//! +//! This could be very helpful when you want to create a transport from a byte stream, such as a file or a tcp connection. +//! +//! ### [Sink/Stream Transport](`sink_stream::SinkStreamTransport`) +//! This transport is used to create a transport from a sink and a stream. +//! +//! This could be very helpful when you want to create a transport from a duplex object stream, such as a websocket connection. +//! +//! ## [IntoTransport](`IntoTransport`) trait +//! [`IntoTransport`] is a helper trait that implicitly convert a type into a transport type. +//! +//! ### These types is automatically implemented [`IntoTransport`] trait +//! 1. A type that already implement both [`futures::Sink`] and [`futures::Stream`] trait, or a tuple `(Tx, Rx)` where `Tx` is [`futures::Sink`] and `Rx` is [`futures::Stream`]. +//! 2. A type that implement both [`tokio::io::AsyncRead`] and [`tokio::io::AsyncWrite`] trait. or a tuple `(R, W)` where `R` is [`tokio::io::AsyncRead`] and `W` is [`tokio::io::AsyncWrite`]. +//! 3. A type that implement [Worker](`worker::Worker`) trait. +//! 4. A type that implement [`Transport`] trait. +//! +//! ## Examples +//! +//! ```rust +//! # use rmcp::{ +//! # ServiceExt, serve_server, +//! # }; +//! #[cfg(feature = "client")] +//! # use rmcp::serve_client; +//! +//! // create transport from tcp stream +//! #[cfg(feature = "client")] +//! async fn client() -> Result<(), Box> { +//! let stream = tokio::net::TcpSocket::new_v4()? +//! .connect("127.0.0.1:8001".parse()?) +//! .await?; +//! let client = ().serve(stream).await?; +//! let tools = client.peer().list_tools(Default::default()).await?; +//! println!("{:?}", tools); +//! Ok(()) +//! } +//! +//! // create transport from std io +//! #[cfg(feature = "client")] +//! async fn io() -> Result<(), Box> { +//! let client = ().serve((tokio::io::stdin(), tokio::io::stdout())).await?; +//! let tools = client.peer().list_tools(Default::default()).await?; +//! println!("{:?}", tools); +//! Ok(()) +//! } +//! ``` + +use std::{borrow::Cow, sync::Arc}; + +use crate::service::{RxJsonRpcMessage, ServiceRole, TxJsonRpcMessage}; + +pub mod sink_stream; + +#[cfg(feature = "transport-async-rw")] +pub mod async_rw; + +#[cfg(feature = "transport-worker")] +pub mod worker; +#[cfg(feature = "transport-worker")] +pub use worker::WorkerTransport; + +#[cfg(feature = "transport-child-process")] +pub mod child_process; +#[cfg(feature = "which-command")] +pub use child_process::which_command; +#[cfg(feature = "transport-child-process")] +pub use child_process::{ConfigureCommandExt, TokioChildProcess}; + +#[cfg(feature = "transport-io")] +pub mod io; +#[cfg(feature = "transport-io")] +pub use io::stdio; + +#[cfg(feature = "auth")] +pub mod auth; +#[cfg(feature = "auth-client-credentials-jwt")] +pub use auth::JwtSigningAlgorithm; +#[cfg(feature = "auth")] +pub use auth::{ + AuthClient, AuthError, AuthorizationManager, AuthorizationRequest, AuthorizationSession, + AuthorizedHttpClient, ClientCredentialsConfig, CredentialStore, + EXTENSION_OAUTH_CLIENT_CREDENTIALS, InMemoryCredentialStore, InMemoryStateStore, + OAuthHttpClient, OAuthHttpClientError, OAuthHttpClientFuture, OAuthHttpRedirectPolicy, + OAuthHttpRequest, ScopeUpgradeConfig, StateStore, StoredAuthorizationState, StoredCredentials, + WWWAuthenticateParams, +}; + +// #[cfg(feature = "transport-ws")] +// pub mod ws; +#[cfg(feature = "transport-streamable-http-server-session")] +pub mod streamable_http_server; +#[cfg(all(feature = "transport-streamable-http-server", not(feature = "local")))] +pub use streamable_http_server::tower::{StreamableHttpServerConfig, StreamableHttpService}; + +#[cfg(feature = "transport-streamable-http-client")] +pub mod streamable_http_client; +#[cfg(all(unix, feature = "transport-streamable-http-client-unix-socket"))] +pub use common::unix_socket::UnixSocketHttpClient; +#[cfg(feature = "transport-streamable-http-client")] +pub use streamable_http_client::StreamableHttpClientTransport; + +/// Common use codes +pub mod common; + +pub trait Transport: Send +where + R: ServiceRole, +{ + type Error: std::error::Error + Send + Sync + 'static; + fn name() -> Cow<'static, str> { + std::any::type_name::().into() + } + /// Send a message to the transport + /// + /// Notice that the future returned by this function should be `Send` and `'static`. + /// It's because the sending message could be executed concurrently. + /// + fn send( + &mut self, + item: TxJsonRpcMessage, + ) -> impl Future> + Send + 'static; + + /// Receive a message from the transport, this operation is sequential. + fn receive(&mut self) -> impl Future>> + Send; + + /// Close the transport + fn close(&mut self) -> impl Future> + Send; +} + +pub trait IntoTransport: Send + 'static +where + R: ServiceRole, + E: std::error::Error + Send + 'static, +{ + fn into_transport(self) -> impl Transport + 'static; +} + +#[non_exhaustive] +pub enum TransportAdapterIdentity {} +impl IntoTransport for T +where + T: Transport + Send + 'static, + R: ServiceRole, + E: std::error::Error + Send + Sync + 'static, +{ + fn into_transport(self) -> impl Transport + 'static { + self + } +} + +/// A transport that can send a single message and then close itself +pub struct OneshotTransport +where + R: ServiceRole, +{ + message: Option>, + sender: tokio::sync::mpsc::Sender>, + termination: Arc, +} + +impl OneshotTransport +where + R: ServiceRole, +{ + pub fn new( + message: RxJsonRpcMessage, + ) -> (Self, tokio::sync::mpsc::Receiver>) { + let (sender, receiver) = tokio::sync::mpsc::channel(16); + ( + Self { + message: Some(message), + sender, + termination: Arc::new(tokio::sync::Semaphore::new(0)), + }, + receiver, + ) + } +} + +impl Transport for OneshotTransport +where + R: ServiceRole, +{ + type Error = tokio::sync::mpsc::error::SendError>; + + fn send( + &mut self, + item: TxJsonRpcMessage, + ) -> impl Future> + Send + 'static { + let sender = self.sender.clone(); + let terminate = matches!(item, TxJsonRpcMessage::::Response(_)) + || matches!(item, TxJsonRpcMessage::::Error(_)); + let termination = self.termination.clone(); + async move { + sender.send(item).await?; + if terminate { + termination.add_permits(1); + } + Ok(()) + } + } + + async fn receive(&mut self) -> Option> { + if let Some(msg) = self.message.take() { + return Some(msg); + } + let _ = self.termination.acquire().await; + None + } + + fn close(&mut self) -> impl Future> + Send { + self.message.take(); + std::future::ready(Ok(())) + } +} + +#[derive(Debug, thiserror::Error)] +#[error("Transport [{transport_name}] error: {error}")] +#[non_exhaustive] +pub struct DynamicTransportError { + pub transport_name: Cow<'static, str>, + pub transport_type_id: std::any::TypeId, + #[source] + pub error: Box, +} + +impl DynamicTransportError { + pub fn new + 'static, R: ServiceRole>(e: T::Error) -> Self { + Self { + transport_name: T::name(), + transport_type_id: std::any::TypeId::of::(), + error: Box::new(e), + } + } + + /// Create a `DynamicTransportError` from raw parts. + /// + /// Unlike [`new`](Self::new), this does not require a concrete [`Transport`] type, + /// making it usable in test fixtures and other contexts where a real transport + /// implementation is not available. + pub fn from_parts( + transport_name: impl Into>, + transport_type_id: std::any::TypeId, + error: Box, + ) -> Self { + Self { + transport_name: transport_name.into(), + transport_type_id, + error, + } + } + + pub(crate) fn is_authorization_required(&self) -> bool { + let mut error = Some(self.error.as_ref() as &(dyn std::error::Error + 'static)); + while let Some(current) = error { + #[cfg(feature = "auth")] + if matches!( + current.downcast_ref::(), + Some(auth::AuthError::AuthorizationRequired) + ) { + return true; + } + + #[cfg(feature = "transport-streamable-http-client")] + if current.is::() { + return true; + } + + error = current.source(); + } + false + } + + pub fn downcast + 'static, R: ServiceRole>(self) -> Result { + if !self.is::() { + Err(self) + } else { + Ok(self + .error + .downcast::() + .map(|e| *e) + .expect("type is checked")) + } + } + pub fn is + 'static, R: ServiceRole>(&self) -> bool { + self.error.is::() && self.transport_type_id == std::any::TypeId::of::() + } +} diff --git a/vendor/rmcp/src/transport/async_rw.rs b/vendor/rmcp/src/transport/async_rw.rs new file mode 100644 index 000000000..e18fd4fce --- /dev/null +++ b/vendor/rmcp/src/transport/async_rw.rs @@ -0,0 +1,779 @@ +use std::{marker::PhantomData, sync::Arc}; + +use futures::SinkExt; +use serde::{Serialize, de::DeserializeOwned}; +use thiserror::Error; +use tokio::{ + io::{AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader}, + sync::Mutex, +}; +use tokio_util::{ + bytes::{Buf, BufMut, BytesMut}, + codec::{Decoder, Encoder, FramedWrite}, +}; + +use super::{IntoTransport, Transport}; +use crate::{ + model::ErrorData, + service::{RxJsonRpcMessage, ServiceRole, TxJsonRpcMessage}, +}; + +#[non_exhaustive] +pub enum TransportAdapterAsyncRW {} + +impl IntoTransport for (R, W) +where + Role: ServiceRole, + R: AsyncRead + Send + 'static + Unpin, + W: AsyncWrite + Send + 'static + Unpin, +{ + fn into_transport(self) -> impl Transport + 'static { + AsyncRwTransport::new(self.0, self.1) + } +} + +#[non_exhaustive] +pub enum TransportAdapterAsyncCombinedRW {} +impl IntoTransport for S +where + Role: ServiceRole, + S: AsyncRead + AsyncWrite + Send + 'static, +{ + fn into_transport(self) -> impl Transport + 'static { + IntoTransport::::into_transport( + tokio::io::split(self), + ) + } +} + +pub type TransportWriter = FramedWrite>>; + +pub struct AsyncRwTransport { + read: BufReader, + line_buf: Vec, + write: Arc>>>, + _role: PhantomData Role>, +} + +impl AsyncRwTransport +where + R: Send + AsyncRead + Unpin, + W: Send + AsyncWrite + Unpin + 'static, +{ + pub fn new(read: R, write: W) -> Self { + let read = BufReader::new(read); + let write = Arc::new(Mutex::new(Some(FramedWrite::new( + write, + JsonRpcMessageCodec::>::default(), + )))); + Self { + read, + line_buf: Vec::new(), + write, + _role: PhantomData, + } + } +} + +#[cfg(feature = "client")] +impl AsyncRwTransport +where + R: Send + AsyncRead + Unpin, + W: Send + AsyncWrite + Unpin + 'static, +{ + pub fn new_client(read: R, write: W) -> Self { + Self::new(read, write) + } +} + +#[cfg(feature = "server")] +impl AsyncRwTransport +where + R: Send + AsyncRead + Unpin, + W: Send + AsyncWrite + Unpin + 'static, +{ + pub fn new_server(read: R, write: W) -> Self { + Self::new(read, write) + } +} + +impl Transport for AsyncRwTransport +where + R: Send + AsyncRead + Unpin, + W: Send + AsyncWrite + Unpin + 'static, +{ + type Error = std::io::Error; + + fn send( + &mut self, + item: TxJsonRpcMessage, + ) -> impl Future> + Send + 'static { + let lock = self.write.clone(); + async move { + let mut write = lock.lock().await; + if let Some(ref mut write) = *write { + write.send(item).await.map_err(Into::into) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "Transport is closed", + )) + } + } + } + + async fn receive(&mut self) -> Option> { + loop { + // `read_until` is not cancellation-safe on its own, and `receive` is + // polled inside a `select!` in the service loop: an in-progress line + // read is dropped whenever another branch (e.g. an outgoing response) + // becomes ready. We rely on `read_until` appending into `self.line_buf` + // and only returning at a delimiter or EOF, so a cancelled read leaves + // its partial bytes in `self.line_buf`. Keeping that buffer across + // calls lets the next read resume the same line; it is cleared only + // after a whole line has been consumed. Clearing at the top of the + // loop (the previous behaviour) discarded the partial read and so + // dropped incoming requests under concurrent response load. + match self.read.read_until(b'\n', &mut self.line_buf).await { + // EOF. Any bytes still in `line_buf` are an incomplete trailing + // message with no delimiter, so there is nothing to deliver. + Ok(0) => return None, + Ok(_) => {} + Err(e) => { + tracing::error!("Error reading from stream: {}", e); + return None; + } + } + // A returned `read_until` means a full line is buffered. Parse it + // (borrowing `line_buf`), then clear the buffer — retaining its + // capacity for the next read — before handling the parse result. + let parsed = { + let line = without_carriage_return( + self.line_buf.strip_suffix(b"\n").unwrap_or(&self.line_buf), + ); + if line.is_empty() { + self.line_buf.clear(); + continue; + } + try_parse_with_compatibility::>(line, "receive") + }; + self.line_buf.clear(); + match parsed { + Ok(Some(msg)) => return Some(msg), + Ok(None) => continue, + Err(JsonRpcMessageCodecError::Serde(e)) => { + match e.classify() { + serde_json::error::Category::Syntax | serde_json::error::Category::Eof => { + // The input isn't valid JSON, so there's no message id to correlate a + // response to, and replying to invalid data can trigger an error storm + // if the peer echoes the response back as more invalid data. This + // matches the other official MCP SDKs, which ignore unparsable input. + // See https://github.com/modelcontextprotocol/rust-sdk/issues/938 + tracing::debug!("Ignoring unparsable incoming message: {e}"); + } + serde_json::error::Category::Data | serde_json::error::Category::Io => { + // Well-formed JSON that doesn't match the expected message shape is a + // real protocol error rather than unparsable input, so surface it with + // an Invalid Request response instead of silently dropping it. + tracing::debug!("Protocol error on incoming message: {e}"); + let mut write = self.write.lock().await; + let framed = write.as_mut()?; + let response = TxJsonRpcMessage::::error( + ErrorData::invalid_request("Invalid request", None), + None, + ); + if framed.send(response).await.is_err() { + return None; + } + } + } + } + Err(e) => { + tracing::error!("Error reading from stream: {}", e); + return None; + } + } + } + } + + async fn close(&mut self) -> Result<(), Self::Error> { + let mut write = self.write.lock().await; + drop(write.take()); + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct JsonRpcMessageCodec { + _marker: PhantomData T>, + next_index: usize, + max_length: usize, + is_discarding: bool, +} + +impl Default for JsonRpcMessageCodec { + fn default() -> Self { + Self::new() + } +} + +impl JsonRpcMessageCodec { + pub fn new() -> Self { + Self { + _marker: PhantomData, + next_index: 0, + max_length: usize::MAX, + is_discarding: false, + } + } + + pub fn new_with_max_length(max_length: usize) -> Self { + Self { + max_length, + ..Self::new() + } + } + + pub fn max_length(&self) -> usize { + self.max_length + } +} + +fn without_carriage_return(s: &[u8]) -> &[u8] { + s.strip_suffix(b"\r").unwrap_or(s) +} + +/// UTF-8 byte order mark. RFC 8259 §8.1 allows JSON parsers to ignore a leading BOM. +const UTF8_BOM: &[u8; 3] = b"\xEF\xBB\xBF"; + +/// Check if a method is a standard MCP method (request, response, or notification). +/// This includes both requests and notifications defined in the MCP specification. +/// +/// Based on MCP specification 2026-07-28: https://modelcontextprotocol.io/specification/2026-07-28 +fn is_standard_method(method: &str) -> bool { + matches!( + method, + "initialize" + | "ping" + | "server/discover" + | "prompts/get" + | "prompts/list" + | "resources/list" + | "resources/read" + | "resources/subscribe" + | "resources/unsubscribe" + | "resources/templates/list" + | "subscriptions/listen" + | "tools/call" + | "tools/list" + | "completion/complete" + | "elicitation/create" + | "logging/setLevel" + | "roots/list" + | "sampling/createMessage" + ) || is_standard_notification(method) +} + +fn is_standard_notification(method: &str) -> bool { + matches!( + method, + "notifications/cancelled" + | "notifications/initialized" + | "notifications/message" + | "notifications/progress" + | "notifications/prompts/list_changed" + | "notifications/resources/list_changed" + | "notifications/resources/updated" + | "notifications/roots/list_changed" + | "notifications/subscriptions/acknowledged" + | "notifications/tools/list_changed" + ) +} + +/// Determines if a notification should be ignored for compatibility. +fn should_ignore_notification(json_value: &serde_json::Value, method: &str) -> bool { + let is_notification = json_value.get("id").is_none(); + + // Ignore non-MCP notifications (like LSP messages) for compatibility + if is_notification && !is_standard_method(method) { + tracing::trace!( + "Ignoring non-MCP notification '{}' for compatibility", + method + ); + return true; + } + + // Ignore non-standard MCP notifications + matches!( + ( + method.starts_with("notifications/"), + is_standard_notification(method) + ), + (true, false) + ) +} + +/// Try to parse a message with compatibility handling for non-standard notifications +fn try_parse_with_compatibility( + line: &[u8], + context: &str, +) -> Result, JsonRpcMessageCodecError> { + let line = line.strip_prefix(UTF8_BOM.as_slice()).unwrap_or(line); + if let Ok(line_str) = std::str::from_utf8(line) { + match serde_json::from_slice(line) { + Ok(item) => Ok(Some(item)), + Err(e) => { + // Check if this is a notification that should be ignored for compatibility + if let Ok(json_value) = serde_json::from_str::(line_str) + && let Some(method) = + json_value.get("method").and_then(serde_json::Value::as_str) + && should_ignore_notification(&json_value, method) + { + return Ok(None); + } + + tracing::debug!( + "Failed to parse message {}: {} | Error: {}", + context, + line_str, + e + ); + Err(JsonRpcMessageCodecError::Serde(e)) + } + } + } else { + serde_json::from_slice(line) + .map(Some) + .map_err(JsonRpcMessageCodecError::Serde) + } +} + +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum JsonRpcMessageCodecError { + #[error("max line length exceeded")] + MaxLineLengthExceeded, + #[error("serde error {0}")] + Serde(#[from] serde_json::Error), + #[error("io error {0}")] + Io(#[from] std::io::Error), +} + +impl From for std::io::Error { + fn from(value: JsonRpcMessageCodecError) -> Self { + match value { + JsonRpcMessageCodecError::MaxLineLengthExceeded => { + std::io::Error::new(std::io::ErrorKind::InvalidData, value) + } + JsonRpcMessageCodecError::Serde(e) => e.into(), + JsonRpcMessageCodecError::Io(e) => e, + } + } +} + +impl Decoder for JsonRpcMessageCodec { + type Item = T; + + type Error = JsonRpcMessageCodecError; + + fn decode( + &mut self, + buf: &mut BytesMut, + ) -> Result, JsonRpcMessageCodecError> { + loop { + // Determine how far into the buffer we'll search for a newline. If + // there's no max_length set, we'll read to the end of the buffer. + let read_to = std::cmp::min(self.max_length.saturating_add(1), buf.len()); + + let newline_offset = buf[self.next_index..read_to] + .iter() + .position(|b| *b == b'\n'); + + match (self.is_discarding, newline_offset) { + (true, Some(offset)) => { + // If we found a newline, discard up to that offset and + // then stop discarding. On the next iteration, we'll try + // to read a line normally. + buf.advance(offset + self.next_index + 1); + self.is_discarding = false; + self.next_index = 0; + } + (true, None) => { + // Otherwise, we didn't find a newline, so we'll discard + // everything we read. On the next iteration, we'll continue + // discarding up to max_len bytes unless we find a newline. + buf.advance(read_to); + self.next_index = 0; + if buf.is_empty() { + return Ok(None); + } + } + (false, Some(offset)) => { + // Found a line! + let newline_index = offset + self.next_index; + self.next_index = 0; + let line = buf.split_to(newline_index + 1); + let line = &line[..line.len() - 1]; + let line = without_carriage_return(line); + + // Use compatibility handling function + let item = match try_parse_with_compatibility(line, "decode")? { + Some(item) => item, + None => return Ok(None), // Skip non-standard message + }; + return Ok(Some(item)); + } + (false, None) if buf.len() > self.max_length => { + // Reached the maximum length without finding a + // newline, return an error and start discarding on the + // next call. + self.is_discarding = true; + return Err(JsonRpcMessageCodecError::MaxLineLengthExceeded); + } + (false, None) => { + // We didn't find a line or reach the length limit, so the next + // call will resume searching at the current offset. + self.next_index = read_to; + return Ok(None); + } + } + } + } + + fn decode_eof(&mut self, buf: &mut BytesMut) -> Result, JsonRpcMessageCodecError> { + Ok(match self.decode(buf)? { + Some(frame) => Some(frame), + None => { + self.next_index = 0; + // No terminating newline - return remaining data, if any + if buf.is_empty() || buf == &b"\r"[..] { + None + } else { + let line = buf.split_to(buf.len()); + let line = without_carriage_return(&line); + + // Use compatibility handling function + let item = match try_parse_with_compatibility(line, "decode_eof")? { + Some(item) => item, + None => return Ok(None), // Skip non-standard message + }; + Some(item) + } + } + }) + } +} + +impl Encoder for JsonRpcMessageCodec { + type Error = JsonRpcMessageCodecError; + + fn encode(&mut self, item: T, buf: &mut BytesMut) -> Result<(), JsonRpcMessageCodecError> { + serde_json::to_writer(buf.writer(), &item)?; + buf.put_u8(b'\n'); + Ok(()) + } +} + +#[cfg(test)] +mod test { + use futures::{Sink, Stream, StreamExt}; + use tokio_util::codec::FramedRead; + + use super::*; + fn from_async_read(reader: R) -> impl Stream { + FramedRead::new(reader, JsonRpcMessageCodec::::default()).filter_map(|result| { + if let Err(e) = &result { + tracing::error!("Error reading from stream: {}", e); + } + futures::future::ready(result.ok()) + }) + } + + fn from_async_write( + writer: W, + ) -> impl Sink { + FramedWrite::new(writer, JsonRpcMessageCodec::::default()).sink_map_err(Into::into) + } + #[tokio::test] + async fn test_decode() { + use futures::StreamExt; + use tokio::io::BufReader; + + let data = r#"{"jsonrpc":"2.0","method":"subtract","params":[42,23],"id":1} + {"jsonrpc":"2.0","method":"subtract","params":[23,42],"id":2} + {"jsonrpc":"2.0","method":"subtract","params":[42,23],"id":3} + {"jsonrpc":"2.0","method":"subtract","params":[23,42],"id":4} + {"jsonrpc":"2.0","method":"subtract","params":[42,23],"id":5} + {"jsonrpc":"2.0","method":"subtract","params":[23,42],"id":6} + {"jsonrpc":"2.0","method":"subtract","params":[42,23],"id":7} + {"jsonrpc":"2.0","method":"subtract","params":[23,42],"id":8} + {"jsonrpc":"2.0","method":"subtract","params":[42,23],"id":9} + {"jsonrpc":"2.0","method":"subtract","params":[23,42],"id":10} + + "#; + + let mut cursor = BufReader::new(data.as_bytes()); + let mut stream = from_async_read::(&mut cursor); + + for i in 1..=10 { + let item = stream.next().await.unwrap(); + assert_eq!( + item, + serde_json::json!({ + "jsonrpc": "2.0", + "method": "subtract", + "params": if i % 2 != 0 { [42, 23] } else { [23, 42] }, + "id": i, + }) + ); + } + } + + #[tokio::test] + async fn test_encode() { + let test_messages = vec![ + serde_json::json!({ + "jsonrpc": "2.0", + "method": "subtract", + "params": [42, 23], + "id": 1, + }), + serde_json::json!({ + "jsonrpc": "2.0", + "method": "subtract", + "params": [23, 42], + "id": 2, + }), + ]; + + // Create a buffer to write to + let mut buffer = Vec::new(); + let mut writer = from_async_write(&mut buffer); + + // Write the test messages + for message in test_messages.iter() { + writer.send(message.clone()).await.unwrap(); + } + writer.close().await.unwrap(); + drop(writer); + // Parse the buffer back into lines and check each one + let output = String::from_utf8_lossy(&buffer); + let mut lines = output.lines(); + + for expected_message in test_messages { + let line = lines.next().unwrap(); + let parsed_message: serde_json::Value = serde_json::from_str(line).unwrap(); + assert_eq!(parsed_message, expected_message); + } + + // Make sure there are no extra lines + assert!(lines.next().is_none()); + } + + #[test] + fn test_standard_notification_check() { + // Test that all standard notifications are recognized + assert!(is_standard_notification("notifications/cancelled")); + assert!(is_standard_notification("notifications/initialized")); + assert!(is_standard_notification("notifications/progress")); + assert!(is_standard_notification( + "notifications/resources/list_changed" + )); + assert!(is_standard_notification("notifications/resources/updated")); + assert!(is_standard_notification( + "notifications/prompts/list_changed" + )); + assert!(is_standard_notification("notifications/tools/list_changed")); + assert!(is_standard_notification("notifications/message")); + assert!(is_standard_notification("notifications/roots/list_changed")); + assert!(is_standard_notification( + "notifications/subscriptions/acknowledged" + )); + + // Test that non-standard notifications are not recognized + assert!(!is_standard_notification("notifications/stderr")); + assert!(!is_standard_notification("notifications/custom")); + assert!(!is_standard_notification("notifications/debug")); + assert!(!is_standard_notification("some/other/method")); + } + + #[test] + fn test_2026_07_28_standard_method_check() { + for method in [ + "elicitation/create", + "server/discover", + "subscriptions/listen", + ] { + assert!(is_standard_method(method), "{method} should be standard"); + } + } + + #[test] + fn test_current_standard_notification_is_not_ignored() { + let method = "notifications/subscriptions/acknowledged"; + let notification = serde_json::json!({ + "jsonrpc": "2.0", + "method": method, + }); + + assert!(!should_ignore_notification(¬ification, method)); + } + + #[test] + fn test_unknown_notification_is_ignored() { + let method = "notifications/custom"; + let notification = serde_json::json!({ + "jsonrpc": "2.0", + "method": method, + }); + + assert!(should_ignore_notification(¬ification, method)); + } + + #[test] + fn test_compatibility_function() { + // Test the compatibility function directly + let stderr_message = + r#"{"method":"notifications/stderr","params":{"content":"stderr message"}}"#; + let custom_message = r#"{"method":"notifications/custom","params":{"data":"custom"}}"#; + let standard_message = + r#"{"method":"notifications/message","params":{"level":"info","data":"standard"}}"#; + let progress_message = r#"{"method":"notifications/progress","params":{"progressToken":"token","progress":50}}"#; + + // Test with valid JSON - all should parse successfully + let result1 = + try_parse_with_compatibility::(stderr_message.as_bytes(), "test"); + let result2 = + try_parse_with_compatibility::(custom_message.as_bytes(), "test"); + let result3 = + try_parse_with_compatibility::(standard_message.as_bytes(), "test"); + let result4 = + try_parse_with_compatibility::(progress_message.as_bytes(), "test"); + + // All should parse successfully since they're valid JSON + assert!(result1.is_ok()); + assert!(result2.is_ok()); + assert!(result3.is_ok()); + assert!(result4.is_ok()); + + // Standard notifications should return Some(value) + assert!(result3.unwrap().is_some()); + assert!(result4.unwrap().is_some()); + + println!("Standard notifications are preserved, non-standard are handled gracefully"); + } + + #[tokio::test] + async fn test_decode_strips_utf8_bom() { + use futures::StreamExt; + use tokio::io::BufReader; + + // Valid JSON-RPC message preceded by a UTF-8 BOM (EF BB BF). Some Windows + // tooling and editors prepend this; the codec should ignore it per RFC 8259 §8.1. + let mut data = Vec::new(); + data.extend_from_slice(UTF8_BOM); + data.extend_from_slice(br#"{"jsonrpc":"2.0","method":"ping","id":1}"#); + data.push(b'\n'); + + let mut cursor = BufReader::new(&data[..]); + let mut stream = from_async_read::(&mut cursor); + + let item = stream + .next() + .await + .expect("should decode BOM-prefixed line"); + assert_eq!( + item, + serde_json::json!({"jsonrpc": "2.0", "method": "ping", "id": 1}) + ); + } + + #[cfg(feature = "server")] + #[tokio::test] + async fn receive_ignores_parse_error() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + use crate::{RoleServer, transport::Transport}; + + // Two paired streams: `server_io` is wrapped by the transport; the test + // drives `client_io` to act as the peer. + let (server_io, client_io) = tokio::io::duplex(4096); + let (server_r, server_w) = tokio::io::split(server_io); + let (mut client_r, mut client_w) = tokio::io::split(client_io); + + let mut transport = AsyncRwTransport::::new(server_r, server_w); + + client_w + .write_all( + b"not json\n{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n", + ) + .await + .unwrap(); + + // The unparsable line is skipped and the next valid message is still yielded. + let received = transport + .receive() + .await + .expect("transport should skip the invalid line and yield the next valid message"); + assert_eq!( + serde_json::to_value(&received).unwrap()["method"], + "notifications/initialized", + ); + + // No response is sent back for the unparsable message (issue #938). Dropping the + // transport closes its write side, so the peer reads to EOF and should see no bytes. + drop(transport); + let mut reply_buf = Vec::new(); + client_r.read_to_end(&mut reply_buf).await.unwrap(); + assert!( + reply_buf.is_empty(), + "expected no response to an unparsable message, got: {}", + String::from_utf8_lossy(&reply_buf), + ); + } + + #[cfg(feature = "server")] + #[tokio::test] + async fn receive_responds_to_protocol_error() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + use crate::{RoleServer, transport::Transport}; + + let (server_io, client_io) = tokio::io::duplex(4096); + let (server_r, server_w) = tokio::io::split(server_io); + let (client_r, mut client_w) = tokio::io::split(client_io); + + let mut transport = AsyncRwTransport::::new(server_r, server_w); + + // Well-formed JSON that does not match the JSON-RPC message shape, followed by a + // valid notification. Unlike unparsable bytes, this is a protocol error: the + // transport should reply to it and still yield the next valid message. + client_w + .write_all( + b"{\"foo\":\"bar\"}\n{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n", + ) + .await + .unwrap(); + + let received = transport.receive().await.expect( + "transport should reply to the protocol error and yield the next valid message", + ); + assert_eq!( + serde_json::to_value(&received).unwrap()["method"], + "notifications/initialized", + ); + + // A protocol error gets an error response back (id omitted since it can't be read). + let mut reply_buf = Vec::new(); + let mut peer = BufReader::new(client_r); + peer.read_until(b'\n', &mut reply_buf).await.unwrap(); + let reply: serde_json::Value = serde_json::from_slice(&reply_buf).unwrap(); + assert_eq!( + reply, + serde_json::json!({ + "jsonrpc": "2.0", + "error": {"code": -32600, "message": "Invalid request"}, + }), + ); + } +} diff --git a/vendor/rmcp/src/transport/auth.rs b/vendor/rmcp/src/transport/auth.rs new file mode 100644 index 000000000..6f8987924 --- /dev/null +++ b/vendor/rmcp/src/transport/auth.rs @@ -0,0 +1,8097 @@ +use std::{ + collections::HashMap, + future::Future, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + pin::Pin, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use async_trait::async_trait; +use futures::StreamExt; +use oauth2::{ + AsyncHttpClient, AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, + EmptyExtraTokenFields, ExtraTokenFields, HttpRequest, HttpResponse, PkceCodeChallenge, + PkceCodeVerifier, RedirectUrl, RefreshToken, RequestTokenError, Scope, StandardTokenResponse, + TokenResponse, TokenUrl, + basic::{BasicErrorResponseType, BasicTokenType}, +}; +use reqwest::{ + Client as ReqwestClient, IntoUrl, StatusCode, Url, + header::{AUTHORIZATION, CONTENT_TYPE, LOCATION, WWW_AUTHENTICATE}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; +use tokio::sync::{Mutex, RwLock}; +use tracing::{debug, warn}; + +use crate::transport::common::http_header::HEADER_MCP_PROTOCOL_VERSION; + +const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES: usize = 1024 * 1024; +const MAX_OAUTH_DISCOVERY_REDIRECTS: usize = 10; +const CLOUD_METADATA_HOSTS: &[&str] = &[ + "metadata", + "metadata.google.internal", + "metadata.azure.internal", +]; + +/// Redirect handling requested for an outbound OAuth HTTP operation. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[non_exhaustive] +pub enum OAuthHttpRedirectPolicy { + /// Follow redirects using the client's normal limits. + #[default] + Follow, + /// Return the redirect response without following its location. + Stop, +} + +/// A complete outbound HTTP operation requested by the OAuth implementation. +#[non_exhaustive] +pub struct OAuthHttpRequest { + /// HTTP request with an absolute URI and buffered body. + pub request: HttpRequest, + /// Redirect behavior required by the OAuth operation. + pub redirect_policy: OAuthHttpRedirectPolicy, + /// Suggested maximum duration for the operation, or no SDK-specified timeout. + /// Implementations with their own timeout policy may retain it instead. + pub timeout: Option, +} + +impl OAuthHttpRequest { + fn new(request: HttpRequest, redirect_policy: OAuthHttpRedirectPolicy) -> Self { + Self { + request, + redirect_policy, + timeout: Some(DEFAULT_HTTP_TIMEOUT), + } + } +} + +/// Type-erased error returned by an [`OAuthHttpClient`]. +pub type OAuthHttpClientError = Box; + +#[derive(Debug, Error)] +enum OAuthHttpError { + #[error("OAuth HTTP response body exceeds {0} bytes")] + ResponseBodyTooLarge(usize), + #[error("unexpected HTTP status {0}")] + UnexpectedStatus(StatusCode), + #[error("OAuth discovery redirect to non-same-origin URL rejected: {0}")] + CrossOriginRedirect(Url), + #[error("OAuth discovery exceeded {0} redirects")] + TooManyRedirects(usize), +} + +/// Future returned by [`OAuthHttpClient::execute`]. +pub type OAuthHttpClientFuture<'a> = + Pin> + Send + 'a>>; + +/// Executes every outbound HTTP request made by the OAuth state machine. +/// +/// Implementations may route requests through a remote execution environment. +/// They must honor the request's redirect policy and return the raw response +/// status, headers, and body. +pub trait OAuthHttpClient: Send + Sync { + /// Execute one OAuth HTTP operation. + fn execute(&self, request: OAuthHttpRequest) -> OAuthHttpClientFuture<'_>; +} + +struct ReqwestOAuthHttpClient { + follow_redirects: ReqwestClient, + stop_redirects: ReqwestClient, +} + +impl ReqwestOAuthHttpClient { + fn new(follow_redirects: ReqwestClient) -> Result { + let stop_redirects = ReqwestClient::builder() + .timeout(DEFAULT_HTTP_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| AuthError::InternalError(error.to_string()))?; + Ok(Self { + follow_redirects, + stop_redirects, + }) + } +} + +impl OAuthHttpClient for ReqwestOAuthHttpClient { + fn execute(&self, request: OAuthHttpRequest) -> OAuthHttpClientFuture<'_> { + Box::pin(async move { + let OAuthHttpRequest { + request, + redirect_policy, + .. + } = request; + let client = match redirect_policy { + OAuthHttpRedirectPolicy::Follow => &self.follow_redirects, + OAuthHttpRedirectPolicy::Stop => &self.stop_redirects, + }; + let request = reqwest::Request::try_from(request) + .map_err(|error| Box::new(error) as OAuthHttpClientError)?; + let response = client + .execute(request) + .await + .map_err(|error| Box::new(error) as OAuthHttpClientError)?; + + let mut builder = oauth2::http::Response::builder() + .status(response.status()) + .version(response.version()); + for (name, value) in response.headers() { + builder = builder.header(name, value); + } + let mut body = Vec::new(); + let mut body_stream = response.bytes_stream(); + while let Some(chunk) = body_stream.next().await { + let chunk = chunk.map_err(|error| Box::new(error) as OAuthHttpClientError)?; + if chunk.len() > MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES - body.len() { + return Err(Box::new(OAuthHttpError::ResponseBodyTooLarge( + MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES, + )) as OAuthHttpClientError); + } + body.extend_from_slice(&chunk); + } + builder + .body(body) + .map_err(|error| Box::new(error) as OAuthHttpClientError) + }) + } +} + +struct OAuth2HttpClient<'a> { + client: &'a dyn OAuthHttpClient, + redirect_policy: OAuthHttpRedirectPolicy, +} + +#[derive(Debug)] +struct OAuth2HttpClientError(OAuthHttpClientError); + +impl std::fmt::Display for OAuth2HttpClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("OAuth HTTP request failed") + } +} + +impl std::error::Error for OAuth2HttpClientError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(self.0.as_ref()) + } +} + +impl<'c> AsyncHttpClient<'c> for OAuth2HttpClient<'_> { + type Error = OAuth2HttpClientError; + + type Future = std::pin::Pin< + Box> + Send + 'c>, + >; + + fn call(&'c self, request: HttpRequest) -> Self::Future { + Box::pin(async move { + self.client + .execute(OAuthHttpRequest::new(request, self.redirect_policy)) + .await + .map_err(OAuth2HttpClientError) + }) + } +} + +const DEFAULT_EXCHANGE_URL: &str = "http://localhost"; + +/// Default OIDC Dynamic Client Registration `application_type` (SEP-837) +const DEFAULT_APPLICATION_TYPE: &str = "native"; + +/// Stored credentials for OAuth2 authorization +#[derive(Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub struct StoredCredentials { + pub client_id: String, + pub token_response: Option, + #[serde(default)] + pub granted_scopes: Vec, + #[serde(default)] + pub token_received_at: Option, + #[serde(default)] + pub issuer: Option, +} + +impl std::fmt::Debug for StoredCredentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StoredCredentials") + .field("client_id", &self.client_id) + .field( + "token_response", + &self.token_response.as_ref().map(|_| "[REDACTED]"), + ) + .field("granted_scopes", &self.granted_scopes) + .field("token_received_at", &self.token_received_at) + .field("issuer", &self.issuer) + .finish() + } +} + +impl StoredCredentials { + /// Create a new `StoredCredentials` instance. + pub fn new( + client_id: String, + token_response: Option, + granted_scopes: Vec, + token_received_at: Option, + ) -> Self { + Self { + client_id, + token_response, + granted_scopes, + token_received_at, + issuer: None, + } + } + + pub fn with_issuer(mut self, issuer: Option) -> Self { + self.issuer = issuer; + self + } +} + +/// Trait for storing and retrieving OAuth2 credentials +/// +/// Implementations of this trait can provide custom storage backends +/// for OAuth2 credentials, such as file-based storage, keychain integration, +/// or database storage. +#[async_trait] +pub trait CredentialStore: Send + Sync { + async fn load(&self) -> Result, AuthError>; + + async fn save(&self, credentials: StoredCredentials) -> Result<(), AuthError>; + + async fn clear(&self) -> Result<(), AuthError>; +} + +/// In-memory credential store (default implementation) +/// +/// This store keeps credentials in memory only and does not persist them +/// between application restarts. This is the default behavior when no +/// custom credential store is provided. +#[derive(Debug, Default, Clone)] +pub struct InMemoryCredentialStore { + credentials: Arc>>, +} + +impl InMemoryCredentialStore { + pub fn new() -> Self { + Self { + credentials: Arc::new(RwLock::new(None)), + } + } +} + +#[async_trait::async_trait] +impl CredentialStore for InMemoryCredentialStore { + async fn load(&self) -> Result, AuthError> { + Ok(self.credentials.read().await.clone()) + } + + async fn save(&self, credentials: StoredCredentials) -> Result<(), AuthError> { + *self.credentials.write().await = Some(credentials); + Ok(()) + } + + async fn clear(&self) -> Result<(), AuthError> { + *self.credentials.write().await = None; + Ok(()) + } +} + +/// Stored authorization state for OAuth2 PKCE flow +#[derive(Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub struct StoredAuthorizationState { + pub pkce_verifier: String, + pub csrf_token: String, + #[serde(default)] + pub expected_issuer: Option, + #[serde(default)] + pub require_issuer: bool, + pub created_at: u64, + /// scopes requested in this round, used to resolve the grant when the token response omits + /// `scope` (RFC 6749 §5.1) + #[serde(default)] + pub requested_scopes: Vec, +} + +impl std::fmt::Debug for StoredAuthorizationState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StoredAuthorizationState") + .field("pkce_verifier", &"[REDACTED]") + .field("csrf_token", &"[REDACTED]") + .field("expected_issuer", &self.expected_issuer) + .field("require_issuer", &self.require_issuer) + .field("created_at", &self.created_at) + .field("requested_scopes", &self.requested_scopes) + .finish() + } +} + +/// A transparent wrapper around a JSON object that captures any extra fields returned by the +/// authorization server during token exchange that are not part of the standard OAuth 2.0 token +/// response. +/// +/// OAuth providers may include non-standard fields alongside the +/// standard OAuth fields. Those fields are collected here so callers +/// can inspect them without losing data. +/// +/// The inner [`HashMap`] maps field names to their raw JSON values. +/// +/// # Accessing extra fields +/// +/// Extra fields are available through [`StandardTokenResponse::extra_fields()`], which returns a +/// reference to this struct. Use the inner map (`.0`) to look up individual fields by name: +/// +/// ```rust,ignore +/// // Obtain the token response from the AuthorizationManager, then: +/// if let Some(value) = token_response.extra_fields().0.get("vendorSpecificField") { +/// println!("vendorSpecificField = {value}"); +/// } +/// ``` +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[non_exhaustive] +pub struct VendorExtraTokenFields(pub HashMap); + +impl ExtraTokenFields for VendorExtraTokenFields {} + +impl StoredAuthorizationState { + pub fn new(pkce_verifier: &PkceCodeVerifier, csrf_token: &CsrfToken) -> Self { + Self::new_with_expected_issuer(pkce_verifier, csrf_token, None, false) + } + + pub fn new_with_expected_issuer( + pkce_verifier: &PkceCodeVerifier, + csrf_token: &CsrfToken, + expected_issuer: Option, + require_issuer: bool, + ) -> Self { + Self { + pkce_verifier: pkce_verifier.secret().to_string(), + csrf_token: csrf_token.secret().to_string(), + expected_issuer, + require_issuer, + created_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + requested_scopes: Vec::new(), + } + } + + /// record the scopes requested in this authorization round (SEP-2350) + pub fn with_requested_scopes(mut self, scopes: Vec) -> Self { + self.requested_scopes = scopes; + self + } + + pub fn into_pkce_verifier(self) -> PkceCodeVerifier { + PkceCodeVerifier::new(self.pkce_verifier) + } +} + +/// Trait for storing and retrieving OAuth2 authorization state +/// +/// Implementations of this trait can provide custom storage backends +/// for OAuth2 PKCE flow state, such as Redis or database storage. +/// +/// Implementors are responsible for expiring stale states (e.g., abandoned +/// authorization flows). Use [`StoredAuthorizationState::created_at`] for +/// TTL-based expiration. +#[async_trait] +pub trait StateStore: Send + Sync { + async fn save( + &self, + csrf_token: &str, + state: StoredAuthorizationState, + ) -> Result<(), AuthError>; + + async fn load(&self, csrf_token: &str) -> Result, AuthError>; + + async fn delete(&self, csrf_token: &str) -> Result<(), AuthError>; +} + +/// In-memory state store (default implementation) +/// +/// This store keeps authorization state in memory only and does not persist +/// between application restarts or across multiple server instances. +#[derive(Debug, Default, Clone)] +pub struct InMemoryStateStore { + states: Arc>>, +} + +impl InMemoryStateStore { + pub fn new() -> Self { + Self { + states: Arc::new(RwLock::new(HashMap::new())), + } + } +} + +#[async_trait] +impl StateStore for InMemoryStateStore { + async fn save( + &self, + csrf_token: &str, + state: StoredAuthorizationState, + ) -> Result<(), AuthError> { + self.states + .write() + .await + .insert(csrf_token.to_string(), state); + Ok(()) + } + + async fn load(&self, csrf_token: &str) -> Result, AuthError> { + Ok(self.states.read().await.get(csrf_token).cloned()) + } + + async fn delete(&self, csrf_token: &str) -> Result<(), AuthError> { + self.states.write().await.remove(csrf_token); + Ok(()) + } +} + +/// HTTP client with OAuth 2.0 authorization +#[derive(Clone)] +#[non_exhaustive] +pub struct AuthClient { + pub http_client: C, + pub auth_manager: Arc>, +} + +impl std::fmt::Debug for AuthClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AuthorizedClient") + .field("http_client", &self.http_client) + .field("auth_manager", &"...") + .finish() + } +} + +impl AuthClient { + /// Create a new authorized HTTP client + pub fn new(http_client: C, auth_manager: AuthorizationManager) -> Self { + Self { + http_client, + auth_manager: Arc::new(Mutex::new(auth_manager)), + } + } +} + +impl AuthClient { + pub fn get_access_token(&self) -> impl Future> + Send { + let auth_manager = self.auth_manager.clone(); + async move { auth_manager.lock().await.get_access_token().await } + } +} + +/// Auth error +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum AuthError { + #[error("OAuth authorization required")] + AuthorizationRequired, + + #[error("OAuth authorization failed: {0}")] + AuthorizationFailed(String), + + #[error("OAuth token exchange failed: {0}")] + TokenExchangeFailed(String), + + /// The refresh attempt failed without a definitive refresh-token rejection. + /// + /// Callers may retry this error because it includes transient request and provider failures. + #[error("OAuth token refresh failed: {0}")] + TokenRefreshFailed(String), + + /// The authorization server definitively rejected the refresh token. + #[error("OAuth refresh token was rejected: {0}")] + TokenRefreshRejected(String), + + #[error("HTTP error: {0}")] + HttpError(#[from] reqwest::Error), + + #[error("OAuth error: {0}")] + OAuthError(String), + + #[error("Metadata error: {0}")] + MetadataError(String), + + #[error("Authorization server does not support the required PKCE code challenge method (S256)")] + PkceUnsupported, + + #[error("URL parse error: {0}")] + UrlError(#[from] url::ParseError), + + #[error("No authorization support detected")] + NoAuthorizationSupport, + + #[error("Internal error: {0}")] + InternalError(String), + + #[error("Invalid token type: {0}")] + InvalidTokenType(String), + + #[error("Token expired")] + TokenExpired, + + #[error("Invalid scope: {0}")] + InvalidScope(String), + + #[error("Registration failed: {0}")] + RegistrationFailed(String), + + #[error("Insufficient scope: {required_scope}")] + InsufficientScope { + required_scope: String, + upgrade_url: Option, + }, + + #[error( + "Authorization server issuer mismatch: expected {expected_issuer}, received {received_issuer}" + )] + AuthorizationServerMismatch { + expected_issuer: String, + received_issuer: String, + }, + + #[error("Authorization server response missing required issuer: expected {expected_issuer}")] + AuthorizationServerMissingIssuer { expected_issuer: String }, + + #[error("Client credentials error: {0}")] + ClientCredentialsError(String), + + #[cfg(feature = "auth-client-credentials-jwt")] + #[error("JWT signing error: {0}")] + JwtSigningError(String), +} + +/// oauth2 metadata +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +#[non_exhaustive] +pub struct AuthorizationMetadata { + pub authorization_endpoint: String, + pub token_endpoint: String, + pub registration_endpoint: Option, + pub issuer: Option, + pub jwks_uri: Option, + pub scopes_supported: Option>, + pub response_types_supported: Option>, + pub code_challenge_methods_supported: Option>, + // allow additional fields + #[serde(flatten)] + pub additional_fields: HashMap, +} + +/// How [`AuthorizationMetadata`] was obtained during discovery. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum AuthorizationMetadataSource { + /// Discovered through RFC 9728 protected resource metadata. + ProtectedResourceMetadata, + /// Discovered through RFC 8414 / OpenID Connect metadata at the server's + /// base URL. + AuthorizationServerMetadata, + /// Nothing was discovered; the endpoints were synthesized from the base + /// URL (`/authorize`, `/token`, `/register`) for compatibility with the + /// 2025-03-26 MCP spec's default-endpoint fallback. The server gave no + /// evidence that it supports OAuth. + /// + /// [Newer MCP revisions] require metadata discovery and do not define an + /// endpoint-synthesis fallback. + /// + /// [Newer MCP revisions]: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery#protected-resource-metadata-discovery-requirements + LegacyEndpointFallback, +} + +impl AuthorizationMetadataSource { + /// Whether the metadata was actually published by the server, as opposed + /// to synthesized by the client as a legacy compatibility fallback. + pub fn is_discovered(self) -> bool { + !matches!(self, Self::LegacyEndpointFallback) + } +} + +/// [`AuthorizationMetadata`] together with how it was resolved. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct AuthorizationMetadataResolution { + pub metadata: AuthorizationMetadata, + pub source: AuthorizationMetadataSource, +} + +#[derive(Debug, Clone, Deserialize)] +struct ResourceServerMetadata { + resource: Option, + authorization_server: Option, + authorization_servers: Option>, + scopes_supported: Option>, +} + +/// Parameters extracted from WWW-Authenticate header +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub struct WWWAuthenticateParams { + pub resource_metadata_url: Option, + pub scope: Option, + pub error: Option, + pub error_description: Option, +} + +impl WWWAuthenticateParams { + /// Parse a `WWW-Authenticate` header value, resolving a relative + /// `resource_metadata` URL against `base_url`. + pub fn parse(header: &str, base_url: &Url) -> Self { + AuthorizationManager::extract_www_authenticate_params(header, base_url) + } + + /// check if this is an insufficient_scope error + pub fn is_insufficient_scope(&self) -> bool { + self.error.as_deref() == Some("insufficient_scope") + } + + /// check if this is an invalid_token error (expired/revoked) + pub fn is_invalid_token(&self) -> bool { + self.error.as_deref() == Some("invalid_token") + } +} + +/// oauth2 client config +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct OAuthClientConfig { + pub client_id: String, + pub client_secret: Option, + pub scopes: Vec, + pub redirect_uri: String, + pub application_type: Option, +} + +impl OAuthClientConfig { + pub fn new(client_id: impl Into, redirect_uri: impl Into) -> Self { + Self { + client_id: client_id.into(), + client_secret: None, + scopes: Vec::new(), + redirect_uri: redirect_uri.into(), + application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()), + } + } + + pub fn with_client_secret(mut self, secret: impl Into) -> Self { + self.client_secret = Some(secret.into()); + self + } + + pub fn with_scopes(mut self, scopes: Vec) -> Self { + self.scopes = scopes; + self + } + + /// Set the OIDC Dynamic Client Registration `application_type` (SEP-837), e.g. `"native"` or `"web"` + pub fn with_application_type(mut self, application_type: impl Into) -> Self { + self.application_type = Some(application_type.into()); + self + } +} + +/// Declarative description of the client identity material available for an +/// authorization flow. +/// +/// The [MCP authorization specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration) +/// recommends that clients obtain a client ID using the following priority +/// order. [`OAuthState::start_authorization`] and [`AuthorizationSession::new`] +/// apply it internally: +/// +/// 1. Pre-registered client information +/// ([`with_preregistered_client`](Self::with_preregistered_client)), when +/// the client already holds a `client_id` issued out of band +/// 2. Client ID Metadata Documents (SEP-991, +/// [`with_client_metadata_url`](Self::with_client_metadata_url)), when the +/// authorization server advertises `client_id_metadata_document_supported` +/// 3. Dynamic Client Registration as a fallback, when the authorization server +/// advertises a `registration_endpoint` +/// +/// Provide whichever identity material the client has available; the SDK +/// selects the highest-priority mechanism the server supports. +/// +/// ```rust,ignore +/// let request = AuthorizationRequest::new("http://localhost:8080/callback") +/// // Omit `with_scopes` to let the SDK auto-select scopes from server metadata. +/// // Used when the server supports CIMD and no pre-registered client is set. +/// .with_client_metadata_url("https://example.com/client-metadata.json") +/// // used for dynamic client registration as a last resort +/// .with_client_name("My MCP Client"); +/// oauth_state.start_authorization(request).await?; +/// ``` +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct AuthorizationRequest { + /// Redirect URI for the authorization-code flow. + pub redirect_uri: String, + /// Scopes to request. When empty, the SDK selects scopes from the + /// server's `WWW-Authenticate` challenge, Protected Resource Metadata, + /// or authorization server metadata. + pub scopes: Vec, + /// Human-readable client name, used for Dynamic Client Registration. + pub client_name: Option, + /// Pre-registered client ID obtained from the authorization server out of + /// band. When set, registration is skipped entirely. + pub client_id: Option, + /// Client secret paired with the pre-registered [`client_id`](Self::client_id). + pub client_secret: Option, + /// HTTPS URL of a Client ID Metadata Document (SEP-991). Used when the + /// authorization server advertises `client_id_metadata_document_supported` + /// and no pre-registered client is configured. + pub client_metadata_url: Option, + /// OIDC Dynamic Client Registration `application_type` (SEP-837), + /// e.g. `"native"` or `"web"`. + pub application_type: Option, + /// `WWW-Authenticate` header value from a real request's 401 response. + /// When set, discovery is seeded from the challenge (its + /// `resource_metadata` URL and `scope` hint) instead of probing the + /// server — the reactive discovery path. + pub challenge: Option, +} + +impl AuthorizationRequest { + /// Create a request for the given redirect URI. With no further identity + /// material, authorization falls back to Dynamic Client Registration. + pub fn new(redirect_uri: impl Into) -> Self { + Self { + redirect_uri: redirect_uri.into(), + scopes: Vec::new(), + client_name: None, + client_id: None, + client_secret: None, + client_metadata_url: None, + application_type: None, + challenge: None, + } + } + + /// Set the scopes to request. When not set, the SDK auto-selects scopes + /// using its normal scope-selection policy. + pub fn with_scopes(mut self, scopes: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.scopes = scopes.into_iter().map(Into::into).collect(); + self + } + + /// Set the client name used for Dynamic Client Registration. + pub fn with_client_name(mut self, client_name: impl Into) -> Self { + self.client_name = Some(client_name.into()); + self + } + + /// Use a client ID that was pre-registered with the authorization server + /// out of band. This takes priority over every other mechanism. + /// + /// Pair with [`with_client_secret`](Self::with_client_secret) for + /// confidential clients. + pub fn with_preregistered_client(mut self, client_id: impl Into) -> Self { + self.client_id = Some(client_id.into()); + self + } + + /// Set the client secret paired with a pre-registered client ID. + /// + /// Must be used together with + /// [`with_preregistered_client`](Self::with_preregistered_client); + /// authorization fails with [`AuthError::RegistrationFailed`] if a secret + /// is provided without a client ID. + pub fn with_client_secret(mut self, client_secret: impl Into) -> Self { + self.client_secret = Some(client_secret.into()); + self + } + + /// Set the HTTPS URL of a Client ID Metadata Document (SEP-991). Used when + /// the authorization server advertises support and no pre-registered + /// client is configured. + pub fn with_client_metadata_url(mut self, client_metadata_url: impl Into) -> Self { + self.client_metadata_url = Some(client_metadata_url.into()); + self + } + + /// Set the OIDC Dynamic Client Registration `application_type` (SEP-837), + /// e.g. `"native"` or `"web"`. + pub fn with_application_type(mut self, application_type: impl Into) -> Self { + self.application_type = Some(application_type.into()); + self + } + + /// Seed discovery from the `WWW-Authenticate` header value of a real + /// request's 401 response instead of probing the server. + pub fn with_challenge(mut self, www_authenticate: impl Into) -> Self { + self.challenge = Some(www_authenticate.into()); + self + } +} + +// add type aliases for oauth2 types +type OAuthErrorResponse = oauth2::StandardErrorResponse; + +/// The token response returned by the authorization server after a successful OAuth 2.0 flow. +/// +/// This is a [`StandardTokenResponse`] parameterised with [`VendorExtraTokenFields`], which means +/// it carries both the standard OAuth fields and +/// any vendor-specific fields the server may have included in the JSON response body. +/// +/// # Accessing vendor-specific fields +/// +/// Call [`extra_fields()`][OAuthTokenResponse::extra_fields] to obtain a reference to the +/// [`VendorExtraTokenFields`] wrapper, then index into its inner map. +pub type OAuthTokenResponse = StandardTokenResponse; +type OAuthTokenIntrospection = + oauth2::StandardTokenIntrospectionResponse; +type OAuthRevocableToken = oauth2::StandardRevocableToken; +type OAuthRevocationError = oauth2::StandardErrorResponse; +type OAuthClient = oauth2::Client< + OAuthErrorResponse, + OAuthTokenResponse, + OAuthTokenIntrospection, + OAuthRevocableToken, + OAuthRevocationError, + oauth2::EndpointSet, + oauth2::EndpointNotSet, + oauth2::EndpointNotSet, + oauth2::EndpointNotSet, + oauth2::EndpointSet, +>; +type Credentials = (String, Option); + +/// OAuth 2.0 extension identifier for client credentials flow (SEP-1046) +pub const EXTENSION_OAUTH_CLIENT_CREDENTIALS: &str = + "io.modelcontextprotocol/oauth-client-credentials"; + +/// JWT signing algorithm for private_key_jwt authentication (SEP-1046) +#[cfg(feature = "auth-client-credentials-jwt")] +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub enum JwtSigningAlgorithm { + RS256, + RS384, + RS512, + ES256, + ES384, +} + +#[cfg(feature = "auth-client-credentials-jwt")] +impl JwtSigningAlgorithm { + fn to_jsonwebtoken_algorithm(self) -> jsonwebtoken::Algorithm { + match self { + JwtSigningAlgorithm::RS256 => jsonwebtoken::Algorithm::RS256, + JwtSigningAlgorithm::RS384 => jsonwebtoken::Algorithm::RS384, + JwtSigningAlgorithm::RS512 => jsonwebtoken::Algorithm::RS512, + JwtSigningAlgorithm::ES256 => jsonwebtoken::Algorithm::ES256, + JwtSigningAlgorithm::ES384 => jsonwebtoken::Algorithm::ES384, + } + } + + fn as_str(self) -> &'static str { + match self { + JwtSigningAlgorithm::RS256 => "RS256", + JwtSigningAlgorithm::RS384 => "RS384", + JwtSigningAlgorithm::RS512 => "RS512", + JwtSigningAlgorithm::ES256 => "ES256", + JwtSigningAlgorithm::ES384 => "ES384", + } + } +} + +/// Configuration for OAuth 2.0 Client Credentials flow (SEP-1046) +/// +/// This supports two authentication methods: +/// - `ClientSecret`: credentials sent in the request body +/// - `PrivateKeyJwt`: RFC 7523 signed JWT assertion (requires `auth-client-credentials-jwt` feature) +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum ClientCredentialsConfig { + /// Client secret authentication (credentials in request body) + ClientSecret { + client_id: String, + client_secret: String, + scopes: Vec, + resource: Option, + }, + /// Private key JWT authentication (RFC 7523) + #[cfg(feature = "auth-client-credentials-jwt")] + PrivateKeyJwt { + client_id: String, + signing_key: Vec, + signing_algorithm: JwtSigningAlgorithm, + /// Overrides the authorization server issuer used for the JWT `aud` claim. + token_endpoint_audience: Option, + scopes: Vec, + resource: Option, + }, +} + +#[cfg(feature = "auth-client-credentials-jwt")] +fn client_authentication_audience<'a>( + metadata: &'a AuthorizationMetadata, + configured_audience: Option<&'a str>, +) -> Result<&'a str, AuthError> { + configured_audience + .or(metadata.issuer.as_deref()) + .ok_or_else(|| { + AuthError::ClientCredentialsError( + "Authorization server metadata is missing the issuer required for private_key_jwt" + .to_string(), + ) + }) +} + +#[cfg(feature = "auth-client-credentials-jwt")] +fn client_authentication_header(algorithm: JwtSigningAlgorithm) -> jsonwebtoken::Header { + let mut header = jsonwebtoken::Header::new(algorithm.to_jsonwebtoken_algorithm()); + header.typ = Some("client-authentication+jwt".to_string()); + header +} + +impl ClientCredentialsConfig { + fn client_id(&self) -> &str { + match self { + ClientCredentialsConfig::ClientSecret { client_id, .. } => client_id, + #[cfg(feature = "auth-client-credentials-jwt")] + ClientCredentialsConfig::PrivateKeyJwt { client_id, .. } => client_id, + } + } + + fn scopes(&self) -> &[String] { + match self { + ClientCredentialsConfig::ClientSecret { scopes, .. } => scopes, + #[cfg(feature = "auth-client-credentials-jwt")] + ClientCredentialsConfig::PrivateKeyJwt { scopes, .. } => scopes, + } + } + + fn resource(&self) -> Option<&str> { + match self { + ClientCredentialsConfig::ClientSecret { resource, .. } => resource.as_deref(), + #[cfg(feature = "auth-client-credentials-jwt")] + ClientCredentialsConfig::PrivateKeyJwt { resource, .. } => resource.as_deref(), + } + } + + fn auth_method(&self) -> &str { + match self { + ClientCredentialsConfig::ClientSecret { .. } => "client_secret_post", + #[cfg(feature = "auth-client-credentials-jwt")] + ClientCredentialsConfig::PrivateKeyJwt { .. } => "private_key_jwt", + } + } +} + +/// Configuration for scope upgrade behavior +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct ScopeUpgradeConfig { + /// Maximum number of scope upgrade attempts before giving up + pub max_upgrade_attempts: u32, + /// Whether to automatically attempt scope upgrades on 403 + pub auto_upgrade: bool, +} + +impl Default for ScopeUpgradeConfig { + fn default() -> Self { + Self { + max_upgrade_attempts: 3, + auto_upgrade: true, + } + } +} + +/// oauth2 auth manager +pub struct AuthorizationManager { + http_client: Arc, + // Preserve legacy reqwest refresh behavior without weakening custom clients. + refresh_redirect_policy: OAuthHttpRedirectPolicy, + metadata: Option, + oauth_client: Option, + credential_store: Arc, + state_store: Arc, + base_url: Url, + current_scopes: RwLock>, + scope_upgrade_attempts: RwLock, + scope_upgrade_config: ScopeUpgradeConfig, + /// scopes from the initial 401 WWW-Authenticate header, used by select_scopes() + www_auth_scopes: RwLock>, + /// scopes_supported from protected resource metadata (RFC 9728) + resource_scopes: RwLock>, + /// resource indicator from protected resource metadata, used for RFC 8707 `resource` + discovered_resource: RwLock>, + /// OIDC Dynamic Client Registration `application_type` (SEP-837) + application_type: Option, + allow_missing_issuer: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ClientRegistrationRequest { + pub client_name: String, + pub redirect_uris: Vec, + pub grant_types: Vec, + pub token_endpoint_auth_method: String, + pub response_types: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub application_type: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub struct ClientRegistrationResponse { + pub client_id: String, + pub client_secret: Option, + pub client_name: Option, + pub redirect_uris: Vec, + // allow additional fields + #[serde(flatten)] + pub additional_fields: HashMap, +} + +impl ClientRegistrationResponse { + pub fn new(client_id: impl Into, redirect_uris: Vec) -> Self { + Self { + client_id: client_id.into(), + client_secret: None, + client_name: None, + redirect_uris, + additional_fields: HashMap::new(), + } + } +} + +/// SEP-991: URL-based Client IDs +/// Validate that the client_id is a valid URL with https scheme and non-root pathname +fn is_https_url(value: &str) -> bool { + Url::parse(value) + .ok() + .map(|url| url.scheme() == "https" && url.path() != "/" && url.host_str().is_some()) + .unwrap_or(false) +} + +#[cfg(feature = "auth-client-credentials-jwt")] +fn is_allowed_client_credentials_endpoint(resource: &Url, token_endpoint: &Url) -> bool { + token_endpoint.scheme() == "https" + || (token_endpoint.scheme() == "http" + && resource + .host_str() + .is_some_and(AuthorizationManager::is_loopback_metadata_host) + && token_endpoint + .host_str() + .is_some_and(AuthorizationManager::is_loopback_metadata_host)) +} + +impl AuthorizationManager { + fn is_http_url(url: &Url) -> bool { + matches!(url.scheme(), "http" | "https") && url.host_str().is_some() + } + + fn is_same_origin(base: &Url, candidate: &Url) -> bool { + base.scheme() == candidate.scheme() + && base + .host_str() + .zip(candidate.host_str()) + .is_some_and(|(base, candidate)| base.eq_ignore_ascii_case(candidate)) + && base.port_or_known_default() == candidate.port_or_known_default() + } + + fn is_same_origin_resource_metadata_url(base_url: &Url, candidate: &Url) -> bool { + Self::is_http_url(candidate) && Self::is_same_origin(base_url, candidate) + } + + fn is_disallowed_metadata_ipv4(addr: Ipv4Addr) -> bool { + let octets = addr.octets(); + addr.is_private() + || addr.is_loopback() + || addr.is_link_local() + || addr.is_broadcast() + || addr.is_unspecified() + || addr.is_multicast() + || octets[0] == 0 + || (octets[0] == 100 && (64..=127).contains(&octets[1])) + || (octets[0] == 198 && matches!(octets[1], 18 | 19)) + } + + fn is_disallowed_metadata_ipv6(addr: Ipv6Addr) -> bool { + if let Some(mapped) = addr.to_ipv4_mapped() { + return Self::is_disallowed_metadata_ipv4(mapped); + } + + let segments = addr.segments(); + addr.is_loopback() + || addr.is_unspecified() + || addr.is_multicast() + || (segments[0] & 0xffc0) == 0xfe80 + || (segments[0] & 0xfe00) == 0xfc00 + } + + fn is_disallowed_metadata_hostname(host: &str) -> bool { + matches!(host, "localhost") + || host.ends_with(".localhost") + || CLOUD_METADATA_HOSTS.contains(&host) + } + + fn is_disallowed_metadata_host(host: &str) -> bool { + let host = host.trim_end_matches('.').to_ascii_lowercase(); + if Self::is_disallowed_metadata_hostname(&host) { + return true; + } + + match host.parse::() { + Ok(IpAddr::V4(addr)) => Self::is_disallowed_metadata_ipv4(addr), + Ok(IpAddr::V6(addr)) => Self::is_disallowed_metadata_ipv6(addr), + Err(_) => false, + } + } + + fn is_loopback_metadata_host(host: &str) -> bool { + let host = host.trim_end_matches('.').to_ascii_lowercase(); + host == "localhost" + || host.ends_with(".localhost") + || matches!(host.parse::(), Ok(IpAddr::V4(addr)) if addr.is_loopback()) + || matches!(host.parse::(), Ok(IpAddr::V6(addr)) if addr.is_loopback()) + } + + fn is_allowed_authorization_server_metadata_url(base_url: &Url, url: &Url) -> bool { + if !Self::is_http_url(url) { + return false; + } + + let Some(host) = url.host_str() else { + return false; + }; + + if !Self::is_disallowed_metadata_host(host) { + return true; + } + + base_url + .host_str() + .is_some_and(Self::is_loopback_metadata_host) + && Self::is_loopback_metadata_host(host) + } + + fn resolve_resource_metadata_url(value: &str, base_url: &Url) -> Option { + let value = value.trim(); + if value.is_empty() { + debug!("ignoring empty resource_metadata value"); + return None; + } + + let url = match Url::parse(value).or_else(|_| base_url.join(value)) { + Ok(url) => url, + Err(error) => { + debug!("failed to parse resource metadata value `{value}` as URL: {error}"); + return None; + } + }; + + if Self::is_same_origin_resource_metadata_url(base_url, &url) { + Some(url) + } else { + warn!( + "rejecting resource metadata URL `{url}` because it is not same-origin with `{base_url}`" + ); + None + } + } + + fn well_known_paths(base_path: &str, resource: &str) -> Vec { + let trimmed = base_path.trim_start_matches('/').trim_end_matches('/'); + let mut candidates = Vec::new(); + + let mut push_candidate = |candidate: String| { + if !candidates.contains(&candidate) { + candidates.push(candidate); + } + }; + + let canonical = format!("/.well-known/{resource}"); + + if trimmed.is_empty() { + push_candidate(canonical); + return candidates; + } + + // This follows the RFC 8414 recommendation for well-known URI discovery + push_candidate(format!("{canonical}/{trimmed}")); + // This is a common pattern used by some identity providers + push_candidate(format!("/{trimmed}/.well-known/{resource}")); + // The canonical path should always be the last fallback + push_candidate(canonical); + + candidates + } + + /// create new auth manager with base url + pub async fn new(base_url: U) -> Result { + let http_client = ReqwestClient::builder() + .timeout(DEFAULT_HTTP_TIMEOUT) + .build() + .map_err(|e| AuthError::InternalError(e.to_string()))?; + Self::new_inner( + base_url, + Arc::new(ReqwestOAuthHttpClient::new(http_client)?), + OAuthHttpRedirectPolicy::Stop, + ) + .await + } + + /// Create an auth manager with a client used for every OAuth HTTP operation. + pub async fn new_with_oauth_http_client( + base_url: U, + http_client: Arc, + ) -> Result { + Self::new_inner(base_url, http_client, OAuthHttpRedirectPolicy::Stop).await + } + + async fn new_inner( + base_url: U, + http_client: Arc, + refresh_redirect_policy: OAuthHttpRedirectPolicy, + ) -> Result { + let base_url = base_url.into_url()?; + + let manager = Self { + http_client, + refresh_redirect_policy, + metadata: None, + oauth_client: None, + credential_store: Arc::new(InMemoryCredentialStore::new()), + state_store: Arc::new(InMemoryStateStore::new()), + base_url, + current_scopes: RwLock::new(Vec::new()), + scope_upgrade_attempts: RwLock::new(0), + scope_upgrade_config: ScopeUpgradeConfig::default(), + www_auth_scopes: RwLock::new(Vec::new()), + resource_scopes: RwLock::new(Vec::new()), + discovered_resource: RwLock::new(None), + application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()), + allow_missing_issuer: false, + }; + + Ok(manager) + } + + /// Set the scope upgrade configuration + pub fn set_scope_upgrade_config(&mut self, config: ScopeUpgradeConfig) { + self.scope_upgrade_config = config; + } + + /// Configure whether authorization server metadata discovery tolerates a + /// missing `issuer` field. + /// + /// The default is `false`, enforcing the RFC 8414/OIDC requirement that + /// discovered metadata include `issuer` whenever the expected issuer can be + /// derived from the discovery URL. Set this to `true` only for compatibility + /// with authorization servers that return incomplete metadata. + pub fn set_allow_missing_issuer(&mut self, allow: bool) { + self.allow_missing_issuer = allow; + } + + /// Set a custom credential store + /// + /// This allows you to provide your own implementation of credential storage, + /// such as file-based storage, keychain integration, or database storage. + /// This should be called before any operations that need credentials. + pub fn set_credential_store(&mut self, store: S) { + self.credential_store = Arc::new(store); + } + + /// Set a custom state store for OAuth2 authorization flow state + /// + /// This should be called before initiating the authorization flow. + pub fn set_state_store(&mut self, store: S) { + self.state_store = Arc::new(store); + } + + /// Set OAuth2 authorization metadata + /// + /// This should be called with + /// [`AuthorizationMetadataResolution::metadata`] after + /// [`Self::resolve_metadata`] and before creating an + /// [`AuthorizationSession`]. + pub fn set_metadata(&mut self, metadata: AuthorizationMetadata) { + self.metadata = Some(metadata); + } + + /// Initialize from stored credentials if available + /// + /// This will load credentials from the credential store and configure + /// the client if credentials are found. Returns `false` when credentials + /// are absent or discarded after an authorization-server change. + pub async fn initialize_from_store(&mut self) -> Result { + if let Some(stored) = self.credential_store.load().await? + && stored.token_response.is_some() + { + if self.metadata.is_none() { + let resolution = self.resolve_metadata().await?; + self.metadata = Some(resolution.metadata); + } + + if let (Some(stored_issuer), Some(current_issuer)) = + (stored.issuer.as_deref(), self.metadata_issuer().as_deref()) + { + // A CIMD client ID is the client's metadata URL, so it is + // portable across authorization servers and exempt here. + if stored_issuer != current_issuer { + if is_https_url(&stored.client_id) { + // A CIMD client ID is the client's metadata URL, so it is + // portable across authorization servers — but the tokens + // were minted by the previous AS and must not be reused. + tracing::warn!( + stored_issuer, + current_issuer, + "authorization server issuer changed; discarding tokens but keeping portable CIMD client ID" + ); + self.credential_store + .save( + StoredCredentials::new( + stored.client_id.clone(), + None, + vec![], + None, + ) + .with_issuer(self.metadata_issuer()), + ) + .await?; + self.configure_client_id(&stored.client_id)?; + return Ok(false); + } + + tracing::warn!( + stored_issuer, + current_issuer, + "authorization server issuer changed; clearing stored credentials bound to the previous issuer" + ); + self.credential_store.clear().await?; + return Ok(false); + } + } + + self.configure_client_id(&stored.client_id)?; + return Ok(true); + } + Ok(false) + } + + /// Use a caller-configured `reqwest::Client` for every OAuth HTTP operation, + /// preserving all of its settings (proxy, TLS, timeout, default headers). + /// + /// The same client is reused for all requests, so its own redirect policy applies + /// and [`OAuthHttpRedirectPolicy::Stop`] is not enforced for token operations. + /// Callers needing strict no-redirect handling should pass a custom + /// [`OAuthHttpClient`] to [`AuthorizationManager::new_with_oauth_http_client`]. + pub fn with_client(&mut self, http_client: ReqwestClient) -> Result<(), AuthError> { + // One client for both modes: a built reqwest::Client can't be rebuilt as a + // no-redirect variant without dropping the caller's configuration. + self.http_client = Arc::new(ReqwestOAuthHttpClient { + follow_redirects: http_client.clone(), + stop_redirects: http_client, + }); + self.refresh_redirect_policy = OAuthHttpRedirectPolicy::Follow; + Ok(()) + } + + /// Resolve OAuth 2.0 metadata and report how it was obtained. + /// + /// Discovery follows SEP-985: protected resource metadata first, then + /// direct OAuth 2.0 Authorization Server Metadata or OpenID Connect + /// Discovery. When discovery finds nothing, the result contains legacy + /// default endpoints derived from the base URL and + /// [`AuthorizationMetadataSource::LegacyEndpointFallback`]. + /// + /// # Examples + /// + /// ```no_run + /// use rmcp::transport::auth::{AuthorizationManager, AuthorizationMetadataSource}; + /// + /// # async fn resolve() -> Result<(), Box> { + /// let mut manager = AuthorizationManager::new("https://mcp.example.com").await?; + /// let resolution = manager.resolve_metadata().await?; + /// + /// if resolution.source == AuthorizationMetadataSource::LegacyEndpointFallback { + /// println!("the server did not publish OAuth metadata"); + /// } + /// + /// manager.set_metadata(resolution.metadata); + /// # Ok(()) + /// # } + /// ``` + pub async fn resolve_metadata(&self) -> Result { + if let Some(metadata) = self.discover_oauth_server_via_resource_metadata().await? { + return Ok(AuthorizationMetadataResolution { + metadata, + source: AuthorizationMetadataSource::ProtectedResourceMetadata, + }); + } + + if let Some(metadata) = self.try_discover_oauth_server(&self.base_url).await? { + return Ok(AuthorizationMetadataResolution { + metadata, + source: AuthorizationMetadataSource::AuthorizationServerMetadata, + }); + } + + debug!("falling back to legacy OAuth endpoints derived from the base URL"); + Ok(AuthorizationMetadataResolution { + metadata: Self::legacy_authorization_metadata(&self.base_url), + source: AuthorizationMetadataSource::LegacyEndpointFallback, + }) + } + + /// Resolve authorization server metadata starting from the + /// `WWW-Authenticate` challenge of a real request's 401 response — the + /// reactive discovery path, matching the TypeScript and Python SDKs. + /// + /// Seeds scope selection with the challenge's `scope` hint and prefers + /// the challenge's `resource_metadata` URL; falls back to + /// [`resolve_metadata`](Self::resolve_metadata) when the challenge is + /// `None` or carries no usable metadata pointer. + pub async fn resolve_metadata_from_challenge( + &self, + www_authenticate: Option<&str>, + ) -> Result { + let Some(www_authenticate) = www_authenticate else { + return self.resolve_metadata().await; + }; + let params = WWWAuthenticateParams::parse(www_authenticate, &self.base_url); + + self.record_challenge_scope(¶ms).await; + + if let Some(resource_metadata_url) = ¶ms.resource_metadata_url + && let Some(metadata) = self + .discover_oauth_server_from_resource_metadata_url(resource_metadata_url) + .await? + { + return Ok(AuthorizationMetadataResolution { + metadata, + source: AuthorizationMetadataSource::ProtectedResourceMetadata, + }); + } + + self.resolve_metadata().await + } + + /// Store a challenge's `scope` hint for later scope selection. + async fn record_challenge_scope(&self, params: &WWWAuthenticateParams) { + let Some(scope) = ¶ms.scope else { + return; + }; + let scopes: Vec = scope.split_whitespace().map(str::to_string).collect(); + if scopes.is_empty() { + return; + } + debug!("WWW-Authenticate challenge contains scope: {scope}"); + *self.www_auth_scopes.write().await = scopes; + } + + fn legacy_authorization_metadata(base_url: &Url) -> AuthorizationMetadata { + let endpoint = |path: &str| { + let mut url = base_url.clone(); + url.set_query(None); + url.set_fragment(None); + url.set_path(path); + url.to_string() + }; + + AuthorizationMetadata { + authorization_endpoint: endpoint("/authorize"), + token_endpoint: endpoint("/token"), + registration_endpoint: Some(endpoint("/register")), + ..Default::default() + } + } + + /// get client id and credentials + pub async fn get_credentials(&self) -> Result { + let client_id = self + .oauth_client + .as_ref() + .ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))? + .client_id(); + + let stored = self.credential_store.load().await?; + let token_response = stored.and_then(|s| s.token_response); + + Ok((client_id.to_string(), token_response)) + } + + /// configure oauth2 client with client credentials + pub fn configure_client(&mut self, config: OAuthClientConfig) -> Result<(), AuthError> { + if self.metadata.is_none() { + return Err(AuthError::NoAuthorizationSupport); + } + + // SEP-837: only override application_type when the config sets one + if let Some(application_type) = &config.application_type { + self.application_type = Some(application_type.clone()); + } + + let metadata = self.metadata.as_ref().unwrap(); + + let auth_url = AuthUrl::new(metadata.authorization_endpoint.clone()) + .map_err(|e| AuthError::OAuthError(format!("Invalid authorization URL: {}", e)))?; + + let token_url = TokenUrl::new(metadata.token_endpoint.clone()) + .map_err(|e| AuthError::OAuthError(format!("Invalid token URL: {}", e)))?; + + let client_id = ClientId::new(config.client_id); + let redirect_url = RedirectUrl::new(config.redirect_uri.clone()) + .map_err(|e| AuthError::OAuthError(format!("Invalid re URL: {}", e)))?; + + let mut client_builder: OAuthClient = oauth2::Client::new(client_id.clone()) + .set_auth_uri(auth_url) + .set_token_uri(token_url) + .set_redirect_uri(redirect_url); + + if let Some(secret) = config.client_secret { + client_builder = client_builder.set_client_secret(ClientSecret::new(secret)); + } + + let uses_secret_post = metadata + .additional_fields + .get("token_endpoint_auth_methods_supported") + .and_then(|v| v.as_array()) + .map(|arr| { + let has_basic = arr + .iter() + .any(|m| m.as_str() == Some("client_secret_basic")); + let has_post = arr.iter().any(|m| m.as_str() == Some("client_secret_post")); + has_post && !has_basic + }) + .unwrap_or(false); + + if uses_secret_post { + client_builder = client_builder.set_auth_type(AuthType::RequestBody); + } + + self.oauth_client = Some(client_builder); + Ok(()) + } + /// validate authorization server metadata before starting authorization. + fn validate_server_metadata(&self, response_type: &str) -> Result<(), AuthError> { + let Some(metadata) = self.metadata.as_ref() else { + return Ok(()); + }; + + // RFC 8414 RECOMMENDS response_types_supported in the metadata. This field is optional, + // but if present and does not include the flow we use ("code"), bail out early with a clear error. + if let Some(response_types_supported) = metadata.response_types_supported.as_ref() + && !response_types_supported.contains(&response_type.to_string()) + { + return Err(AuthError::InvalidScope(response_type.to_string())); + } + + // The client always sends an S256 challenge. A server that advertises + // methods without S256 can't do the flow we require, so refuse it. A + // server that omits the field is tolerated: it usually means the server + // didn't advertise PKCE, not that it lacks S256. + match &metadata.code_challenge_methods_supported { + Some(methods) if !methods.iter().any(|m| m == "S256") => { + return Err(AuthError::PkceUnsupported); + } + None => { + warn!( + "authorization server metadata omits code_challenge_methods_supported; \ + proceeding with an S256 challenge anyway" + ); + } + Some(_) => {} + } + + Ok(()) + } + /// dynamic register oauth2 client + pub async fn register_client( + &mut self, + name: &str, + redirect_uri: &str, + scopes: &[&str], + ) -> Result { + if self.metadata.is_none() { + return Err(AuthError::NoAuthorizationSupport); + } + + let metadata = self.metadata.as_ref().unwrap(); + let Some(registration_url) = metadata.registration_endpoint.as_ref() else { + return Err(AuthError::RegistrationFailed( + "Dynamic client registration not supported".to_string(), + )); + }; + self.validate_server_metadata("code")?; + + let application_type = self.application_type.clone(); + let registration_request = ClientRegistrationRequest { + client_name: name.to_string(), + redirect_uris: vec![redirect_uri.to_string()], + grant_types: vec![ + "authorization_code".to_string(), + "refresh_token".to_string(), + ], + token_endpoint_auth_method: "none".to_string(), // public client + response_types: vec!["code".to_string()], + scope: if scopes.is_empty() { + None + } else { + Some(scopes.join(" ")) + }, + application_type: application_type.clone(), + }; + + let request = oauth2::http::Request::builder() + .method("POST") + .uri(registration_url) + .header(CONTENT_TYPE, "application/json") + .body( + serde_json::to_vec(®istration_request) + .map_err(|error| AuthError::RegistrationFailed(error.to_string()))?, + ) + .map_err(|error| AuthError::RegistrationFailed(error.to_string()))?; + let response = match self + .http_client + .execute(OAuthHttpRequest::new( + request, + OAuthHttpRedirectPolicy::Follow, + )) + .await + { + Ok(response) => response, + Err(e) => { + return Err(AuthError::RegistrationFailed(format!( + "HTTP request error: {}", + e + ))); + } + }; + + if !response.status().is_success() { + let status = response.status(); + let error_text = String::from_utf8_lossy(response.body()); + + return Err(AuthError::RegistrationFailed(format!( + "HTTP {}: {}", + status, error_text + ))); + } + + debug!("registration response status: {:?}", response.status()); + let reg_response = + match serde_json::from_slice::(response.body()) { + Ok(response) => response, + Err(e) => { + return Err(AuthError::RegistrationFailed(format!( + "analyze response error: {}", + e + ))); + } + }; + + let config = OAuthClientConfig { + client_id: reg_response.client_id, + // Some IdP returns a response where the field 'client_secret' is present but with empty string value. + // In that case, the interpretation is that the client is a public client and does not have a secret during the + // registration phase here, e.g. dynamic client registrations. + // + // Even though whether or not the empty string is valid is outside of the scope of Oauth2 spec, + // we should treat it as no secret since otherwise we end up authenticating with a valid client_id with an empty client_secret + // as a password, which is not a goal of the client secret. + client_secret: reg_response.client_secret.filter(|s| !s.is_empty()), + redirect_uri: redirect_uri.to_string(), + scopes: scopes.iter().map(|s| s.to_string()).collect(), + application_type, + }; + + self.configure_client(config.clone())?; + Ok(config) + } + + /// use provided client id to configure oauth2 client instead of dynamic registration + /// this is useful when you have a stored client id from previous registration + pub fn configure_client_id(&mut self, client_id: &str) -> Result<(), AuthError> { + let config = OAuthClientConfig { + client_id: client_id.to_string(), + client_secret: None, + scopes: vec![], + redirect_uri: self.base_url.to_string(), + // keep the manager's current application_type + application_type: None, + }; + self.configure_client(config) + } + + /// generate authorization url + pub async fn get_authorization_url(&self, scopes: &[&str]) -> Result { + let oauth_client = self + .oauth_client + .as_ref() + .ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))?; + self.validate_server_metadata("code")?; + + // generate pkce challenge + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + + // build authorization request + let mut auth_request = oauth_client + .authorize_url(CsrfToken::new_random) + .set_pkce_challenge(pkce_challenge) + .add_extra_param("resource", self.oauth_resource().await); + + // add request scopes + for scope in scopes { + auth_request = auth_request.add_scope(Scope::new(scope.to_string())); + } + + let (auth_url, csrf_token) = auth_request.url(); + + // store pkce verifier, expected issuer, and the requested scopes for later use via state store + let expected_issuer = self + .metadata + .as_ref() + .and_then(|metadata| metadata.issuer.clone()); + let require_issuer = self + .metadata + .as_ref() + .and_then(|metadata| { + metadata + .additional_fields + .get("authorization_response_iss_parameter_supported") + .and_then(|value| value.as_bool()) + }) + .unwrap_or(false); + let stored_state = StoredAuthorizationState::new_with_expected_issuer( + &pkce_verifier, + &csrf_token, + expected_issuer, + require_issuer, + ) + .with_requested_scopes(scopes.iter().map(|s| s.to_string()).collect()); + self.state_store + .save(csrf_token.secret(), stored_state) + .await?; + + Ok(auth_url.to_string()) + } + + async fn oauth_resource(&self) -> String { + self.discovered_resource + .read() + .await + .clone() + .unwrap_or_else(|| self.base_url.to_string()) + } + + /// get the current granted scopes + pub async fn get_current_scopes(&self) -> Vec { + self.current_scopes.read().await.clone() + } + + /// compute the union of current scopes and required scopes + fn compute_scope_union(current: &[String], required: &str) -> Vec { + let mut scopes = current.to_vec(); + scopes.extend(required.split_whitespace().map(|s| s.to_string())); + Self::dedup_scopes(scopes) + } + + /// deduplicate scopes preserving first-seen order (SEP-2350: stable for testability) + fn dedup_scopes(scopes: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + scopes + .into_iter() + .filter(|s| seen.insert(s.clone())) + .collect() + } + + /// resolve the granted scope set from a token response (SEP-2350, RFC 6749 §5.1): an explicit + /// `scope` may narrow the grant; an omitted one means the request was granted in full, so fall + /// back to the requested scopes (or the previously granted set when none were recorded). + fn resolve_granted_scopes( + response_scopes: Option>, + requested_scopes: &[String], + current_scopes: &[String], + ) -> Vec { + match response_scopes { + Some(scopes) => scopes, + None if !requested_scopes.is_empty() => requested_scopes.to_vec(), + None => current_scopes.to_vec(), + } + } + + /// check if a scope upgrade is possible and allowed + pub async fn can_attempt_scope_upgrade(&self) -> bool { + if !self.scope_upgrade_config.auto_upgrade { + return false; + } + let attempts = *self.scope_upgrade_attempts.read().await; + attempts < self.scope_upgrade_config.max_upgrade_attempts + } + + /// select scopes to request from authorization server + pub fn select_scopes( + &self, + www_authenticate_scope: Option<&str>, + default_scopes: &[&str], + ) -> Vec { + let mut scopes = self.select_base_scopes(www_authenticate_scope, default_scopes); + self.add_offline_access_if_supported(&mut scopes); + scopes + } + + /// select scopes following SEP-2350: re-authorization requests the union of the + /// previously requested scopes and the newly challenged scopes. Server-reported + /// scopes (WWW-Authenticate challenge, protected resource metadata) are operational + /// requirements for the current operation, never an exclusive directive, so they + /// accumulate rather than replace. The AS metadata and caller defaults only seed the + /// request when nothing has been requested or challenged yet. + fn select_base_scopes( + &self, + www_authenticate_scope: Option<&str>, + default_scopes: &[&str], + ) -> Vec { + let mut accumulated: Vec = Vec::new(); + + // previously requested scopes + if let Ok(guard) = self.current_scopes.try_read() { + accumulated.extend(guard.iter().cloned()); + } + + // newly challenged scopes for the current operation (RFC 6750 §3.1) + if let Some(scope) = www_authenticate_scope { + accumulated.extend(scope.split_whitespace().map(|s| s.to_string())); + } + if let Ok(guard) = self.www_auth_scopes.try_read() { + accumulated.extend(guard.iter().cloned()); + } + + // scopes required for the current operation per protected resource metadata (RFC 9728) + if let Ok(guard) = self.resource_scopes.try_read() { + accumulated.extend(guard.iter().cloned()); + } + + if !accumulated.is_empty() { + return Self::dedup_scopes(accumulated); + } + + // nothing requested or challenged yet: seed from AS metadata, then caller defaults + if let Some(metadata) = &self.metadata + && let Some(scopes_supported) = &metadata.scopes_supported + && !scopes_supported.is_empty() + { + return scopes_supported.clone(); + } + + default_scopes.iter().map(|s| s.to_string()).collect() + } + + /// SEP-2207: when the AS advertises `offline_access` in `scopes_supported`, append + /// it so OIDC-flavored Authorization Servers will issue refresh tokens. + fn add_offline_access_if_supported(&self, scopes: &mut Vec) { + if scopes.is_empty() || scopes.iter().any(|s| s == "offline_access") { + return; + } + if let Some(metadata) = &self.metadata + && let Some(supported) = &metadata.scopes_supported + && supported.iter().any(|s| s == "offline_access") + { + scopes.push("offline_access".to_string()); + } + } + + /// attempt to upgrade scopes after receiving a 403 insufficient_scope error. + /// returns the authorization URL for re-authorization with upgraded scopes. + pub async fn request_scope_upgrade(&self, required_scope: &str) -> Result { + if !self.scope_upgrade_config.auto_upgrade { + return Err(AuthError::InvalidScope( + "Scope upgrade is disabled".to_string(), + )); + } + + let mut attempts = self.scope_upgrade_attempts.write().await; + if *attempts >= self.scope_upgrade_config.max_upgrade_attempts { + return Err(AuthError::InvalidScope(format!( + "Maximum scope upgrade attempts ({}) exceeded", + self.scope_upgrade_config.max_upgrade_attempts + ))); + } + + *attempts += 1; + drop(attempts); + + let current_scopes = self.current_scopes.read().await.clone(); + let mut upgraded_scopes = Self::compute_scope_union(¤t_scopes, required_scope); + self.add_offline_access_if_supported(&mut upgraded_scopes); + + debug!( + "Requesting scope upgrade: current={:?}, required={}, union={:?}", + current_scopes, required_scope, upgraded_scopes + ); + + let scope_refs: Vec<&str> = upgraded_scopes.iter().map(|s| s.as_str()).collect(); + self.get_authorization_url(&scope_refs).await + } + + /// reset scope upgrade attempt counter + pub async fn reset_scope_upgrade_attempts(&self) { + *self.scope_upgrade_attempts.write().await = 0; + } + + /// get the number of scope upgrade attempts made + pub async fn get_scope_upgrade_attempts(&self) -> u32 { + *self.scope_upgrade_attempts.read().await + } + + fn validate_authorization_response_issuer( + stored_state: &StoredAuthorizationState, + received_issuer: Option<&str>, + ) -> Result<(), AuthError> { + let Some(expected_issuer) = stored_state.expected_issuer.as_deref() else { + if received_issuer.is_some() || stored_state.require_issuer { + return Err(AuthError::AuthorizationFailed( + "Authorization callback issuer cannot be validated because expected issuer was not recorded" + .to_string(), + )); + } + // Without issuer metadata from discovery and without an issuer-bearing + // callback, there is no stable value to bind to. This preserves + // compatibility with older authorization servers. + return Ok(()); + }; + let Some(received_issuer) = received_issuer else { + if stored_state.require_issuer { + return Err(AuthError::AuthorizationServerMissingIssuer { + expected_issuer: expected_issuer.to_string(), + }); + } + // SEP-2468 recommends RFC 9207 `iss`, but tolerate older authorization + // servers that do not advertise support for backwards compatibility. + return Ok(()); + }; + if received_issuer != expected_issuer { + return Err(AuthError::AuthorizationServerMismatch { + expected_issuer: expected_issuer.to_string(), + received_issuer: received_issuer.to_string(), + }); + } + Ok(()) + } + + /// exchange authorization code for access token + pub async fn exchange_code_for_token( + &self, + code: &str, + csrf_token: &str, + ) -> Result { + self.exchange_code_for_token_with_issuer(code, csrf_token, None) + .await + } + + /// exchange authorization code for access token, validating the optional + /// RFC 9207 authorization response issuer (`iss`) when present. + pub async fn exchange_code_for_token_with_issuer( + &self, + code: &str, + csrf_token: &str, + received_issuer: Option<&str>, + ) -> Result { + debug!("start exchange code for token: {:?}", code); + let oauth_client = self + .oauth_client + .as_ref() + .ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))?; + + // Load state from state store using CSRF token as key + let stored_state = + self.state_store.load(csrf_token).await?.ok_or_else(|| { + AuthError::InternalError("Authorization state not found".to_string()) + })?; + + // Delete state after retrieval (one-time use) + self.state_store.delete(csrf_token).await?; + + Self::validate_authorization_response_issuer(&stored_state, received_issuer)?; + + // capture requested scopes before the state is consumed + let requested_scopes = stored_state.requested_scopes.clone(); + + // Reconstruct the PKCE verifier + let pkce_verifier = stored_state.into_pkce_verifier(); + + debug!("client_id: {:?}", oauth_client.client_id()); + + // exchange token + let token_result = match oauth_client + .exchange_code(AuthorizationCode::new(code.to_string())) + .set_pkce_verifier(pkce_verifier) + .add_extra_param("resource", self.oauth_resource().await) + .request_async(&OAuth2HttpClient { + client: self.http_client.as_ref(), + redirect_policy: OAuthHttpRedirectPolicy::Stop, + }) + .await + { + Ok(token) => token, + Err(RequestTokenError::Parse(_, body)) => { + match serde_json::from_slice::(&body) { + Ok(parsed) => { + warn!( + "token exchange failed to parse completely but included a valid token response. Accepting it." + ); + parsed + } + Err(parse_err) => { + return Err(AuthError::TokenExchangeFailed(parse_err.to_string())); + } + } + } + Err(e) => { + return Err(AuthError::TokenExchangeFailed(e.to_string())); + } + }; + + debug!("exchange token result: {:?}", token_result); + + // SEP-2350: an omitted `scope` means the grant equals the request (RFC 6749 §5.1). + let response_scopes = token_result + .scopes() + .map(|scopes| scopes.iter().map(|s| s.to_string()).collect()); + let granted_scopes = { + let current = self.current_scopes.read().await; + Self::resolve_granted_scopes(response_scopes, &requested_scopes, ¤t) + }; + + *self.current_scopes.write().await = granted_scopes.clone(); + *self.scope_upgrade_attempts.write().await = 0; + + let client_id = oauth_client.client_id().to_string(); + let stored = StoredCredentials { + client_id, + token_response: Some(token_result.clone()), + granted_scopes, + token_received_at: Some(Self::now_epoch_secs()), + issuer: self.metadata_issuer(), + }; + self.credential_store.save(stored).await?; + + Ok(token_result) + } + + fn now_epoch_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + } + + fn metadata_issuer(&self) -> Option { + self.metadata.as_ref().and_then(|m| m.issuer.clone()) + } + + /// Proactive refresh buffer: refresh tokens this many seconds before they expire + /// to avoid races between token retrieval and the actual HTTP request. + const REFRESH_BUFFER_SECS: u64 = 30; + + /// Get access token from local credential store. + /// If expired, refresh it automatically when a refresh token is available. + /// When the access token has expired and no refresh token is available, or the + /// authorization server rejects the refresh token, returns + /// [`AuthError::AuthorizationRequired`] so the caller can re-authenticate. + /// Transient refresh failures return [`AuthError::TokenRefreshFailed`] so the + /// caller can retry; other errors are propagated as-is. + pub async fn get_access_token(&self) -> Result { + let stored = self.credential_store.load().await?; + let Some(stored_creds) = stored else { + return Err(AuthError::AuthorizationRequired); + }; + let Some(creds) = stored_creds.token_response.as_ref() else { + return Err(AuthError::AuthorizationRequired); + }; + + if let (Some(expires_in), Some(received_at)) = + (creds.expires_in(), stored_creds.token_received_at) + { + let elapsed = Self::now_epoch_secs().saturating_sub(received_at); + let remaining = expires_in.as_secs().saturating_sub(elapsed); + + if remaining < Self::REFRESH_BUFFER_SECS { + tracing::info!( + remaining_secs = remaining, + "Access token expired or nearly expired, refreshing." + ); + return self.try_refresh_or_reauth().await; + } + } + + // When expiry info is unavailable (e.g., credentials stored before + // token_received_at was tracked), skip the expiry check and return + // the token as-is. + Ok(creds.access_token().secret().to_string()) + } + + /// Attempt to refresh the token. If refresh fails because there is no + /// refresh token or the server rejected it, return `AuthorizationRequired` + /// so the caller can re-prompt the user. Infrastructure errors (e.g. store + /// I/O failures, misconfigured client) are propagated as-is. + pub(crate) async fn try_refresh_or_reauth(&self) -> Result { + match self.refresh_token().await { + Ok(new_creds) => { + tracing::info!("Refreshed access token."); + Ok(new_creds.access_token().secret().to_string()) + } + Err(e @ (AuthError::AuthorizationRequired | AuthError::TokenRefreshRejected(_))) => { + tracing::warn!(error = %e, "Token refresh not possible, re-authorization required."); + Err(AuthError::AuthorizationRequired) + } + Err(e) => Err(e), + } + } + + /// refresh access token + pub async fn refresh_token(&self) -> Result { + let oauth_client = self + .oauth_client + .as_ref() + .ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))?; + + let stored = self.credential_store.load().await?; + let stored_credentials = stored.ok_or(AuthError::AuthorizationRequired)?; + let current_credentials = stored_credentials + .token_response + .ok_or(AuthError::AuthorizationRequired)?; + + let refresh_token = current_credentials + .refresh_token() + .ok_or(AuthError::AuthorizationRequired)?; + debug!("refresh token present, attempting refresh"); + + let refresh_token_value = RefreshToken::new(refresh_token.secret().to_string()); + let mut refresh_request = oauth_client + .exchange_refresh_token(&refresh_token_value) + // RFC 8707: the resource indicator is required on token requests, including refreshes + .add_extra_param("resource", self.oauth_resource().await); + let mut refresh_scopes = stored_credentials.granted_scopes; + self.add_offline_access_if_supported(&mut refresh_scopes); + for scope in refresh_scopes { + refresh_request = refresh_request.add_scope(Scope::new(scope)); + } + let mut token_result = refresh_request + .request_async(&OAuth2HttpClient { + client: self.http_client.as_ref(), + redirect_policy: self.refresh_redirect_policy, + }) + .await + .map_err(|error| match &error { + RequestTokenError::ServerResponse(response) + if response.error() == &BasicErrorResponseType::InvalidGrant => + { + AuthError::TokenRefreshRejected(error.to_string()) + } + _ => AuthError::TokenRefreshFailed(error.to_string()), + })?; + + // RFC 6749 section 6: issuing a new refresh token on refresh is optional. + // When the response omits one, keep the existing refresh token rather than + // dropping it. When a new one is present, the response value is used as-is. + if token_result.refresh_token().is_none() { + token_result.set_refresh_token(Some(refresh_token_value)); + } + + let granted_scopes: Vec = match token_result.scopes() { + Some(scopes) => scopes.iter().map(|s| s.to_string()).collect(), + None => self.current_scopes.read().await.clone(), + }; + + *self.current_scopes.write().await = granted_scopes.clone(); + + let client_id = oauth_client.client_id().to_string(); + let stored = StoredCredentials { + client_id, + token_response: Some(token_result.clone()), + granted_scopes, + token_received_at: Some(Self::now_epoch_secs()), + issuer: self.metadata_issuer(), + }; + self.credential_store.save(stored).await?; + + Ok(token_result) + } + + /// prepare request, add authorization header + pub async fn prepare_request( + &self, + request: reqwest::RequestBuilder, + ) -> Result { + let token = self.get_access_token().await?; + Ok(request.header(AUTHORIZATION, format!("Bearer {}", token))) + } + + /// handle response, check if need to re-authorize or scope upgrade + pub async fn handle_response( + &self, + response: reqwest::Response, + ) -> Result { + if response.status() == StatusCode::UNAUTHORIZED { + return Err(AuthError::AuthorizationRequired); + } + if response.status() == StatusCode::FORBIDDEN { + for value in response.headers().get_all(WWW_AUTHENTICATE).iter() { + let Ok(value_str) = value.to_str() else { + continue; + }; + let params = Self::extract_www_authenticate_params(value_str, &self.base_url); + if params.is_insufficient_scope() { + let required_scope = params.scope.unwrap_or_default(); + return Err(AuthError::InsufficientScope { + required_scope, + upgrade_url: None, + }); + } + } + return Err(AuthError::AuthorizationFailed("Forbidden".to_string())); + } + Ok(response) + } + + /// Generate discovery endpoint URLs following the priority order in spec-2025-11-25 4.3 "Authorization Server Metadata Discovery". + fn generate_discovery_urls(base_url: &Url) -> Vec { + let mut candidates = Vec::new(); + let path = base_url.path(); + let trimmed = path.trim_start_matches('/').trim_end_matches('/'); + let mut push_candidate = |discovery_path: String| { + let mut discovery_url = base_url.clone(); + discovery_url.set_query(None); + discovery_url.set_fragment(None); + discovery_url.set_path(&discovery_path); + candidates.push(discovery_url); + }; + if trimmed.is_empty() { + // No path components: try OAuth first, then OpenID Connect + push_candidate("/.well-known/oauth-authorization-server".to_string()); + push_candidate("/.well-known/openid-configuration".to_string()); + } else { + // Path components present: prefer OAuth discovery before OpenID Connect fallbacks. + // 1. OAuth 2.0 with path insertion + push_candidate(format!("/.well-known/oauth-authorization-server/{trimmed}")); + // 2. OpenID Connect with path insertion + push_candidate(format!("/.well-known/openid-configuration/{trimmed}")); + // 3. OpenID Connect with path appending + push_candidate(format!("/{trimmed}/.well-known/openid-configuration")); + // 4. Canonical OAuth fallback (without path suffix) + push_candidate("/.well-known/oauth-authorization-server".to_string()); + } + + candidates + } + + async fn try_discover_oauth_server( + &self, + base_url: &Url, + ) -> Result, AuthError> { + for discovery_url in Self::generate_discovery_urls(base_url) { + if let Some(metadata) = self.fetch_authorization_metadata(&discovery_url).await? { + return Ok(Some(metadata)); + } + } + Ok(None) + } + + async fn fetch_authorization_metadata( + &self, + discovery_url: &Url, + ) -> Result, AuthError> { + debug!("discovery url: {:?}", discovery_url); + let response = self + .discovery_get(discovery_url) + .await + .map_err(|error| Self::discovery_failed(discovery_url, error))?; + + if response.status() != StatusCode::OK { + debug!("discovery returned non-200: {}", response.status()); + return Ok(None); + } + + match serde_json::from_slice::(response.body()) { + Ok(metadata) => { + self.validate_authorization_metadata_issuer(discovery_url, &metadata)?; + Ok(Some(metadata)) + } + Err(err) => { + debug!("Failed to parse metadata for {}: {}", discovery_url, err); + Ok(None) // malformed JSON ⇒ try next candidate + } + } + } + + fn expected_issuer_for_authorization_metadata_url(discovery_url: &Url) -> Option { + let path = discovery_url.path(); + let oauth_prefix = "/.well-known/oauth-authorization-server"; + let oidc_prefix = "/.well-known/openid-configuration"; + + let issuer_path = if path == oauth_prefix || path == oidc_prefix { + "" + } else if let Some(suffix) = path.strip_prefix(&format!("{oauth_prefix}/")) { + // RFC 8414 path-insertion form + suffix + } else if let Some(suffix) = path.strip_prefix(&format!("{oidc_prefix}/")) { + // MCP-required OpenID Connect path-insertion compatibility form + suffix + } else if let Some(prefix) = path.strip_suffix(oidc_prefix) { + // OpenID Connect path-appended form + prefix.trim_start_matches('/') + } else { + return None; + }; + + let mut issuer = discovery_url.clone(); + issuer.set_query(None); + issuer.set_fragment(None); + if issuer_path.is_empty() { + issuer.set_path(""); + } else { + issuer.set_path(&format!("/{issuer_path}")); + } + Some(issuer.to_string()) + } + + fn issuer_identifiers_match(received_issuer: &str, expected_issuer: &str) -> bool { + if received_issuer == expected_issuer { + return true; + } + + let trim_root_slash = |issuer: &str| -> String { + issuer + .strip_suffix('/') + .filter(|without_slash| { + Url::parse(without_slash) + .map(|url| url.path().is_empty() || url.path() == "/") + .unwrap_or(false) + }) + .unwrap_or(issuer) + .to_string() + }; + + trim_root_slash(received_issuer) == trim_root_slash(expected_issuer) + } + + fn validate_authorization_metadata_issuer( + &self, + discovery_url: &Url, + metadata: &AuthorizationMetadata, + ) -> Result<(), AuthError> { + let Some(expected_issuer) = + Self::expected_issuer_for_authorization_metadata_url(discovery_url) + else { + return Ok(()); + }; + let Some(received_issuer) = metadata.issuer.as_deref() else { + if self.allow_missing_issuer { + return Ok(()); + } + return Err(AuthError::AuthorizationServerMissingIssuer { expected_issuer }); + }; + if !Self::issuer_identifiers_match(received_issuer, &expected_issuer) { + return Err(AuthError::AuthorizationServerMismatch { + expected_issuer, + received_issuer: received_issuer.to_string(), + }); + } + Ok(()) + } + + async fn discover_oauth_server_via_resource_metadata( + &self, + ) -> Result, AuthError> { + let Some(resource_metadata_url) = self.discover_resource_metadata_url().await? else { + return Ok(None); + }; + self.discover_oauth_server_from_resource_metadata_url(&resource_metadata_url) + .await + } + + async fn discover_oauth_server_from_resource_metadata_url( + &self, + resource_metadata_url: &Url, + ) -> Result, AuthError> { + let Some(resource_metadata) = self + .fetch_resource_metadata_from_url(resource_metadata_url) + .await? + else { + return Ok(None); + }; + + self.validate_resource_metadata_resource(&resource_metadata)?; + + self.discovered_resource + .write() + .await + .replace(resource_metadata.resource.clone().unwrap_or_default()); + + // store scopes_supported from protected resource metadata for select_scopes() + if let Some(scopes) = resource_metadata.scopes_supported + && !scopes.is_empty() + { + *self.resource_scopes.write().await = scopes; + } + + let mut candidates = Vec::new(); + + if let Some(single) = resource_metadata.authorization_server { + candidates.push(single); + } + if let Some(list) = resource_metadata.authorization_servers { + candidates.extend(list); + } + + for candidate in candidates { + let candidate = candidate.trim(); + if candidate.is_empty() { + continue; + } + + let candidate_url = match Url::parse(candidate) { + Ok(url) => url, + Err(_) => match resource_metadata_url.join(candidate) { + Ok(url) => url, + Err(e) => { + debug!("Failed to resolve authorization server URL `{candidate}`: {e}"); + continue; + } + }, + }; + + if !Self::is_allowed_authorization_server_metadata_url(&self.base_url, &candidate_url) { + warn!("rejecting authorization server metadata URL `{candidate_url}`"); + continue; + } + + if candidate_url.path().contains("/.well-known/") { + if let Some(metadata) = self.fetch_authorization_metadata(&candidate_url).await? { + return Ok(Some(metadata)); + } + continue; + } + + if let Some(metadata) = self.try_discover_oauth_server(&candidate_url).await? { + return Ok(Some(metadata)); + } + } + + Ok(None) + } + + fn validate_resource_metadata_resource( + &self, + metadata: &ResourceServerMetadata, + ) -> Result<(), AuthError> { + let Some(resource) = metadata.resource.as_deref() else { + return Err(AuthError::MetadataError( + "Protected resource metadata missing required resource field".to_string(), + )); + }; + + let Ok(resource_url) = Url::parse(resource) else { + return Err(AuthError::MetadataError( + "Protected resource metadata resource field is not a valid URL".to_string(), + )); + }; + + if resource_url.fragment().is_some() { + return Err(AuthError::MetadataError( + "Protected resource metadata resource does not permit fragment in URL as specified by RFC 8707".to_string() + )); + } + + if !Self::is_resource_identifier_valid(&self.base_url, &resource_url) { + return Err(AuthError::MetadataError(format!( + "Protected resource metadata resource mismatch: reference '{}', permitted '{}'", + self.base_url, resource + ))); + } + + Ok(()) + } + + fn is_resource_identifier_valid(expected: &Url, actual: &Url) -> bool { + if expected == actual { + return true; + } + + if expected.scheme() != actual.scheme() + || expected.host_str() != actual.host_str() + || expected.port_or_known_default() != actual.port_or_known_default() + { + return false; + } + + let expected_path = expected.path(); + let actual_path = actual.path(); + + // URL query part supported, even if it is discouraged in RFC 8707 + if expected_path == actual_path && expected.query() == actual.query() { + return true; + } + + expected_path.starts_with(actual_path) + && expected.query().is_none() + && actual.query().is_none() + && (actual_path.ends_with('/') + || expected_path.as_bytes().get(actual_path.len()) == Some(&b'/')) + } + + async fn discover_resource_metadata_url(&self) -> Result, AuthError> { + if let Some(resource_metadata_url) = + self.probe_resource_metadata_url(&self.base_url).await? + { + return Ok(Some(resource_metadata_url)); + } + + // If the primary URL doesn't use WWW-Authenticate, try oauth-protected-resource discovery. + // https://www.rfc-editor.org/rfc/rfc9728.html#name-obtaining-protected-resourc + for candidate_path in + Self::well_known_paths(self.base_url.path(), "oauth-protected-resource") + { + let mut discovery_url = self.base_url.clone(); + discovery_url.set_query(None); + discovery_url.set_fragment(None); + discovery_url.set_path(&candidate_path); + if let Some(resource_metadata_url) = + self.probe_resource_metadata_url(&discovery_url).await? + { + return Ok(Some(resource_metadata_url)); + } + } + + Ok(None) + } + + /// Probe `url` with a GET, extracting the resource metadata url from a + /// 200 (the url itself is the metadata document) or from a 401's + /// WWW-Authenticate header value. + /// https://www.rfc-editor.org/rfc/rfc9728.html#name-use-of-www-authenticate-for + async fn probe_resource_metadata_url(&self, url: &Url) -> Result, AuthError> { + let response = self + .discovery_get(url) + .await + .map_err(|error| Self::discovery_failed(url, error))?; + + match response.status() { + StatusCode::OK => Ok(Some(url.clone())), + StatusCode::UNAUTHORIZED => Ok(self + .extract_resource_metadata_url_from_www_authenticate(&response) + .await), + status => { + debug!("resource metadata probe returned unexpected status: {status}"); + Ok(None) + } + } + } + + async fn extract_resource_metadata_url_from_www_authenticate( + &self, + response: &HttpResponse, + ) -> Option { + let mut parsed_url = None; + for value in response.headers().get_all(WWW_AUTHENTICATE).iter() { + let Ok(value_str) = value.to_str() else { + continue; + }; + let params = Self::extract_www_authenticate_params(value_str, &self.base_url); + if params.resource_metadata_url.is_some() { + self.record_challenge_scope(¶ms).await; + parsed_url = params.resource_metadata_url; + break; + } + } + + parsed_url + } + + async fn fetch_resource_metadata_from_url( + &self, + resource_metadata_url: &Url, + ) -> Result, AuthError> { + debug!( + "resource metadata discovery url: {:?}", + resource_metadata_url + ); + let response = self + .discovery_get(resource_metadata_url) + .await + .map_err(|error| Self::discovery_failed(resource_metadata_url, error))?; + + if response.status() != StatusCode::OK { + debug!( + "resource metadata request returned non-200: {}", + response.status() + ); + return Ok(None); + } + + let metadata = match serde_json::from_slice::(response.body()) { + Ok(metadata) => metadata, + Err(e) => { + debug!("failed to parse resource metadata as JSON: {}", e); + return Ok(None); + } + }; + Ok(Some(metadata)) + } + + fn discovery_failed(url: &Url, error: OAuthHttpClientError) -> AuthError { + AuthError::MetadataError(format!( + "OAuth metadata discovery failed for {url}\n Caused by: {}", + crate::error::ErrorChain(error.as_ref()) + )) + } + + async fn discovery_request( + &self, + request: OAuthHttpRequest, + ) -> Result { + let response = self.http_client.execute(request).await?; + let status = response.status(); + if status.is_server_error() + || matches!( + status, + StatusCode::REQUEST_TIMEOUT | StatusCode::TOO_EARLY | StatusCode::TOO_MANY_REQUESTS + ) + { + return Err(Box::new(OAuthHttpError::UnexpectedStatus(status))); + } + Ok(response) + } + + async fn discovery_get(&self, url: &Url) -> Result { + let mut current_url = url.clone(); + for _ in 0..MAX_OAUTH_DISCOVERY_REDIRECTS { + let request = oauth2::http::Request::builder() + .method("GET") + .uri(current_url.as_str()) + .header(HEADER_MCP_PROTOCOL_VERSION, "2024-11-05") + .body(Vec::new()) + .map_err(|error| Box::new(error) as OAuthHttpClientError)?; + let response = self + .discovery_request(OAuthHttpRequest::new( + request, + OAuthHttpRedirectPolicy::Stop, + )) + .await?; + + if !response.status().is_redirection() { + return Ok(response); + } + + let Some(location) = response.headers().get(LOCATION) else { + return Ok(response); + }; + let location = location + .to_str() + .map_err(|error| Box::new(error) as OAuthHttpClientError)?; + let next_url = current_url + .join(location) + .map_err(|error| Box::new(error) as OAuthHttpClientError)?; + + if Self::is_http_url(&next_url) && Self::is_same_origin(¤t_url, &next_url) { + current_url = next_url; + continue; + } + + return Err(Box::new(OAuthHttpError::CrossOriginRedirect(next_url))); + } + + Err(Box::new(OAuthHttpError::TooManyRedirects( + MAX_OAUTH_DISCOVERY_REDIRECTS, + ))) + } + + /// extract parameters from WWW-Authenticate header (resource_metadata and scope) + fn extract_www_authenticate_params(header: &str, base_url: &Url) -> WWWAuthenticateParams { + let mut params = WWWAuthenticateParams::default(); + let header_lowercase = header.to_ascii_lowercase(); + + // extract resource_metadata + let mut search_offset = 0; + let resource_key = "resource_metadata="; + while let Some(pos) = header_lowercase[search_offset..].find(resource_key) { + let global_pos = search_offset + pos + resource_key.len(); + let value_slice = &header[global_pos..]; + if let Some((value, consumed)) = Self::parse_next_header_value(value_slice) { + if let Some(url) = Self::resolve_resource_metadata_url(&value, base_url) { + params.resource_metadata_url = Some(url); + break; + } + search_offset = global_pos + consumed; + continue; + } else { + break; + } + } + + // extract scope + let scope_key = "scope="; + if let Some(pos) = header_lowercase.find(scope_key) { + let global_pos = pos + scope_key.len(); + let value_slice = &header[global_pos..]; + if let Some((value, _consumed)) = Self::parse_next_header_value(value_slice) { + params.scope = Some(value); + } + } + + // extract error + let error_key = "error="; + if let Some(pos) = header_lowercase.find(error_key) { + let global_pos = pos + error_key.len(); + let value_slice = &header[global_pos..]; + if let Some((value, _consumed)) = Self::parse_next_header_value(value_slice) { + params.error = Some(value); + } + } + + // extract error_description + let desc_key = "error_description="; + if let Some(pos) = header_lowercase.find(desc_key) { + let global_pos = pos + desc_key.len(); + let value_slice = &header[global_pos..]; + if let Some((value, _consumed)) = Self::parse_next_header_value(value_slice) { + params.error_description = Some(value); + } + } + + params + } + + /// Parses an authentication parameter value from a `WWW-Authenticate` header fragment. + /// The header fragment should start with the header value after the `=` character and then + /// reads until the value ends. + /// + /// Returns the extracted value together with the number of bytes consumed from the provided + /// fragment. Quoted values support escaped characters (e.g. `\"`). The parser skips leading + /// whitespace before reading either a quoted or token value. If no well-formed value is found, + /// `None` is returned. + fn parse_next_header_value(header_fragment: &str) -> Option<(String, usize)> { + let trimmed = header_fragment.trim_start(); + let leading_ws = header_fragment.len() - trimmed.len(); + + if let Some(stripped) = trimmed.strip_prefix('"') { + let mut escaped = false; + let mut result = String::new(); + #[allow(clippy::manual_strip)] + for (idx, ch) in stripped.char_indices() { + if escaped { + result.push(ch); + escaped = false; + continue; + } + match ch { + '\\' => escaped = true, + '"' => return Some((result, leading_ws + idx + 2)), + _ => result.push(ch), + } + } + None + } else { + let end = trimmed + .find(|c: char| c == ',' || c == ';' || c.is_whitespace()) + .unwrap_or(trimmed.len()); + Some((trimmed[..end].to_string(), leading_ws + end)) + } + } + + // -- Client Credentials flow (SEP-1046) -- + + /// Validate that the authorization server metadata supports the requested + /// client credentials authentication method. + /// + /// For `client_secret_post`, checks `token_endpoint_auth_methods_supported`. + /// For `private_key_jwt`, additionally checks `token_endpoint_auth_signing_alg_values_supported`. + /// When the metadata field is absent, the method is permissive (assumes support). + pub fn validate_client_credentials_metadata( + &self, + config: &ClientCredentialsConfig, + ) -> Result<(), AuthError> { + let Some(metadata) = self.metadata.as_ref() else { + return Ok(()); + }; + + if let Some(methods) = metadata + .additional_fields + .get("token_endpoint_auth_methods_supported") + .and_then(|v| v.as_array()) + { + let is_supported = match config { + ClientCredentialsConfig::ClientSecret { .. } => { + // Accept either client_secret_post (request body) or + // client_secret_basic (HTTP Basic) per the MCP auth spec. + methods.iter().any(|m| { + matches!( + m.as_str(), + Some("client_secret_post") | Some("client_secret_basic") + ) + }) + } + #[cfg(feature = "auth-client-credentials-jwt")] + ClientCredentialsConfig::PrivateKeyJwt { .. } => methods + .iter() + .any(|m| m.as_str() == Some("private_key_jwt")), + }; + if !is_supported { + let required_method = config.auth_method(); + let supported: Vec<&str> = methods.iter().filter_map(|m| m.as_str()).collect(); + return Err(AuthError::ClientCredentialsError(format!( + "Authorization server does not support auth method '{}'. Supported: {:?}", + required_method, supported + ))); + } + } + + #[cfg(feature = "auth-client-credentials-jwt")] + if let ClientCredentialsConfig::PrivateKeyJwt { + signing_algorithm, .. + } = config + && let Some(algs) = metadata + .additional_fields + .get("token_endpoint_auth_signing_alg_values_supported") + .and_then(|v| v.as_array()) + { + let alg_str = signing_algorithm.as_str(); + if !algs.iter().any(|a| a.as_str() == Some(alg_str)) { + let supported: Vec<&str> = algs.iter().filter_map(|a| a.as_str()).collect(); + return Err(AuthError::ClientCredentialsError(format!( + "Authorization server does not support signing algorithm '{}'. Supported: {:?}", + alg_str, supported + ))); + } + } + + Ok(()) + } + + /// Configure the OAuth2 client for the client credentials flow. + /// + /// Selects `client_secret_post` (request body) by default. Switches to + /// `client_secret_basic` (HTTP Basic) only when the server advertises that + /// method exclusively. For `PrivateKeyJwt`, no OAuth client state is needed + /// here; the token request is built manually in `exchange_client_credentials_jwt`. + pub fn configure_client_credentials( + &mut self, + config: &ClientCredentialsConfig, + ) -> Result<(), AuthError> { + let metadata = self + .metadata + .as_ref() + .ok_or(AuthError::NoAuthorizationSupport)?; + + let token_url = TokenUrl::new(metadata.token_endpoint.clone()) + .map_err(|e| AuthError::OAuthError(format!("Invalid token URL: {}", e)))?; + + // auth_url is required by the type but won't be used for client credentials + let auth_url = AuthUrl::new(metadata.authorization_endpoint.clone()) + .map_err(|e| AuthError::OAuthError(format!("Invalid authorization URL: {}", e)))?; + + let client_id = ClientId::new(config.client_id().to_string()); + + let mut client_builder: OAuthClient = oauth2::Client::new(client_id) + .set_auth_uri(auth_url) + .set_token_uri(token_url); + + match config { + ClientCredentialsConfig::ClientSecret { client_secret, .. } => { + client_builder = + client_builder.set_client_secret(ClientSecret::new(client_secret.clone())); + // Use client_secret_basic (HTTP Basic) when that is the only method + // the server advertises; fall back to client_secret_post (request body). + let only_basic = metadata + .additional_fields + .get("token_endpoint_auth_methods_supported") + .and_then(|v| v.as_array()) + .map(|arr| { + let (has_basic, has_post) = + arr.iter() + .fold((false, false), |(b, p), m| match m.as_str() { + Some("client_secret_basic") => (true, p), + Some("client_secret_post") => (b, true), + _ => (b, p), + }); + has_basic && !has_post + }) + .unwrap_or_default(); + if !only_basic { + client_builder = client_builder.set_auth_type(AuthType::RequestBody); + } + } + #[cfg(feature = "auth-client-credentials-jwt")] + ClientCredentialsConfig::PrivateKeyJwt { .. } => { + // For JWT, client identity comes from the assertion's `sub` claim. + // The request is built manually in exchange_client_credentials_jwt to + // ensure client_id is not included in the body per RFC 7523 §3. + } + } + + self.oauth_client = Some(client_builder); + Ok(()) + } + + /// Exchange client credentials for an access token (SEP-1046). + /// + /// For `ClientSecret`: sends credentials in the request body (or Authorization header + /// for `client_secret_basic`) with scopes and resource. + /// For `PrivateKeyJwt`: builds the request manually (no `client_id` in body per RFC 7523 §3). + pub async fn exchange_client_credentials( + &self, + config: &ClientCredentialsConfig, + ) -> Result { + // The MCP auth spec requires the `resource` parameter in all token requests. + if config.resource().is_none() { + return Err(AuthError::ClientCredentialsError( + "resource parameter is required by the MCP auth spec".to_string(), + )); + } + + // For private_key_jwt, use a separate path that omits client_id from the request + // body, as required by RFC 7523 §3 (client is identified by the JWT `sub` claim). + #[cfg(feature = "auth-client-credentials-jwt")] + if matches!(config, ClientCredentialsConfig::PrivateKeyJwt { .. }) { + return self.exchange_client_credentials_jwt(config).await; + } + + let oauth_client = self + .oauth_client + .as_ref() + .ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))?; + + let mut request = oauth_client.exchange_client_credentials(); + + for scope in config.scopes() { + request = request.add_scope(Scope::new(scope.clone())); + } + + if let Some(resource) = config.resource() { + request = request.add_extra_param("resource", resource); + } + + let token_result = match request + .request_async(&OAuth2HttpClient { + client: self.http_client.as_ref(), + redirect_policy: OAuthHttpRedirectPolicy::Stop, + }) + .await + { + Ok(token) => token, + Err(RequestTokenError::Parse(_, body)) => { + match serde_json::from_slice::(&body) { + Ok(parsed) => { + warn!( + "client credentials token exchange failed to parse completely but included a valid token response. Accepting it." + ); + parsed + } + Err(parse_err) => { + return Err(AuthError::ClientCredentialsError(format!( + "Token exchange parse error: {}", + parse_err + ))); + } + } + } + Err(e) => { + return Err(AuthError::ClientCredentialsError(format!( + "Token exchange failed: {}", + e + ))); + } + }; + + debug!("client credentials token result: {:?}", token_result); + + let granted_scopes: Vec = token_result + .scopes() + .map(|scopes| scopes.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + + *self.current_scopes.write().await = granted_scopes.clone(); + + let client_id = config.client_id().to_string(); + let stored = StoredCredentials { + client_id, + token_response: Some(token_result.clone()), + granted_scopes, + token_received_at: Some(Self::now_epoch_secs()), + issuer: self.metadata_issuer(), + }; + self.credential_store.save(stored).await?; + + Ok(token_result) + } + + /// Exchange client credentials using a JWT assertion (RFC 7523). + /// + /// Builds the token request manually so that `client_id` is **not** included in the + /// request body; client identity is conveyed solely by the `sub` claim in the assertion. + #[cfg(feature = "auth-client-credentials-jwt")] + async fn exchange_client_credentials_jwt( + &self, + config: &ClientCredentialsConfig, + ) -> Result { + let ClientCredentialsConfig::PrivateKeyJwt { + client_id, + signing_key, + signing_algorithm, + token_endpoint_audience, + scopes, + resource, + } = config + else { + return Err(AuthError::InternalError( + "expected PrivateKeyJwt config".to_string(), + )); + }; + + let metadata = self + .metadata + .as_ref() + .ok_or(AuthError::NoAuthorizationSupport)?; + + let token_endpoint_url = url::Url::parse(&metadata.token_endpoint).map_err(|e| { + AuthError::ClientCredentialsError(format!( + "Invalid token endpoint URL in authorization metadata: {e}" + )) + })?; + if !is_allowed_client_credentials_endpoint(&self.base_url, &token_endpoint_url) { + return Err(AuthError::ClientCredentialsError( + "Insecure token endpoint URL: HTTPS is required for client credentials flow" + .to_string(), + )); + } + + let audience = + client_authentication_audience(metadata, token_endpoint_audience.as_deref())?; + + let assertion = + Self::build_jwt_assertion(client_id, audience, signing_key, *signing_algorithm)?; + + let scope_str = scopes.join(" "); + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + serializer.append_pair("grant_type", "client_credentials"); + serializer.append_pair( + "client_assertion_type", + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + ); + serializer.append_pair("client_assertion", &assertion); + if !scope_str.is_empty() { + serializer.append_pair("scope", &scope_str); + } + if let Some(res) = resource.as_deref() { + serializer.append_pair("resource", res); + } + let body_str = serializer.finish(); + + let request = oauth2::http::Request::builder() + .method("POST") + .uri(token_endpoint_url.as_str()) + .header(CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(body_str.into_bytes()) + .map_err(|error| AuthError::ClientCredentialsError(error.to_string()))?; + let response = self + .http_client + .execute(OAuthHttpRequest::new( + request, + OAuthHttpRedirectPolicy::Stop, + )) + .await + .map_err(|e| { + AuthError::ClientCredentialsError(format!("Token exchange request failed: {e}")) + })?; + + let status = response.status(); + let body = response.body(); + + if !status.is_success() { + let msg = if let Ok(v) = serde_json::from_slice::(body) { + let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("unknown"); + let desc = v + .get("error_description") + .and_then(|d| d.as_str()) + .unwrap_or(""); + format!("Token exchange failed: {error}: {desc}") + } else { + format!("Token exchange failed: HTTP {status}") + }; + return Err(AuthError::ClientCredentialsError(msg)); + } + + let token_result = serde_json::from_slice::(body).map_err(|e| { + AuthError::ClientCredentialsError(format!("Failed to parse token response: {e}")) + })?; + + debug!("client credentials JWT token result: {:?}", token_result); + + let granted_scopes: Vec = token_result + .scopes() + .map(|scopes| scopes.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + + *self.current_scopes.write().await = granted_scopes.clone(); + + let stored = StoredCredentials { + client_id: client_id.clone(), + token_response: Some(token_result.clone()), + granted_scopes, + token_received_at: Some(Self::now_epoch_secs()), + issuer: self.metadata_issuer(), + }; + self.credential_store.save(stored).await?; + + Ok(token_result) + } + + /// Build a JWT assertion per RFC 7523 for private_key_jwt authentication. + #[cfg(feature = "auth-client-credentials-jwt")] + fn build_jwt_assertion( + client_id: &str, + audience: &str, + signing_key: &[u8], + algorithm: JwtSigningAlgorithm, + ) -> Result { + use serde_json::json; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let jti = uuid::Uuid::new_v4().to_string(); + + let claims = json!({ + "iss": client_id, + "sub": client_id, + "aud": audience, + "iat": now, + "exp": now + 300, // 5 minutes + "jti": jti, + }); + + let header = client_authentication_header(algorithm); + let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(signing_key).or_else(|_| { + jsonwebtoken::EncodingKey::from_ec_pem(signing_key).map_err(|e| { + AuthError::JwtSigningError(format!("Failed to parse signing key: {}", e)) + }) + })?; + + jsonwebtoken::encode(&header, &claims, &encoding_key) + .map_err(|e| AuthError::JwtSigningError(format!("Failed to sign JWT: {}", e))) + } +} + +/// Parameters returned by an OAuth authorization redirect. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct AuthorizationCallback { + pub code: String, + pub csrf_token: String, + pub issuer: Option, +} + +impl AuthorizationCallback { + /// Parse an OAuth redirect URL and extract `code`, `state`, and optional RFC 9207 `iss`. + pub fn from_redirect_url(url: &str) -> Result { + let url = Url::parse(url)?; + let mut code = None; + let mut csrf_token = None; + let mut issuer = None; + + for (key, value) in url.query_pairs() { + match key.as_ref() { + "code" => code = Some(value.into_owned()), + "state" => csrf_token = Some(value.into_owned()), + "iss" => issuer = Some(value.into_owned()), + _ => {} + } + } + + let code = code.ok_or_else(|| { + AuthError::AuthorizationFailed("Authorization callback missing code".to_string()) + })?; + let csrf_token = csrf_token.ok_or_else(|| { + AuthError::AuthorizationFailed("Authorization callback missing state".to_string()) + })?; + + Ok(Self { + code, + csrf_token, + issuer, + }) + } +} + +/// oauth2 authorization session, for guiding user to complete the authorization process +#[non_exhaustive] +pub struct AuthorizationSession { + pub auth_manager: AuthorizationManager, + pub auth_url: String, + pub redirect_uri: String, +} + +impl AuthorizationSession { + /// Create a new authorization session, selecting a client registration + /// mechanism per the [MCP authorization specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration) + /// priority order: + /// + /// 1. Pre-registered client information + /// ([`AuthorizationRequest::with_preregistered_client`]), when available + /// 2. Client ID Metadata Documents + /// ([`AuthorizationRequest::with_client_metadata_url`]), when the + /// authorization server advertises `client_id_metadata_document_supported` + /// 3. Dynamic Client Registration, when the authorization server + /// advertises a `registration_endpoint` + /// + /// The manager must already have discovered authorization server metadata. + /// If `request.scopes` is empty, scopes are selected using the SDK's + /// normal scope-selection policy. + /// + /// On failure, the manager is returned alongside the error so callers can + /// retry without losing the original configuration and stores. + pub async fn new( + mut auth_manager: AuthorizationManager, + mut request: AuthorizationRequest, + ) -> Result { + if request.client_secret.is_some() && request.client_id.is_none() { + return Err(( + auth_manager, + AuthError::RegistrationFailed( + "client_secret was provided without a pre-registered client_id; \ + pair with_client_secret with with_preregistered_client" + .to_string(), + ), + )); + } + + if request.scopes.is_empty() { + request.scopes = auth_manager.select_scopes(None, &[]); + } else { + auth_manager.add_offline_access_if_supported(&mut request.scopes); + } + + if request.application_type.is_some() { + auth_manager.application_type = request.application_type.clone(); + } + + let redirect_uri = request.redirect_uri.clone(); + let scopes = request.scopes.clone(); + let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); + + let supports_url_based_client_id = auth_manager + .metadata + .as_ref() + .and_then(|m| { + m.additional_fields + .get("client_id_metadata_document_supported") + }) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + // 1. pre-registered client information takes priority over everything else + let config = if let Some(client_id) = &request.client_id { + OAuthClientConfig { + client_id: client_id.clone(), + client_secret: request.client_secret.clone(), + scopes: scopes.clone(), + redirect_uri: redirect_uri.clone(), + application_type: request.application_type.clone(), + } + // 2. CIMD (SEP-991), when the server advertises support and the client hosts a metadata document + } else if let Some(client_metadata_url) = request.client_metadata_url.as_deref() + && supports_url_based_client_id + { + if !is_https_url(client_metadata_url) { + return Err(( + auth_manager, + AuthError::RegistrationFailed(format!( + "client_metadata_url must be a valid HTTPS URL with a non-root pathname, got: {}", + client_metadata_url + )), + )); + } + // SEP-991: URL-based Client IDs - use URL as client_id directly. + // SEP-837: match the hosted client-metadata.json application_type ("native") + OAuthClientConfig { + client_id: client_metadata_url.to_string(), + client_secret: None, + scopes: scopes.clone(), + redirect_uri: redirect_uri.clone(), + application_type: Some( + request + .application_type + .clone() + .unwrap_or_else(|| DEFAULT_APPLICATION_TYPE.to_string()), + ), + } + // 3. fall back to dynamic client registration + } else { + match auth_manager + .register_client( + request.client_name.as_deref().unwrap_or("MCP Client"), + &redirect_uri, + &scope_refs, + ) + .await + { + Ok(config) => config, + Err(e) => { + return Err(( + auth_manager, + AuthError::RegistrationFailed(format!( + "Dynamic registration failed: {}", + e + )), + )); + } + } + }; + + // reset client config + if let Err(e) = auth_manager.configure_client(config) { + return Err((auth_manager, e)); + } + let auth_url = match auth_manager.get_authorization_url(&scope_refs).await { + Ok(url) => url, + Err(e) => return Err((auth_manager, e)), + }; + + Ok(Self { + auth_manager, + auth_url, + redirect_uri, + }) + } + + /// create session for scope upgrade flow (existing manager + pre-computed auth url) + pub fn for_scope_upgrade( + auth_manager: AuthorizationManager, + auth_url: String, + redirect_uri: &str, + ) -> Self { + Self { + auth_manager, + auth_url, + redirect_uri: redirect_uri.to_string(), + } + } + + /// get client_id and credentials + pub async fn get_credentials(&self) -> Result { + self.auth_manager.get_credentials().await + } + + /// get authorization url + pub fn get_authorization_url(&self) -> &str { + &self.auth_url + } + + /// handle authorization code callback + pub async fn handle_callback( + &self, + code: &str, + csrf_token: &str, + ) -> Result { + self.handle_callback_with_issuer(code, csrf_token, None) + .await + } + + /// handle authorization code callback, validating the optional RFC 9207 + /// authorization response issuer (`iss`) when present. + pub async fn handle_callback_with_issuer( + &self, + code: &str, + csrf_token: &str, + issuer: Option<&str>, + ) -> Result { + self.auth_manager + .exchange_code_for_token_with_issuer(code, csrf_token, issuer) + .await + } + + /// handle an OAuth redirect URL, including optional RFC 9207 `iss` validation. + pub async fn handle_callback_url( + &self, + callback_url: &str, + ) -> Result { + let callback = AuthorizationCallback::from_redirect_url(callback_url)?; + self.handle_callback_with_issuer( + &callback.code, + &callback.csrf_token, + callback.issuer.as_deref(), + ) + .await + } +} + +/// http client extension, automatically add authorization header +pub struct AuthorizedHttpClient { + auth_manager: Arc, + inner_client: ReqwestClient, +} + +impl AuthorizedHttpClient { + /// create new authorized http client + pub fn new(auth_manager: Arc, client: Option) -> Self { + let inner_client = client.unwrap_or_default(); + Self { + auth_manager, + inner_client, + } + } + + /// send authorized request + pub async fn request( + &self, + method: reqwest::Method, + url: U, + ) -> Result { + let request = self.inner_client.request(method, url); + self.auth_manager.prepare_request(request).await + } + + /// send get request + pub async fn get(&self, url: U) -> Result { + let request = self.request(reqwest::Method::GET, url).await?; + let response = request.send().await?; + self.auth_manager.handle_response(response).await + } + + /// send post request + pub async fn post(&self, url: U) -> Result { + self.request(reqwest::Method::POST, url).await + } +} + +/// OAuth state machine +/// Use the OAuthState to manage the OAuth client is more recommend +/// But also you can use the AuthorizationManager,AuthorizationSession,AuthorizedHttpClient directly +#[non_exhaustive] +pub enum OAuthState { + /// the AuthorizationManager + Unauthorized(AuthorizationManager), + /// the AuthorizationSession + Session(AuthorizationSession), + /// the authd AuthorizationManager + Authorized(AuthorizationManager), + /// the authd http client + AuthorizedHttpClient(AuthorizedHttpClient), +} + +impl OAuthState { + fn oauth_http_client_config(&self) -> (Arc, OAuthHttpRedirectPolicy) { + let manager = match self { + OAuthState::Unauthorized(manager) | OAuthState::Authorized(manager) => manager, + OAuthState::Session(session) => &session.auth_manager, + OAuthState::AuthorizedHttpClient(client) => &client.auth_manager, + }; + ( + Arc::clone(&manager.http_client), + manager.refresh_redirect_policy, + ) + } + + async fn placeholder_state(&self) -> Result { + let (http_client, refresh_redirect_policy) = self.oauth_http_client_config(); + Ok(OAuthState::Unauthorized( + AuthorizationManager::new_inner( + DEFAULT_EXCHANGE_URL, + http_client, + refresh_redirect_policy, + ) + .await?, + )) + } + + /// Create new OAuth state machine + pub async fn new( + base_url: U, + client: Option, + ) -> Result { + let mut manager = AuthorizationManager::new(base_url).await?; + if let Some(client) = client { + manager.with_client(client)?; + } + + Ok(OAuthState::Unauthorized(manager)) + } + + /// Create an OAuth state machine that routes all OAuth HTTP operations + /// through the supplied client. + pub async fn new_with_oauth_http_client( + base_url: U, + client: Arc, + ) -> Result { + let manager = AuthorizationManager::new_with_oauth_http_client(base_url, client).await?; + Ok(OAuthState::Unauthorized(manager)) + } + + /// Get client_id and OAuth credentials + pub async fn get_credentials(&self) -> Result { + // return client_id and credentials + match self { + OAuthState::Unauthorized(manager) | OAuthState::Authorized(manager) => { + manager.get_credentials().await + } + OAuthState::Session(session) => session.get_credentials().await, + OAuthState::AuthorizedHttpClient(client) => client.auth_manager.get_credentials().await, + } + } + + /// Manually set credentials and move into authorized state + /// Useful if you're caching credentials externally and wish to reuse them + pub async fn set_credentials( + &mut self, + client_id: &str, + credentials: OAuthTokenResponse, + ) -> Result<(), AuthError> { + if let OAuthState::Unauthorized(manager) = self { + let replacement = AuthorizationManager::new_inner( + DEFAULT_EXCHANGE_URL, + Arc::clone(&manager.http_client), + manager.refresh_redirect_policy, + ) + .await?; + let mut manager = std::mem::replace(manager, replacement); + + let granted_scopes: Vec = credentials + .scopes() + .map(|scopes| scopes.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + + *manager.current_scopes.write().await = granted_scopes.clone(); + + let resolution = manager.resolve_metadata().await?; + manager.metadata = Some(resolution.metadata); + + let stored = StoredCredentials { + client_id: client_id.to_string(), + token_response: Some(credentials), + granted_scopes, + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: manager.metadata_issuer(), + }; + manager.credential_store.save(stored).await?; + + manager.configure_client_id(client_id)?; + + *self = OAuthState::Authorized(manager); + Ok(()) + } else { + Err(AuthError::InternalError( + "Cannot set credentials in this state".to_string(), + )) + } + } + + /// Start authorization. + /// + /// Selects a client registration mechanism from the identity material in + /// `request`, following the [MCP authorization specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration) + /// priority order: + /// + /// 1. Pre-registered client information + /// ([`AuthorizationRequest::with_preregistered_client`]), when available + /// 2. Client ID Metadata Documents + /// ([`AuthorizationRequest::with_client_metadata_url`]), when the + /// authorization server advertises `client_id_metadata_document_supported` + /// 3. Dynamic Client Registration, when the authorization server + /// advertises a `registration_endpoint` + /// + /// If `request.scopes` is empty, scopes are selected using the SDK's + /// normal scope-selection policy. + /// + /// On failure, the state returns to `Unauthorized` so callers can retry + /// without losing the original configuration and stores. + pub async fn start_authorization( + &mut self, + request: AuthorizationRequest, + ) -> Result<(), AuthError> { + let placeholder = self.placeholder_state().await?; + let old = std::mem::replace(self, placeholder); + let OAuthState::Unauthorized(mut manager) = old else { + *self = old; + return Err(AuthError::InternalError( + "Already in session state".to_string(), + )); + }; + debug!("start discovery"); + let resolution = manager + .resolve_metadata_from_challenge(request.challenge.as_deref()) + .await; + let metadata = match resolution { + Ok(resolution) => resolution.metadata, + Err(e) => { + *self = OAuthState::Unauthorized(manager); + return Err(e); + } + }; + manager.metadata = Some(metadata); + debug!("start session"); + match AuthorizationSession::new(manager, request).await { + Ok(session) => { + *self = OAuthState::Session(session); + Ok(()) + } + Err((manager, e)) => { + *self = OAuthState::Unauthorized(manager); + Err(e) + } + } + } + + /// complete authorization + pub async fn complete_authorization(&mut self) -> Result<(), AuthError> { + let placeholder = self.placeholder_state().await?; + if let OAuthState::Session(session) = std::mem::replace(self, placeholder) { + *self = OAuthState::Authorized(session.auth_manager); + Ok(()) + } else { + Err(AuthError::InternalError("Not in session state".to_string())) + } + } + /// covert to authorized http client + pub async fn to_authorized_http_client(&mut self) -> Result<(), AuthError> { + let placeholder = self.placeholder_state().await?; + if let OAuthState::Authorized(manager) = std::mem::replace(self, placeholder) { + *self = OAuthState::AuthorizedHttpClient(AuthorizedHttpClient::new( + Arc::new(manager), + None, + )); + Ok(()) + } else { + Err(AuthError::InternalError( + "Not in authorized state".to_string(), + )) + } + } + + /// request scope upgrade (Authorized -> Session); returns auth URL to open + pub async fn request_scope_upgrade( + &mut self, + required_scope: &str, + redirect_uri: &str, + ) -> Result { + let placeholder = self.placeholder_state().await?; + let old = std::mem::replace(self, placeholder); + let OAuthState::Authorized(manager) = old else { + *self = old; + return Err(AuthError::InternalError( + "Not in authorized state".to_string(), + )); + }; + let auth_url = match manager.request_scope_upgrade(required_scope).await { + Ok(url) => url, + Err(e) => { + *self = OAuthState::Authorized(manager); + return Err(e); + } + }; + let session = + AuthorizationSession::for_scope_upgrade(manager, auth_url.clone(), redirect_uri); + *self = OAuthState::Session(session); + Ok(auth_url) + } + + /// get current authorization url + pub async fn get_authorization_url(&self) -> Result { + match self { + OAuthState::Session(session) => Ok(session.get_authorization_url().to_string()), + OAuthState::Unauthorized(_) => { + Err(AuthError::InternalError("Not in session state".to_string())) + } + OAuthState::Authorized(_) => { + Err(AuthError::InternalError("Already authorized".to_string())) + } + OAuthState::AuthorizedHttpClient(_) => { + Err(AuthError::InternalError("Already authorized".to_string())) + } + } + } + + /// handle authorization callback + pub async fn handle_callback(&mut self, code: &str, csrf_token: &str) -> Result<(), AuthError> { + self.handle_callback_with_issuer(code, csrf_token, None) + .await + } + + /// handle authorization callback, validating the optional RFC 9207 + /// authorization response issuer (`iss`) when present. + pub async fn handle_callback_with_issuer( + &mut self, + code: &str, + csrf_token: &str, + issuer: Option<&str>, + ) -> Result<(), AuthError> { + match self { + OAuthState::Session(session) => { + session + .handle_callback_with_issuer(code, csrf_token, issuer) + .await?; + self.complete_authorization().await + } + OAuthState::Unauthorized(_) => { + Err(AuthError::InternalError("Not in session state".to_string())) + } + OAuthState::Authorized(_) => { + Err(AuthError::InternalError("Already authorized".to_string())) + } + OAuthState::AuthorizedHttpClient(_) => { + Err(AuthError::InternalError("Already authorized".to_string())) + } + } + } + + /// handle an OAuth redirect URL, including optional RFC 9207 `iss` validation. + pub async fn handle_callback_url(&mut self, callback_url: &str) -> Result<(), AuthError> { + let callback = AuthorizationCallback::from_redirect_url(callback_url)?; + self.handle_callback_with_issuer( + &callback.code, + &callback.csrf_token, + callback.issuer.as_deref(), + ) + .await + } + + /// get access token + pub async fn get_access_token(&self) -> Result { + match self { + OAuthState::Unauthorized(manager) => manager.get_access_token().await, + OAuthState::Session(_) => { + Err(AuthError::InternalError("Not in manager state".to_string())) + } + OAuthState::Authorized(_) => { + Err(AuthError::InternalError("Already authorized".to_string())) + } + OAuthState::AuthorizedHttpClient(_) => { + Err(AuthError::InternalError("Already authorized".to_string())) + } + } + } + + /// refresh access token + pub async fn refresh_token(&self) -> Result<(), AuthError> { + match self { + OAuthState::Unauthorized(_) => { + Err(AuthError::InternalError("Not in manager state".to_string())) + } + OAuthState::Session(_) => { + Err(AuthError::InternalError("Not in manager state".to_string())) + } + OAuthState::Authorized(manager) => { + manager.refresh_token().await?; + Ok(()) + } + OAuthState::AuthorizedHttpClient(_) => { + Err(AuthError::InternalError("Already authorized".to_string())) + } + } + } + + pub fn into_authorization_manager(self) -> Option { + match self { + OAuthState::Authorized(manager) => Some(manager), + _ => None, + } + } + + /// Authenticate using OAuth 2.0 Client Credentials flow (SEP-1046). + /// + /// Transitions directly from `Unauthorized` to `Authorized`, skipping the + /// interactive `Session` state entirely. Discovers metadata, configures the + /// client, and exchanges credentials for an access token. + pub async fn authenticate_client_credentials( + &mut self, + config: ClientCredentialsConfig, + ) -> Result<(), AuthError> { + let placeholder = self.placeholder_state().await?; + let OAuthState::Unauthorized(mut manager) = std::mem::replace(self, placeholder) else { + return Err(AuthError::InternalError( + "Client credentials flow requires Unauthorized state".to_string(), + )); + }; + + // Discover metadata + let resolution = manager.resolve_metadata().await?; + manager.metadata = Some(resolution.metadata); + + // Validate server supports the requested auth method + manager.validate_client_credentials_metadata(&config)?; + + // Configure OAuth client + manager.configure_client_credentials(&config)?; + + // Exchange credentials for token + manager.exchange_client_credentials(&config).await?; + + *self = OAuthState::Authorized(manager); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::{ + collections::{HashMap, VecDeque}, + sync::{Arc, Mutex as StdMutex}, + }; + + use oauth2::{AuthType, CsrfToken, HttpResponse, PkceCodeVerifier}; + use reqwest::StatusCode; + use rstest::rstest; + use url::Url; + + use super::{ + AuthError, AuthorizationCallback, AuthorizationManager, AuthorizationMetadata, + AuthorizationMetadataSource, AuthorizationRequest, AuthorizationSession, CredentialStore, + InMemoryCredentialStore, InMemoryStateStore, OAuthClientConfig, OAuthHttpClient, + OAuthHttpClientError, OAuthHttpClientFuture, OAuthHttpRedirectPolicy, OAuthHttpRequest, + ScopeUpgradeConfig, StateStore, StoredAuthorizationState, is_https_url, + }; + use crate::transport::auth::VendorExtraTokenFields; + + #[derive(Clone, Debug, PartialEq, Eq)] + struct RecordedOAuthRequest { + method: String, + uri: String, + redirect_policy: OAuthHttpRedirectPolicy, + body: Vec, + } + + #[derive(Clone, Default)] + struct RecordingOAuthHttpClient { + requests: Arc>>, + responses: Arc>>, + } + + impl RecordingOAuthHttpClient { + fn with_responses(responses: Vec) -> Self { + Self { + responses: Arc::new(StdMutex::new(responses.into())), + ..Default::default() + } + } + + fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } + } + + impl OAuthHttpClient for RecordingOAuthHttpClient { + fn execute(&self, request: OAuthHttpRequest) -> OAuthHttpClientFuture<'_> { + self.requests.lock().unwrap().push(RecordedOAuthRequest { + method: request.request.method().to_string(), + uri: request.request.uri().to_string(), + redirect_policy: request.redirect_policy, + body: request.request.body().clone(), + }); + let response = self.responses.lock().unwrap().pop_front(); + Box::pin(async move { response.ok_or_else(|| "missing fake response".into()) }) + } + } + + fn http_response(status: u16, body: serde_json::Value) -> HttpResponse { + oauth2::http::Response::builder() + .status(status) + .body(serde_json::to_vec(&body).unwrap()) + .unwrap() + } + + fn redirect_response(location: &str) -> HttpResponse { + oauth2::http::Response::builder() + .status(302) + .header("location", location) + .body(Vec::new()) + .unwrap() + } + + fn empty_response(status: u16) -> HttpResponse { + oauth2::http::Response::builder() + .status(status) + .body(Vec::new()) + .unwrap() + } + + #[test] + fn oauth_http_client_error_preserves_source_chain() { + #[derive(Debug, thiserror::Error)] + #[error("request failed")] + struct RequestError(#[source] std::io::Error); + + let error: OAuthHttpClientError = RequestError(std::io::Error::other( + "certificate signed by unknown authority", + )) + .into(); + assert!(error.downcast_ref::().is_some()); + + let url = Url::parse("https://mcp.example.com/mcp").unwrap(); + let error = AuthorizationManager::discovery_failed(&url, error); + assert_eq!( + error.to_string(), + "Metadata error: OAuth metadata discovery failed for https://mcp.example.com/mcp\n Caused by: request failed\n Caused by: certificate signed by unknown authority" + ); + } + + #[tokio::test] + async fn default_http_client_preserves_connection_failure_cause() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/mcp", listener.local_addr().unwrap()); + drop(listener); + + let manager = AuthorizationManager::new(&url).await.unwrap(); + let error = manager.resolve_metadata().await.unwrap_err(); + + assert!( + matches!( + error, + AuthError::MetadataError(ref reason) + if reason.contains(&url) + && reason.contains("\n Caused by: error sending request for url") + && reason.matches("error sending request for url").count() == 1 + && reason.to_ascii_lowercase().contains("connection refused") + ), + "unexpected discovery error: {error}" + ); + } + + #[tokio::test] + async fn authorization_metadata_propagates_transport_failure() { + let responses = preregistered_discovery_responses() + .into_iter() + .take(2) + .collect(); + let client = RecordingOAuthHttpClient::with_responses(responses); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let error = manager.resolve_metadata().await.unwrap_err(); + + assert!( + matches!( + error, + AuthError::MetadataError(ref reason) + if reason.contains("https://auth.example.com/.well-known/oauth-authorization-server") + && reason.contains("missing fake response") + ), + "unexpected discovery error: {error}" + ); + assert_eq!(client.requests().len(), 3); + } + + #[tokio::test] + async fn discovery_propagates_server_errors() { + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(RecordingOAuthHttpClient::with_responses(vec![ + empty_response(503), + ])), + ) + .await + .unwrap(); + + let error = manager.resolve_metadata().await.unwrap_err(); + + assert!( + matches!( + error, + AuthError::MetadataError(ref reason) + if reason.contains("https://mcp.example.com/mcp") && reason.contains("503") + ), + "unexpected discovery error: {error}" + ); + } + + #[rstest] + #[case::resource_request_timeout(StatusCode::REQUEST_TIMEOUT, 0, "https://mcp.example.com/mcp")] + #[case::resource_too_early(StatusCode::TOO_EARLY, 0, "https://mcp.example.com/mcp")] + #[case::resource_too_many_requests( + StatusCode::TOO_MANY_REQUESTS, + 0, + "https://mcp.example.com/mcp" + )] + #[case::protected_metadata_request_timeout( + StatusCode::REQUEST_TIMEOUT, + 1, + "https://mcp.example.com/.well-known/oauth-protected-resource" + )] + #[case::protected_metadata_too_early( + StatusCode::TOO_EARLY, + 1, + "https://mcp.example.com/.well-known/oauth-protected-resource" + )] + #[case::protected_metadata_too_many_requests( + StatusCode::TOO_MANY_REQUESTS, + 1, + "https://mcp.example.com/.well-known/oauth-protected-resource" + )] + #[case::authorization_request_timeout( + StatusCode::REQUEST_TIMEOUT, + 2, + "https://auth.example.com/.well-known/oauth-authorization-server" + )] + #[case::authorization_too_early( + StatusCode::TOO_EARLY, + 2, + "https://auth.example.com/.well-known/oauth-authorization-server" + )] + #[case::authorization_too_many_requests( + StatusCode::TOO_MANY_REQUESTS, + 2, + "https://auth.example.com/.well-known/oauth-authorization-server" + )] + #[tokio::test] + async fn discovery_propagates_transient_client_errors( + #[case] status: StatusCode, + #[case] successful_response_count: usize, + #[case] expected_url: &str, + ) { + let mut responses = preregistered_discovery_responses(); + responses.insert(successful_response_count, empty_response(status.as_u16())); + + let client = RecordingOAuthHttpClient::with_responses(responses); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let error = manager.resolve_metadata().await.unwrap_err(); + + assert!( + matches!( + error, + AuthError::MetadataError(ref reason) + if reason.contains(expected_url) && reason.contains(status.as_str()) + ), + "unexpected discovery error for {status}: {error}" + ); + assert_eq!(client.requests().len(), successful_response_count + 1); + } + + #[tokio::test] + async fn custom_http_client_handles_protected_resource_discovery() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com", + "authorization_servers": ["https://auth.example.com"] + }), + ), + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let metadata = manager.resolve_metadata().await.unwrap().metadata; + + assert_eq!(metadata.token_endpoint, "https://auth.example.com/token"); + assert_eq!( + manager.discovered_resource.read().await.as_deref(), + Some("https://mcp.example.com") + ); + assert_eq!( + client.requests(), + vec![ + RecordedOAuthRequest { + method: "GET".to_string(), + uri: "https://mcp.example.com/mcp".to_string(), + redirect_policy: OAuthHttpRedirectPolicy::Stop, + body: Vec::new(), + }, + RecordedOAuthRequest { + method: "GET".to_string(), + uri: "https://mcp.example.com/.well-known/oauth-protected-resource".to_string(), + redirect_policy: OAuthHttpRedirectPolicy::Stop, + body: Vec::new(), + }, + RecordedOAuthRequest { + method: "GET".to_string(), + uri: "https://auth.example.com/.well-known/oauth-authorization-server" + .to_string(), + redirect_policy: OAuthHttpRedirectPolicy::Stop, + body: Vec::new(), + }, + ] + ); + } + + #[tokio::test] + async fn refresh_token_uses_discovered_protected_resource() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com", + "authorization_servers": ["https://auth.example.com"] + }), + ), + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token", + "response_types_supported": ["code"], + "code_challenge_methods_supported": ["S256"] + }), + ), + http_response( + 200, + serde_json::json!({ + "access_token": "initial-access-token", + "token_type": "bearer", + "refresh_token": "initial-refresh-token", + "expires_in": 3600 + }), + ), + http_response( + 200, + serde_json::json!({ + "access_token": "refreshed-access-token", + "token_type": "bearer", + "refresh_token": "refreshed-refresh-token", + "expires_in": 3600 + }), + ), + ]); + let mut manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + manager.set_metadata(resolution.metadata); + manager.configure_client_id("test-client-id").unwrap(); + + let authorization_url = manager.get_authorization_url(&[]).await.unwrap(); + let authorization_params: HashMap = Url::parse(&authorization_url) + .unwrap() + .query_pairs() + .into_owned() + .collect(); + let state = authorization_params.get("state").unwrap(); + + manager + .exchange_code_for_token("authorization-code", state) + .await + .unwrap(); + manager.refresh_token().await.unwrap(); + + let token_requests: Vec> = client + .requests() + .into_iter() + .filter(|request| request.uri == "https://auth.example.com/token") + .map(|request| { + url::form_urlencoded::parse(&request.body) + .into_owned() + .collect() + }) + .collect(); + + assert_eq!(token_requests.len(), 2); + assert_eq!( + token_requests[0].get("grant_type").map(String::as_str), + Some("authorization_code") + ); + assert_eq!( + token_requests[1].get("grant_type").map(String::as_str), + Some("refresh_token") + ); + assert_eq!( + [ + authorization_params.get("resource").map(String::as_str), + token_requests[0].get("resource").map(String::as_str), + token_requests[1].get("resource").map(String::as_str), + ], + [Some("https://mcp.example.com"); 3], + "authorization, code exchange, and refresh must use the discovered resource audience" + ); + } + + #[tokio::test] + async fn protected_resource_metadata_supports_authorization_server_path_insertion() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + empty_response(401), + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/", + "authorization_servers": ["https://auth.example.com/tenant1"] + }), + ), + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/", + "authorization_servers": ["https://auth.example.com/tenant1"] + }), + ), + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com/tenant1", + "authorization_endpoint": "https://auth.example.com/tenant1/authorize", + "token_endpoint": "https://auth.example.com/tenant1/token" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let metadata = manager.resolve_metadata().await.unwrap().metadata; + + assert_eq!( + ( + metadata.issuer.as_deref(), + metadata.authorization_endpoint.as_str(), + client + .requests() + .iter() + .map(|request| request.uri.as_str()) + .collect::>(), + ), + ( + Some("https://auth.example.com/tenant1"), + "https://auth.example.com/tenant1/authorize", + vec![ + "https://mcp.example.com/", + "https://mcp.example.com/.well-known/oauth-protected-resource", + "https://mcp.example.com/.well-known/oauth-protected-resource", + "https://auth.example.com/.well-known/oauth-authorization-server/tenant1", + ], + ) + ); + } + + #[tokio::test] + async fn authorization_metadata_rejects_mismatched_issuer() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + empty_response(401), + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/", + "authorization_servers": ["https://auth.example.com/tenant1"] + }), + ), + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/", + "authorization_servers": ["https://auth.example.com/tenant1"] + }), + ), + http_response( + 200, + serde_json::json!({ + "issuer": "https://evil.example.com/tenant1", + "authorization_endpoint": "https://evil.example.com/tenant1/authorize", + "token_endpoint": "https://evil.example.com/tenant1/token" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/", + Arc::new(client), + ) + .await + .unwrap(); + + let error = manager.resolve_metadata().await.unwrap_err(); + + assert!( + matches!( + error, + AuthError::AuthorizationServerMismatch { + ref expected_issuer, + ref received_issuer + } if expected_issuer == "https://auth.example.com/tenant1" + && received_issuer == "https://evil.example.com/tenant1" + ), + "expected authorization server issuer mismatch, got: {error:?}" + ); + } + + #[tokio::test] + async fn authorization_metadata_accepts_oidc_path_appended_issuer() { + let discovery_url = + Url::parse("https://auth.example.com/tenant1/.well-known/openid-configuration") + .unwrap(); + let metadata = AuthorizationMetadata { + issuer: Some("https://auth.example.com/tenant1".to_string()), + authorization_endpoint: "https://auth.example.com/tenant1/authorize".to_string(), + token_endpoint: "https://auth.example.com/tenant1/token".to_string(), + ..Default::default() + }; + let manager = AuthorizationManager::new("https://mcp.example.com/") + .await + .unwrap(); + + manager + .validate_authorization_metadata_issuer(&discovery_url, &metadata) + .unwrap(); + } + + #[test] + fn authorization_metadata_allows_only_root_trailing_slash_equivalence() { + assert!(AuthorizationManager::issuer_identifiers_match( + "https://auth.example.com/", + "https://auth.example.com" + )); + assert!(!AuthorizationManager::issuer_identifiers_match( + "https://auth.example.com/tenant1/", + "https://auth.example.com/tenant1" + )); + } + + #[tokio::test] + async fn authorization_metadata_accepts_oidc_path_inserted_issuer() { + let discovery_url = + Url::parse("https://auth.example.com/.well-known/openid-configuration/tenant1") + .unwrap(); + let metadata = AuthorizationMetadata { + issuer: Some("https://auth.example.com/tenant1".to_string()), + authorization_endpoint: "https://auth.example.com/tenant1/authorize".to_string(), + token_endpoint: "https://auth.example.com/tenant1/token".to_string(), + ..Default::default() + }; + let manager = AuthorizationManager::new("https://mcp.example.com/") + .await + .unwrap(); + + manager + .validate_authorization_metadata_issuer(&discovery_url, &metadata) + .unwrap(); + } + + #[tokio::test] + async fn authorization_metadata_allows_missing_issuer_when_configured() { + let discovery_url = + Url::parse("https://auth.example.com/.well-known/openid-configuration/tenant1") + .unwrap(); + let metadata = AuthorizationMetadata { + issuer: None, + authorization_endpoint: "https://auth.example.com/tenant1/authorize".to_string(), + token_endpoint: "https://auth.example.com/tenant1/token".to_string(), + ..Default::default() + }; + + let mut manager = AuthorizationManager::new("https://mcp.example.com/") + .await + .unwrap(); + manager.set_allow_missing_issuer(true); + + manager + .validate_authorization_metadata_issuer(&discovery_url, &metadata) + .unwrap(); + } + + #[tokio::test] + async fn authorization_metadata_rejects_missing_issuer_by_default() { + let discovery_url = + Url::parse("https://auth.example.com/.well-known/openid-configuration/tenant1") + .unwrap(); + let metadata = AuthorizationMetadata { + issuer: None, + authorization_endpoint: "https://auth.example.com/tenant1/authorize".to_string(), + token_endpoint: "https://auth.example.com/tenant1/token".to_string(), + ..Default::default() + }; + let manager = AuthorizationManager::new("https://mcp.example.com/") + .await + .unwrap(); + + let error = manager + .validate_authorization_metadata_issuer(&discovery_url, &metadata) + .unwrap_err(); + + assert!( + matches!( + error, + AuthError::AuthorizationServerMissingIssuer { ref expected_issuer } + if expected_issuer == "https://auth.example.com/tenant1" + ), + "expected missing issuer error, got: {error:?}" + ); + } + + #[tokio::test] + async fn protected_resource_metadata_supports_custom_location_and_oidc_path_append() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="/custom/metadata/location.json""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com/tenant1"] + }), + ), + empty_response(404), + empty_response(404), + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com/tenant1", + "authorization_endpoint": "https://auth.example.com/tenant1/authorize", + "token_endpoint": "https://auth.example.com/tenant1/token" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let metadata = manager.resolve_metadata().await.unwrap().metadata; + + assert_eq!( + ( + metadata.token_endpoint.as_str(), + client + .requests() + .iter() + .map(|request| request.uri.as_str()) + .collect::>(), + ), + ( + "https://auth.example.com/tenant1/token", + vec![ + "https://mcp.example.com/mcp", + "https://mcp.example.com/custom/metadata/location.json", + "https://auth.example.com/.well-known/oauth-authorization-server/tenant1", + "https://auth.example.com/.well-known/openid-configuration/tenant1", + "https://auth.example.com/tenant1/.well-known/openid-configuration", + ], + ) + ); + assert!( + client + .requests() + .iter() + .all(|request| request.method == "GET"), + "discovery must not send non-GET requests" + ); + } + + #[rstest] + #[case::not_found(StatusCode::NOT_FOUND)] + #[case::method_not_allowed(StatusCode::METHOD_NOT_ALLOWED)] + #[tokio::test] + async fn resolve_metadata_reports_legacy_fallback_when_nothing_is_discovered( + #[case] status: StatusCode, + ) { + let client = RecordingOAuthHttpClient::with_responses(vec![ + empty_response(status.as_u16()), + empty_response(status.as_u16()), + empty_response(status.as_u16()), + empty_response(status.as_u16()), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://legacy.example.com/", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + + assert_eq!( + ( + resolution.source, + resolution.metadata.authorization_endpoint.as_str(), + resolution.metadata.token_endpoint.as_str(), + resolution.metadata.registration_endpoint.as_deref(), + client + .requests() + .iter() + .map(|request| request.uri.as_str()) + .collect::>(), + ), + ( + AuthorizationMetadataSource::LegacyEndpointFallback, + "https://legacy.example.com/authorize", + "https://legacy.example.com/token", + Some("https://legacy.example.com/register"), + vec![ + "https://legacy.example.com/", + "https://legacy.example.com/.well-known/oauth-protected-resource", + "https://legacy.example.com/.well-known/oauth-authorization-server", + "https://legacy.example.com/.well-known/openid-configuration", + ], + ) + ); + } + + #[tokio::test] + async fn resolve_metadata_reports_protected_resource_metadata() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }), + ), + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + + assert_eq!( + ( + resolution.source, + resolution.metadata.token_endpoint.as_str(), + ), + ( + AuthorizationMetadataSource::ProtectedResourceMetadata, + "https://auth.example.com/token", + ) + ); + } + + #[tokio::test] + async fn resolve_metadata_from_challenge_uses_challenge_pointer_and_scope() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }), + ), + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let resolution = manager + .resolve_metadata_from_challenge(Some( + r#"Bearer resource_metadata="https://mcp.example.com/custom/prm.json", scope="mcp:read mcp:write""#, + )) + .await + .unwrap(); + + assert_eq!( + ( + resolution.source, + resolution.metadata.token_endpoint.as_str(), + manager.select_scopes(None, &[]), + client.requests().first().map(|request| request.uri.clone()), + ), + ( + AuthorizationMetadataSource::ProtectedResourceMetadata, + "https://auth.example.com/token", + vec!["mcp:read".to_string(), "mcp:write".to_string()], + // discovery starts at the challenge's pointer: no probing + Some("https://mcp.example.com/custom/prm.json".to_string()), + ) + ); + } + + #[tokio::test] + async fn resolve_metadata_from_challenge_falls_back_without_metadata_pointer() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + empty_response(404), + empty_response(404), + empty_response(404), + empty_response(404), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://legacy.example.com/", + Arc::new(client), + ) + .await + .unwrap(); + + let resolution = manager + .resolve_metadata_from_challenge(Some(r#"Bearer scope="mcp:basic""#)) + .await + .unwrap(); + + assert_eq!( + (resolution.source, manager.select_scopes(None, &[]),), + ( + AuthorizationMetadataSource::LegacyEndpointFallback, + vec!["mcp:basic".to_string()], + ) + ); + } + + #[tokio::test] + async fn resolve_metadata_reports_authorization_server_metadata() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + empty_response(404), + empty_response(404), + empty_response(404), + http_response( + 200, + serde_json::json!({ + "issuer": "https://mcp.example.com", + "authorization_endpoint": "https://mcp.example.com/oauth/authorize", + "token_endpoint": "https://mcp.example.com/oauth/token" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/", + Arc::new(client), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + + assert_eq!( + ( + resolution.source, + resolution.metadata.token_endpoint.as_str(), + ), + ( + AuthorizationMetadataSource::AuthorizationServerMetadata, + "https://mcp.example.com/oauth/token", + ) + ); + } + + #[rstest] + #[case::protected_resource_metadata( + AuthorizationMetadataSource::ProtectedResourceMetadata, + true + )] + #[case::authorization_server_metadata( + AuthorizationMetadataSource::AuthorizationServerMetadata, + true + )] + #[case::legacy_endpoint_fallback(AuthorizationMetadataSource::LegacyEndpointFallback, false)] + fn is_discovered_is_false_only_for_the_legacy_fallback( + #[case] source: AuthorizationMetadataSource, + #[case] expected: bool, + ) { + assert_eq!(source.is_discovered(), expected); + } + + fn preregistered_as_metadata_response() -> HttpResponse { + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token", + "registration_endpoint": "https://auth.example.com/register", + "scopes_supported": ["read", "write", "offline_access"] + }), + ) + } + + /// discovery responses for the preregistered-client tests: a 401 challenge + /// pointing at protected resource metadata, the PRM document, then the + /// authorization server metadata. + fn preregistered_discovery_responses() -> Vec { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + ) + .body(Vec::new()) + .unwrap(); + vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }), + ), + preregistered_as_metadata_response(), + ] + } + + fn auth_url_query(auth_url: &str) -> HashMap { + Url::parse(auth_url) + .unwrap() + .query_pairs() + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect() + } + + #[tokio::test] + async fn preregistered_client_skips_registration_endpoint() { + let client = RecordingOAuthHttpClient::with_responses(preregistered_discovery_responses()); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_preregistered_client("preregistered-client") + .with_client_secret("secret") + .with_scopes(["read"]); + state.start_authorization(request).await.unwrap(); + + // the registration endpoint was advertised but must not be called + let requests = client.requests(); + assert!( + requests + .iter() + .all(|request| !request.uri.contains("/register")), + "registration endpoint should not be called: {requests:?}" + ); + + let auth_url = state.get_authorization_url().await.unwrap(); + let query = auth_url_query(&auth_url); + assert_eq!(query.get("client_id").unwrap(), "preregistered-client"); + assert!(matches!(state, super::OAuthState::Session(_))); + } + + #[tokio::test] + async fn authorization_session_selects_default_scopes_when_none_provided() { + let client = RecordingOAuthHttpClient::with_responses(preregistered_discovery_responses()); + let mut manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client), + ) + .await + .unwrap(); + manager.metadata = Some(manager.resolve_metadata().await.unwrap().metadata); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_preregistered_client("preregistered-client"); + let session = match AuthorizationSession::new(manager, request).await { + Ok(session) => session, + Err((_, error)) => panic!("authorization session creation failed: {error}"), + }; + + // Empty request scopes fall back to the discovered scopes_supported. + let query = auth_url_query(&session.auth_url); + assert_eq!(query.get("scope").unwrap(), "read write offline_access"); + } + + #[tokio::test] + async fn preregistered_client_uses_explicit_scopes_and_adds_offline_access() { + let client = RecordingOAuthHttpClient::with_responses(preregistered_discovery_responses()); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_preregistered_client("preregistered-client") + .with_scopes(["read"]); + state.start_authorization(request).await.unwrap(); + + // explicit scopes are preserved; offline_access is appended per SEP-2207 + let auth_url = state.get_authorization_url().await.unwrap(); + let query = auth_url_query(&auth_url); + assert_eq!(query.get("scope").unwrap(), "read offline_access"); + } + + #[tokio::test] + async fn preregistered_client_recovers_unauthorized_state_after_discovery_failure() { + // first discovery attempt fails: protected resource metadata reports a + // mismatched resource identifier, which discover_metadata rejects + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "resource": "https://other.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }), + ), + ]); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_preregistered_client("preregistered-client") + .with_scopes(["read"]); + let err = state + .start_authorization(request.clone()) + .await + .unwrap_err(); + assert!(!matches!(err, AuthError::InternalError(_)), "{err:?}"); + assert!( + matches!(state, super::OAuthState::Unauthorized(_)), + "state should return to Unauthorized after a transient failure" + ); + + // retrying with the same state succeeds once the server responds + client + .responses + .lock() + .unwrap() + .extend(preregistered_discovery_responses()); + state.start_authorization(request).await.unwrap(); + assert!(matches!(state, super::OAuthState::Session(_))); + } + + fn cimd_as_metadata_response() -> HttpResponse { + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token", + "registration_endpoint": "https://auth.example.com/register", + "scopes_supported": ["read", "write", "offline_access"], + "client_id_metadata_document_supported": true + }), + ) + } + + /// discovery responses like [`preregistered_discovery_responses`] but the + /// authorization server advertises CIMD support. + fn cimd_discovery_responses() -> Vec { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + ) + .body(Vec::new()) + .unwrap(); + vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }), + ), + cimd_as_metadata_response(), + ] + } + + #[tokio::test] + async fn preregistered_client_takes_priority_over_cimd() { + // server supports CIMD and the request carries both pre-registered + // credentials and a client metadata URL: pre-registration wins + let client = RecordingOAuthHttpClient::with_responses(cimd_discovery_responses()); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_preregistered_client("preregistered-client") + .with_client_metadata_url("https://client.example.com/client-metadata.json") + .with_scopes(["read"]); + state.start_authorization(request).await.unwrap(); + + let requests = client.requests(); + assert!( + requests + .iter() + .all(|request| !request.uri.contains("/register")), + "registration endpoint should not be called: {requests:?}" + ); + let auth_url = state.get_authorization_url().await.unwrap(); + let query = auth_url_query(&auth_url); + assert_eq!(query.get("client_id").unwrap(), "preregistered-client"); + } + + #[tokio::test] + async fn cimd_used_when_server_advertises_support() { + let client = RecordingOAuthHttpClient::with_responses(cimd_discovery_responses()); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_client_metadata_url("https://client.example.com/client-metadata.json") + .with_scopes(["read"]); + state.start_authorization(request).await.unwrap(); + + // CIMD takes priority over the advertised registration endpoint + let requests = client.requests(); + assert!( + requests + .iter() + .all(|request| !request.uri.contains("/register")), + "registration endpoint should not be called: {requests:?}" + ); + let auth_url = state.get_authorization_url().await.unwrap(); + let query = auth_url_query(&auth_url); + assert_eq!( + query.get("client_id").unwrap(), + "https://client.example.com/client-metadata.json" + ); + } + + #[tokio::test] + async fn cimd_falls_back_to_dcr_when_server_lacks_support() { + // server does not advertise client_id_metadata_document_supported, so + // the client metadata URL is ignored and DCR is used instead + let mut responses = preregistered_discovery_responses(); + responses.push(http_response( + 201, + serde_json::json!({ + "client_id": "dcr-client", + "redirect_uris": ["http://localhost:8080/callback"] + }), + )); + let client = RecordingOAuthHttpClient::with_responses(responses); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_client_metadata_url("https://client.example.com/client-metadata.json") + .with_scopes(["read"]); + state.start_authorization(request).await.unwrap(); + + let requests = client.requests(); + assert!( + requests + .iter() + .any(|request| request.uri.contains("/register")), + "registration endpoint should be called: {requests:?}" + ); + let auth_url = state.get_authorization_url().await.unwrap(); + let query = auth_url_query(&auth_url); + assert_eq!(query.get("client_id").unwrap(), "dcr-client"); + } + + #[tokio::test] + async fn dcr_used_when_no_identity_material_is_provided() { + let mut responses = preregistered_discovery_responses(); + responses.push(http_response( + 201, + serde_json::json!({ + "client_id": "dcr-client", + "redirect_uris": ["http://localhost:8080/callback"] + }), + )); + let client = RecordingOAuthHttpClient::with_responses(responses); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_client_name("test-client") + .with_scopes(["read"]); + state.start_authorization(request).await.unwrap(); + + let auth_url = state.get_authorization_url().await.unwrap(); + let query = auth_url_query(&auth_url); + assert_eq!(query.get("client_id").unwrap(), "dcr-client"); + assert!(matches!(state, super::OAuthState::Session(_))); + } + + #[tokio::test] + async fn dcr_registration_uses_requested_application_type() { + let mut responses = preregistered_discovery_responses(); + responses.push(http_response( + 201, + serde_json::json!({ + "client_id": "dcr-client", + "redirect_uris": ["http://localhost:8080/callback"] + }), + )); + let client = RecordingOAuthHttpClient::with_responses(responses); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_client_name("test-client") + .with_application_type("web") + .with_scopes(["read"]); + state.start_authorization(request).await.unwrap(); + + // SEP-837: the requested application_type must be sent in the DCR request + let requests = client.requests(); + let registration = requests + .iter() + .find(|request| request.uri.contains("/register")) + .expect("registration endpoint should be called"); + let body: serde_json::Value = serde_json::from_slice(®istration.body).unwrap(); + assert_eq!(body.get("application_type").unwrap(), "web"); + } + + #[tokio::test] + async fn cimd_rejects_non_https_client_metadata_url_and_recovers_state() { + let client = RecordingOAuthHttpClient::with_responses(cimd_discovery_responses()); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_client_metadata_url("http://client.example.com/client-metadata.json") + .with_scopes(["read"]); + let err = state.start_authorization(request).await.unwrap_err(); + assert!(matches!(err, AuthError::RegistrationFailed(_)), "{err:?}"); + assert!( + matches!(state, super::OAuthState::Unauthorized(_)), + "state should return to Unauthorized after a registration failure" + ); + } + + #[tokio::test] + async fn dcr_recovers_unauthorized_state_after_registration_failure() { + // discovery succeeds, but the registration endpoint rejects the request + let mut responses = preregistered_discovery_responses(); + responses.push(http_response( + 400, + serde_json::json!({"error": "invalid_client_metadata"}), + )); + let client = RecordingOAuthHttpClient::with_responses(responses); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_client_name("test-client") + .with_scopes(["read"]); + let err = state + .start_authorization(request.clone()) + .await + .unwrap_err(); + assert!(matches!(err, AuthError::RegistrationFailed(_)), "{err:?}"); + assert!( + matches!(state, super::OAuthState::Unauthorized(_)), + "state should return to Unauthorized after a registration failure" + ); + + // retrying with the same state succeeds once the server accepts + // registration (discovery runs again on retry) + { + let mut responses = client.responses.lock().unwrap(); + responses.extend(preregistered_discovery_responses()); + responses.push_back(http_response( + 201, + serde_json::json!({ + "client_id": "dcr-client", + "redirect_uris": ["http://localhost:8080/callback"] + }), + )); + } + state.start_authorization(request).await.unwrap(); + assert!(matches!(state, super::OAuthState::Session(_))); + } + + #[tokio::test] + async fn discovery_get_follows_same_origin_redirects() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + redirect_response("/redirected"), + http_response(200, serde_json::json!({})), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let response = manager + .discovery_get(&Url::parse("https://mcp.example.com/start").unwrap()) + .await + .unwrap(); + let requests = client.requests(); + + assert_eq!( + ( + response.status(), + requests + .iter() + .map(|request| request.uri.as_str()) + .collect::>() + ), + ( + oauth2::http::StatusCode::OK, + vec![ + "https://mcp.example.com/start", + "https://mcp.example.com/redirected" + ] + ) + ); + } + + #[tokio::test] + async fn discovery_get_rejects_cross_origin_redirects() { + let client = RecordingOAuthHttpClient::with_responses(vec![redirect_response( + "http://169.254.169.254/", + )]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let err = manager + .discovery_get(&Url::parse("https://mcp.example.com/start").unwrap()) + .await + .unwrap_err(); + + assert_eq!( + ( + err.to_string().contains("non-same-origin"), + client.requests().len() + ), + (true, 1) + ); + } + + #[tokio::test] + async fn protected_resource_metadata_rejects_private_authorization_server_urls() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": [ + "http://169.254.169.254/latest/meta-data/", + "http://127.0.0.1:8080/tenant1", + "https://auth.example.com" + ] + }), + ), + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let metadata = manager.resolve_metadata().await.unwrap().metadata; + let requests = client.requests(); + + assert_eq!( + ( + metadata.token_endpoint.as_str(), + requests + .iter() + .map(|request| request.uri.as_str()) + .collect::>() + ), + ( + "https://auth.example.com/token", + vec![ + "https://mcp.example.com/mcp", + "https://mcp.example.com/.well-known/oauth-protected-resource", + "https://auth.example.com/.well-known/oauth-authorization-server" + ] + ) + ); + } + + #[tokio::test] + async fn allows_loopback_authorization_server_when_resource_is_loopback() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="http://localhost/custom-metadata.json""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "resource": "http://localhost/mcp", + "authorization_servers": ["http://127.0.0.1:8080/tenant1"] + }), + ), + http_response( + 200, + serde_json::json!({ + "issuer": "http://127.0.0.1:8080/tenant1", + "authorization_endpoint": "http://127.0.0.1:8080/tenant1/authorize", + "token_endpoint": "http://127.0.0.1:8080/tenant1/token" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "http://localhost/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let metadata = manager.resolve_metadata().await.unwrap().metadata; + + assert_eq!( + ( + metadata.issuer.as_deref(), + client + .requests() + .iter() + .map(|request| request.uri.as_str()) + .collect::>(), + ), + ( + Some("http://127.0.0.1:8080/tenant1"), + vec![ + "http://localhost/mcp", + "http://localhost/custom-metadata.json", + "http://127.0.0.1:8080/.well-known/oauth-authorization-server/tenant1", + ], + ) + ); + } + + #[tokio::test] + async fn protected_resource_discovery_rejects_mismatched_resource() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "resource": "https://real.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let error = manager.resolve_metadata().await.unwrap_err(); + + assert!( + matches!(error, AuthError::MetadataError(ref message) if message.contains("resource mismatch")), + "expected resource mismatch metadata error, got: {error:?}" + ); + assert_eq!(client.requests().len(), 2); + } + + #[tokio::test] + async fn protected_resource_discovery_rejects_missing_resource() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "authorization_servers": ["https://auth.example.com"] + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let error = manager.resolve_metadata().await.unwrap_err(); + + assert!( + matches!(error, AuthError::MetadataError(ref message) if message.contains("missing required resource")), + "expected missing resource metadata error, got: {error:?}" + ); + assert_eq!(client.requests().len(), 2); + } + + #[test] + fn resource_identifier_matching_allows_matching_host_or_parent_path() { + assert!(AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com/").unwrap(), + &Url::parse("https://mcp.example.com").unwrap() + )); + assert!(AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com").unwrap(), + &Url::parse("https://mcp.example.com/").unwrap() + )); + assert!(AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com/mcp").unwrap(), + &Url::parse("https://mcp.example.com").unwrap() + )); + assert!(AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com/mcp/tools").unwrap(), + &Url::parse("https://mcp.example.com/mcp").unwrap() + )); + assert!(AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com/mcp?query=param").unwrap(), + &Url::parse("https://mcp.example.com/mcp?query=param").unwrap() + )); + + assert!(!AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com/mcp").unwrap(), + &Url::parse("https://mcp.example.com/mcp/").unwrap() + )); + assert!(!AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com/mcp-tools").unwrap(), + &Url::parse("https://mcp.example.com/mcp").unwrap() + )); + assert!(!AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com/mcp").unwrap(), + &Url::parse("https://mcp.example.com/mcp-tools").unwrap() + )); + assert!(!AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com/mcp").unwrap(), + &Url::parse("https://mcp.example.com/mcp/tools").unwrap() + )); + assert!(!AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com/mcp").unwrap(), + &Url::parse("https://real.example.com/mcp").unwrap() + )); + assert!(!AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com/mcp").unwrap(), + &Url::parse("https://mcp.example.com/mcp?query=value1").unwrap() + )); + assert!(!AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com/mcp?query=value1").unwrap(), + &Url::parse("https://mcp.example.com/mcp?query=value2").unwrap() + )); + } + + #[tokio::test] + async fn custom_http_client_handles_registration_exchange_and_refresh() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + http_response( + 201, + serde_json::json!({ + "client_id": "test-client", + "redirect_uris": ["http://localhost/callback"] + }), + ), + http_response( + 200, + serde_json::json!({ + "access_token": "access-1", + "token_type": "bearer", + "refresh_token": "refresh-1", + "expires_in": 3600 + }), + ), + http_response( + 200, + serde_json::json!({ + "access_token": "access-2", + "token_type": "bearer", + "refresh_token": "refresh-2", + "expires_in": 3600 + }), + ), + ]); + let mut manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + manager.set_metadata(AuthorizationMetadata { + authorization_endpoint: "https://auth.example.com/authorize".to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + registration_endpoint: Some("https://auth.example.com/register".to_string()), + response_types_supported: Some(vec!["code".to_string()]), + ..Default::default() + }); + manager + .register_client( + "Codex", + "http://localhost/callback", + &["profile", "offline_access"], + ) + .await + .unwrap(); + let authorization_url = manager + .get_authorization_url(&["profile", "offline_access"]) + .await + .unwrap(); + let state = Url::parse(&authorization_url) + .unwrap() + .query_pairs() + .find(|(name, _)| name == "state") + .unwrap() + .1 + .into_owned(); + + manager + .exchange_code_for_token("authorization-code", &state) + .await + .unwrap(); + manager.refresh_token().await.unwrap(); + + let requests = client.requests(); + let registration: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!(registration["scope"], "profile offline_access"); + assert_eq!( + requests + .iter() + .map(|request| request.uri.as_str()) + .collect::>(), + vec![ + "https://auth.example.com/register", + "https://auth.example.com/token", + "https://auth.example.com/token", + ] + ); + assert_eq!( + requests + .iter() + .map(|request| request.redirect_policy) + .collect::>(), + vec![ + OAuthHttpRedirectPolicy::Follow, + OAuthHttpRedirectPolicy::Stop, + OAuthHttpRedirectPolicy::Stop, + ] + ); + } + + // -- url helpers -- + + #[test] + fn test_is_https_url_scenarios() { + assert!(is_https_url("https://example.com/client-metadata.json")); + assert!(is_https_url("https://example.com/metadata?version=1")); + assert!(!is_https_url("https://example.com")); + assert!(!is_https_url("https://example.com/")); + assert!(!is_https_url("https://")); + assert!(!is_https_url("http://example.com/metadata")); + assert!(!is_https_url("not a url")); + assert!(!is_https_url("")); + assert!(!is_https_url("javascript:alert(1)")); + assert!(!is_https_url("data:text/html,")); + } + + // -- well-known path generation -- + + #[test] + fn well_known_paths_root() { + let paths = AuthorizationManager::well_known_paths("/", "oauth-authorization-server"); + assert_eq!( + paths, + vec!["/.well-known/oauth-authorization-server".to_string()] + ); + } + + #[test] + fn well_known_paths_with_suffix() { + let paths = AuthorizationManager::well_known_paths("/mcp", "oauth-authorization-server"); + assert_eq!( + paths, + vec![ + "/.well-known/oauth-authorization-server/mcp".to_string(), + "/mcp/.well-known/oauth-authorization-server".to_string(), + "/.well-known/oauth-authorization-server".to_string(), + ] + ); + } + + #[test] + fn well_known_paths_trailing_slash() { + let paths = + AuthorizationManager::well_known_paths("/v1/mcp/", "oauth-authorization-server"); + assert_eq!( + paths, + vec![ + "/.well-known/oauth-authorization-server/v1/mcp".to_string(), + "/v1/mcp/.well-known/oauth-authorization-server".to_string(), + "/.well-known/oauth-authorization-server".to_string(), + ] + ); + } + + #[test] + fn test_protected_resource_metadata_paths() { + let paths = + AuthorizationManager::well_known_paths("/mcp/example", "oauth-protected-resource"); + assert!(paths.contains(&"/.well-known/oauth-protected-resource/mcp/example".to_string())); + assert!(paths.contains(&"/.well-known/oauth-protected-resource".to_string())); + } + + // -- discovery url generation -- + + #[test] + fn generate_discovery_urls() { + let base_url = Url::parse("https://auth.example.com").unwrap(); + let urls = AuthorizationManager::generate_discovery_urls(&base_url); + assert_eq!(urls.len(), 2); + assert_eq!( + urls[0].as_str(), + "https://auth.example.com/.well-known/oauth-authorization-server" + ); + assert_eq!( + urls[1].as_str(), + "https://auth.example.com/.well-known/openid-configuration" + ); + + let base_url = Url::parse("https://auth.example.com/tenant1").unwrap(); + let urls = AuthorizationManager::generate_discovery_urls(&base_url); + assert_eq!(urls.len(), 4); + assert_eq!( + urls[0].as_str(), + "https://auth.example.com/.well-known/oauth-authorization-server/tenant1" + ); + assert_eq!( + urls[1].as_str(), + "https://auth.example.com/.well-known/openid-configuration/tenant1" + ); + assert_eq!( + urls[2].as_str(), + "https://auth.example.com/tenant1/.well-known/openid-configuration" + ); + assert_eq!( + urls[3].as_str(), + "https://auth.example.com/.well-known/oauth-authorization-server" + ); + + let base_url = Url::parse("https://auth.example.com/v1/mcp/").unwrap(); + let urls = AuthorizationManager::generate_discovery_urls(&base_url); + assert_eq!(urls.len(), 4); + assert_eq!( + urls[0].as_str(), + "https://auth.example.com/.well-known/oauth-authorization-server/v1/mcp" + ); + assert_eq!( + urls[1].as_str(), + "https://auth.example.com/.well-known/openid-configuration/v1/mcp" + ); + assert_eq!( + urls[2].as_str(), + "https://auth.example.com/v1/mcp/.well-known/openid-configuration" + ); + assert_eq!( + urls[3].as_str(), + "https://auth.example.com/.well-known/oauth-authorization-server" + ); + + let base_url = Url::parse("https://auth.example.com/tenant1/subtenant").unwrap(); + let urls = AuthorizationManager::generate_discovery_urls(&base_url); + assert_eq!(urls.len(), 4); + assert_eq!( + urls[0].as_str(), + "https://auth.example.com/.well-known/oauth-authorization-server/tenant1/subtenant" + ); + assert_eq!( + urls[1].as_str(), + "https://auth.example.com/.well-known/openid-configuration/tenant1/subtenant" + ); + assert_eq!( + urls[2].as_str(), + "https://auth.example.com/tenant1/subtenant/.well-known/openid-configuration" + ); + assert_eq!( + urls[3].as_str(), + "https://auth.example.com/.well-known/oauth-authorization-server" + ); + } + + #[test] + fn test_discovery_urls_with_path_suffix() { + let base_url = Url::parse("https://mcp.example.com/mcp").unwrap(); + let urls = AuthorizationManager::generate_discovery_urls(&base_url); + + let canonical_oauth_fallback = + "https://mcp.example.com/.well-known/oauth-authorization-server"; + + assert!( + urls.iter().any(|u| u.as_str() == canonical_oauth_fallback), + "Expected discovery URLs to include canonical OAuth fallback '{}', but got: {:?}", + canonical_oauth_fallback, + urls.iter().map(|u| u.as_str()).collect::>() + ); + } + + // -- header value parsing -- + + #[rstest] + #[case::quoted_string(r#""example", realm="foo""#, "example", r#""example""#)] + #[case::escaped_quotes_and_whitespace( + r#" "a\"b\\c" ,next=value"#, + r#"a"b\c"#, + r#" "a\"b\\c""# + )] + #[case::token_values(" token,next", "token", " token")] + #[case::semicolon_separated_tokens( + r#" https://example.com/meta; error="invalid_token""#, + "https://example.com/meta", + " https://example.com/meta" + )] + #[case::semicolon_after_quoted_value( + r#" "https://example.com/meta"; error="invalid_token""#, + "https://example.com/meta", + r#" "https://example.com/meta""# + )] + fn parse_auth_param_value_handles_supported_values( + #[case] fragment: &str, + #[case] expected_value: &str, + #[case] expected_consumed_prefix: &str, + ) { + let (value, consumed) = AuthorizationManager::parse_next_header_value(fragment).unwrap(); + + assert_eq!( + (value.as_str(), &fragment[..consumed]), + (expected_value, expected_consumed_prefix) + ); + } + + #[test] + fn parse_auth_param_value_returns_none_for_unterminated_quotes() { + let fragment = r#""unterminated,value"#; + assert!(AuthorizationManager::parse_next_header_value(fragment).is_none()); + } + + // -- www-authenticate param extraction -- + + #[test] + fn parses_resource_metadata_parameter() { + let header = r#"Bearer error="invalid_request", error_description="missing token", resource_metadata="https://example.com/.well-known/oauth-protected-resource/api""#; + let base = Url::parse("https://example.com/api").unwrap(); + let params = AuthorizationManager::extract_www_authenticate_params(header, &base); + assert_eq!( + params.resource_metadata_url.unwrap().as_str(), + "https://example.com/.well-known/oauth-protected-resource/api" + ); + } + + #[test] + fn parses_relative_resource_metadata_parameter() { + let header = r#"Bearer error="invalid_request", resource_metadata="/.well-known/oauth-protected-resource/api""#; + let base = Url::parse("https://example.com/api").unwrap(); + let params = AuthorizationManager::extract_www_authenticate_params(header, &base); + assert_eq!( + params.resource_metadata_url.unwrap().as_str(), + "https://example.com/.well-known/oauth-protected-resource/api" + ); + } + + #[test] + fn rejects_cross_origin_resource_metadata_parameter() { + let header = r#"Bearer error="invalid_request", resource_metadata="http://169.254.169.254/latest/meta-data/", scope="read""#; + let base = Url::parse("https://example.com/api").unwrap(); + let params = AuthorizationManager::extract_www_authenticate_params(header, &base); + + assert!(params.resource_metadata_url.is_none()); + assert_eq!(params.scope.unwrap(), "read"); + } + + #[test] + fn rejects_non_http_resource_metadata_parameter() { + let header = r#"Bearer resource_metadata="file:///etc/passwd""#; + let base = Url::parse("https://example.com/api").unwrap(); + let params = AuthorizationManager::extract_www_authenticate_params(header, &base); + + assert!(params.resource_metadata_url.is_none()); + } + + #[test] + fn extract_www_authenticate_params_with_all_fields() { + let header = r#"Bearer error="invalid_token", resource_metadata="https://example.com/.well-known/oauth-protected-resource", scope="read:data write:data", error_description="token expired""#; + let base = Url::parse("https://example.com/api").unwrap(); + let params = AuthorizationManager::extract_www_authenticate_params(header, &base); + + assert_eq!( + params.resource_metadata_url.unwrap().as_str(), + "https://example.com/.well-known/oauth-protected-resource" + ); + assert_eq!(params.scope.unwrap(), "read:data write:data"); + assert_eq!(params.error.unwrap(), "invalid_token"); + assert_eq!(params.error_description.unwrap(), "token expired"); + } + + #[test] + fn extract_www_authenticate_params_insufficient_scope() { + let header = r#"Bearer error="insufficient_scope", scope="admin:write", error_description="Additional file write permission required""#; + let base = Url::parse("https://example.com/api").unwrap(); + let params = AuthorizationManager::extract_www_authenticate_params(header, &base); + + assert!(params.resource_metadata_url.is_none()); + assert!(params.is_insufficient_scope()); + assert!(!params.is_invalid_token()); + assert_eq!(params.scope.unwrap(), "admin:write"); + assert_eq!( + params.error_description.unwrap(), + "Additional file write permission required" + ); + } + + #[test] + fn extract_www_authenticate_params_with_only_resource_metadata() { + let header = r#"Bearer resource_metadata="/.well-known/oauth-protected-resource""#; + let base = Url::parse("https://example.com/api").unwrap(); + let params = AuthorizationManager::extract_www_authenticate_params(header, &base); + + assert_eq!( + params.resource_metadata_url.unwrap().as_str(), + "https://example.com/.well-known/oauth-protected-resource" + ); + assert!(params.scope.is_none()); + } + + #[test] + fn extract_www_authenticate_params_bare_bearer() { + let header = "Bearer"; + let base = Url::parse("https://example.com/api").unwrap(); + let params = AuthorizationManager::extract_www_authenticate_params(header, &base); + + assert!(params.resource_metadata_url.is_none()); + assert!(params.scope.is_none()); + assert!(params.error.is_none()); + assert!(params.error_description.is_none()); + } + + #[test] + fn extract_www_authenticate_params_error_only() { + let header = r#"Bearer error="invalid_token""#; + let base = Url::parse("https://example.com/api").unwrap(); + let params = AuthorizationManager::extract_www_authenticate_params(header, &base); + + assert!(params.resource_metadata_url.is_none()); + assert!(params.scope.is_none()); + assert!(params.is_invalid_token()); + assert!(!params.is_insufficient_scope()); + assert!(params.error_description.is_none()); + } + + #[test] + fn extract_www_authenticate_params_with_unquoted_scope() { + let header = r#"Bearer scope=read:data, error="insufficient_scope""#; + let base = Url::parse("https://example.com/api").unwrap(); + let params = AuthorizationManager::extract_www_authenticate_params(header, &base); + + assert_eq!(params.scope.unwrap(), "read:data"); + } + + // -- stored authorization state -- + + #[test] + fn test_stored_authorization_state_serialization() { + let pkce = PkceCodeVerifier::new("my-verifier".to_string()); + let csrf = CsrfToken::new("my-csrf".to_string()); + let state = StoredAuthorizationState::new(&pkce, &csrf) + .with_requested_scopes(vec!["read".to_string(), "write".to_string()]); + + let json = serde_json::to_string(&state).unwrap(); + let deserialized: StoredAuthorizationState = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.pkce_verifier, "my-verifier"); + assert_eq!(deserialized.csrf_token, "my-csrf"); + assert_eq!(deserialized.requested_scopes, vec!["read", "write"]); + assert_eq!(deserialized.expected_issuer, None); + assert!(!deserialized.require_issuer); + } + + #[test] + fn stored_authorization_state_defaults_requested_scopes_when_absent() { + let json = r#"{"pkce_verifier":"v","csrf_token":"c","created_at":1}"#; + let state: StoredAuthorizationState = serde_json::from_str(json).unwrap(); + + assert!(state.requested_scopes.is_empty()); + } + + #[test] + fn test_stored_authorization_state_records_expected_issuer() { + let pkce = PkceCodeVerifier::new("my-verifier".to_string()); + let csrf = CsrfToken::new("my-csrf".to_string()); + let state = StoredAuthorizationState::new_with_expected_issuer( + &pkce, + &csrf, + Some("https://auth.example.com".to_string()), + false, + ); + + let json = serde_json::to_string(&state).unwrap(); + let deserialized: StoredAuthorizationState = serde_json::from_str(&json).unwrap(); + + assert_eq!( + deserialized.expected_issuer.as_deref(), + Some("https://auth.example.com") + ); + assert!(!deserialized.require_issuer); + } + + #[test] + fn test_stored_authorization_state_debug_redacts_secrets() { + let pkce = PkceCodeVerifier::new("super-secret-verifier".to_string()); + let csrf = CsrfToken::new("super-secret-csrf".to_string()); + let state = StoredAuthorizationState::new(&pkce, &csrf); + let debug_output = format!("{:?}", state); + + assert!(!debug_output.contains("super-secret-verifier")); + assert!(!debug_output.contains("super-secret-csrf")); + assert!(debug_output.contains("[REDACTED]")); + assert!(debug_output.contains("expected_issuer")); + assert!(debug_output.contains("created_at")); + } + + #[test] + fn test_stored_credentials_debug_redacts_token_response() { + use oauth2::{AccessToken, basic::BasicTokenType}; + + use super::{OAuthTokenResponse, StoredCredentials}; + + let token_response = OAuthTokenResponse::new( + AccessToken::new("super-secret-access-token".to_string()), + BasicTokenType::Bearer, + VendorExtraTokenFields::default(), + ); + let creds = StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(token_response), + granted_scopes: vec![], + token_received_at: None, + issuer: None, + }; + let debug_output = format!("{:?}", creds); + + assert!(!debug_output.contains("super-secret-access-token")); + assert!(debug_output.contains("[REDACTED]")); + assert!(debug_output.contains("my-client")); + } + + #[test] + fn test_stored_authorization_state_into_pkce_verifier() { + let pkce = PkceCodeVerifier::new("original-verifier".to_string()); + let csrf = CsrfToken::new("csrf-token".to_string()); + let state = StoredAuthorizationState::new(&pkce, &csrf); + + let recovered = state.into_pkce_verifier(); + assert_eq!(recovered.secret(), "original-verifier"); + } + + #[test] + fn test_stored_authorization_state_created_at() { + let pkce = PkceCodeVerifier::new("verifier".to_string()); + let csrf = CsrfToken::new("csrf".to_string()); + let state = StoredAuthorizationState::new(&pkce, &csrf); + + assert!(state.created_at > 1577836800); // Jan 1, 2020 + } + + // -- state store -- + + #[tokio::test] + async fn test_in_memory_state_store_save_and_load() { + let store = InMemoryStateStore::new(); + let pkce = PkceCodeVerifier::new("test-verifier".to_string()); + let csrf = CsrfToken::new("test-csrf".to_string()); + let state = StoredAuthorizationState::new(&pkce, &csrf); + + store.save("test-csrf", state).await.unwrap(); + + let loaded = store.load("test-csrf").await.unwrap(); + assert!(loaded.is_some()); + let loaded = loaded.unwrap(); + assert_eq!(loaded.csrf_token, "test-csrf"); + assert_eq!(loaded.pkce_verifier, "test-verifier"); + } + + #[tokio::test] + async fn test_in_memory_state_store_load_nonexistent() { + let store = InMemoryStateStore::new(); + let result = store.load("nonexistent").await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_in_memory_state_store_delete() { + let store = InMemoryStateStore::new(); + let pkce = PkceCodeVerifier::new("verifier".to_string()); + let csrf = CsrfToken::new("csrf".to_string()); + let state = StoredAuthorizationState::new(&pkce, &csrf); + + store.save("csrf", state).await.unwrap(); + store.delete("csrf").await.unwrap(); + + let result = store.load("csrf").await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_in_memory_state_store_overwrite() { + let store = InMemoryStateStore::new(); + let csrf_key = "same-csrf"; + + let pkce1 = PkceCodeVerifier::new("verifier-1".to_string()); + let csrf1 = CsrfToken::new(csrf_key.to_string()); + let state1 = StoredAuthorizationState::new(&pkce1, &csrf1); + store.save(csrf_key, state1).await.unwrap(); + + let pkce2 = PkceCodeVerifier::new("verifier-2".to_string()); + let csrf2 = CsrfToken::new(csrf_key.to_string()); + let state2 = StoredAuthorizationState::new(&pkce2, &csrf2); + store.save(csrf_key, state2).await.unwrap(); + + let loaded = store.load(csrf_key).await.unwrap().unwrap(); + assert_eq!(loaded.pkce_verifier, "verifier-2"); + } + + #[tokio::test] + async fn test_in_memory_state_store_concurrent_access() { + let store = Arc::new(InMemoryStateStore::new()); + let mut handles = vec![]; + + for i in 0..10 { + let store = Arc::clone(&store); + let handle = tokio::spawn(async move { + let csrf_key = format!("csrf-{}", i); + let verifier = format!("verifier-{}", i); + + let pkce = PkceCodeVerifier::new(verifier.clone()); + let csrf = CsrfToken::new(csrf_key.clone()); + let state = StoredAuthorizationState::new(&pkce, &csrf); + + store.save(&csrf_key, state).await.unwrap(); + let loaded = store.load(&csrf_key).await.unwrap().unwrap(); + assert_eq!(loaded.pkce_verifier, verifier); + + store.delete(&csrf_key).await.unwrap(); + let deleted = store.load(&csrf_key).await.unwrap(); + assert!(deleted.is_none()); + }); + handles.push(handle); + } + + for handle in handles { + handle.await.unwrap(); + } + } + + #[tokio::test] + async fn test_custom_state_store_with_authorization_manager() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[derive(Debug, Default)] + struct TrackingStateStore { + inner: InMemoryStateStore, + save_count: AtomicUsize, + load_count: AtomicUsize, + delete_count: AtomicUsize, + } + + #[async_trait::async_trait] + impl StateStore for TrackingStateStore { + async fn save( + &self, + csrf_token: &str, + state: StoredAuthorizationState, + ) -> Result<(), AuthError> { + self.save_count.fetch_add(1, Ordering::SeqCst); + self.inner.save(csrf_token, state).await + } + + async fn load( + &self, + csrf_token: &str, + ) -> Result, AuthError> { + self.load_count.fetch_add(1, Ordering::SeqCst); + self.inner.load(csrf_token).await + } + + async fn delete(&self, csrf_token: &str) -> Result<(), AuthError> { + self.delete_count.fetch_add(1, Ordering::SeqCst); + self.inner.delete(csrf_token).await + } + } + + let store = TrackingStateStore::default(); + let pkce = PkceCodeVerifier::new("test-verifier".to_string()); + let csrf = CsrfToken::new("test-csrf".to_string()); + let state = StoredAuthorizationState::new(&pkce, &csrf); + + store.save("test-csrf", state).await.unwrap(); + assert_eq!(store.save_count.load(Ordering::SeqCst), 1); + + let _ = store.load("test-csrf").await.unwrap(); + assert_eq!(store.load_count.load(Ordering::SeqCst), 1); + + store.delete("test-csrf").await.unwrap(); + assert_eq!(store.delete_count.load(Ordering::SeqCst), 1); + + let mut manager = AuthorizationManager::new("http://localhost").await.unwrap(); + manager.set_state_store(TrackingStateStore::default()); + } + + /// Helper: create an AuthorizationManager with minimal metadata so + /// `configure_client` can be exercised without a live server. + async fn manager_with_metadata( + metadata_override: Option, + ) -> AuthorizationManager { + let mut mgr = AuthorizationManager::new("http://localhost").await.unwrap(); + mgr.set_metadata(metadata_override.unwrap_or(AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + ..Default::default() + })); + mgr + } + + #[tokio::test] + async fn initialize_from_store_clears_dcr_credentials_when_issuer_changes() { + let store = InMemoryCredentialStore::new(); + store + .save(StoredCredentials { + client_id: "dcr-client".to_string(), + token_response: Some(make_token_response("old-token", Some(3600))), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: Some("https://old.example.com".to_string()), + }) + .await + .unwrap(); + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: "https://new.example.com/authorize".to_string(), + token_endpoint: "https://new.example.com/token".to_string(), + issuer: Some("https://new.example.com".to_string()), + ..Default::default() + })) + .await; + manager.set_credential_store(store.clone()); + + let initialized = manager.initialize_from_store().await.unwrap(); + let credentials_cleared = store.load().await.unwrap().is_none(); + + assert_eq!((initialized, credentials_cleared), (false, true)); + } + + fn test_client_config() -> OAuthClientConfig { + OAuthClientConfig { + client_id: "my-client".to_string(), + client_secret: Some("my-secret".to_string()), + scopes: vec![], + redirect_uri: "http://localhost/callback".to_string(), + application_type: None, + } + } + + #[tokio::test] + async fn test_configure_client_uses_client_secret_post_from_metadata() { + let mut additional_fields = HashMap::new(); + additional_fields.insert( + "token_endpoint_auth_methods_supported".to_string(), + serde_json::json!(["client_secret_post"]), + ); + let meta = AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + additional_fields, + ..Default::default() + }; + let mut mgr = manager_with_metadata(Some(meta)).await; + mgr.configure_client(test_client_config()).unwrap(); + assert!(matches!( + mgr.oauth_client.as_ref().unwrap().auth_type(), + AuthType::RequestBody + )); + } + + #[tokio::test] + async fn test_configure_client_defaults_to_basic_auth() { + let mut mgr = manager_with_metadata(None).await; + mgr.configure_client(test_client_config()).unwrap(); + assert!(matches!( + mgr.oauth_client.as_ref().unwrap().auth_type(), + AuthType::BasicAuth + )); + } + + #[tokio::test] + async fn test_configure_client_with_explicit_basic_in_metadata() { + let mut additional_fields = HashMap::new(); + additional_fields.insert( + "token_endpoint_auth_methods_supported".to_string(), + serde_json::json!(["client_secret_basic"]), + ); + let meta = AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + additional_fields, + ..Default::default() + }; + let mut mgr = manager_with_metadata(Some(meta)).await; + mgr.configure_client(test_client_config()).unwrap(); + assert!(matches!( + mgr.oauth_client.as_ref().unwrap().auth_type(), + AuthType::BasicAuth + )); + } + + #[tokio::test] + async fn test_configure_client_ignores_unsupported_auth_methods_in_metadata() { + let mut additional_fields = HashMap::new(); + additional_fields.insert( + "token_endpoint_auth_methods_supported".to_string(), + serde_json::json!(["private_key_jwt"]), + ); + let meta = AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + additional_fields, + ..Default::default() + }; + let mut mgr = manager_with_metadata(Some(meta)).await; + // Unsupported method should fall through to default (basic auth) + mgr.configure_client(test_client_config()).unwrap(); + assert!(matches!( + mgr.oauth_client.as_ref().unwrap().auth_type(), + AuthType::BasicAuth + )); + } + + #[tokio::test] + async fn test_configure_client_prefers_basic_when_both_methods_supported() { + let mut additional_fields = HashMap::new(); + additional_fields.insert( + "token_endpoint_auth_methods_supported".to_string(), + serde_json::json!(["client_secret_post", "client_secret_basic"]), + ); + let meta = AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + additional_fields, + ..Default::default() + }; + let mut mgr = manager_with_metadata(Some(meta)).await; + mgr.configure_client(test_client_config()).unwrap(); + assert!(matches!( + mgr.oauth_client.as_ref().unwrap().auth_type(), + AuthType::BasicAuth + )); + } + // -- metadata deserialization -- + + #[test] + fn test_code_challenge_methods_supported_deserialization() { + let json = r#"{ + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token", + "code_challenge_methods_supported": ["S256", "plain"] + }"#; + let metadata: AuthorizationMetadata = serde_json::from_str(json).unwrap(); + let methods = metadata.code_challenge_methods_supported.unwrap(); + assert!(methods.contains(&"S256".to_string())); + assert!(methods.contains(&"plain".to_string())); + } + + #[test] + fn test_code_challenge_methods_supported_missing_from_json() { + let json = r#"{ + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token" + }"#; + let metadata: AuthorizationMetadata = serde_json::from_str(json).unwrap(); + assert!(metadata.code_challenge_methods_supported.is_none()); + } + + // -- server validation -- + + #[tokio::test] + async fn test_validate_as_metadata_rejects_unsupported_response_type() { + let mut manager = AuthorizationManager::new("https://example.com") + .await + .unwrap(); + let metadata = AuthorizationMetadata { + authorization_endpoint: "https://auth.example.com/authorize".to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + response_types_supported: Some(vec!["token".to_string()]), + ..Default::default() + }; + manager.set_metadata(metadata); + assert!(manager.validate_server_metadata("code").is_err()); + } + + fn as_metadata_with_pkce(methods: Option>) -> AuthorizationMetadata { + AuthorizationMetadata { + authorization_endpoint: "https://auth.example.com/authorize".to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + response_types_supported: Some(vec!["code".to_string()]), + code_challenge_methods_supported: methods, + ..Default::default() + } + } + + #[tokio::test] + async fn test_validate_as_metadata_rejects_without_pkce_s256() { + let mut manager = AuthorizationManager::new("https://example.com") + .await + .unwrap(); + manager.set_metadata(as_metadata_with_pkce(Some(vec!["plain".to_string()]))); + assert!(matches!( + manager.validate_server_metadata("code"), + Err(AuthError::PkceUnsupported) + )); + } + + #[tokio::test] + async fn test_validate_as_metadata_allows_absent_pkce_methods_by_default() { + let mut manager = AuthorizationManager::new("https://example.com") + .await + .unwrap(); + manager.set_metadata(as_metadata_with_pkce(None)); + assert!(manager.validate_server_metadata("code").is_ok()); + } + + #[tokio::test] + async fn test_validate_as_metadata_passes_with_pkce_s256() { + let mut manager = AuthorizationManager::new("https://example.com") + .await + .unwrap(); + manager.set_metadata(as_metadata_with_pkce(Some(vec!["S256".to_string()]))); + assert!(manager.validate_server_metadata("code").is_ok()); + } + + #[tokio::test] + async fn test_validate_as_metadata_passes_without_metadata() { + let manager = AuthorizationManager::new("https://example.com") + .await + .unwrap(); + assert!(manager.validate_server_metadata("code").is_ok()); + } + + // -- authorization flow -- + + #[tokio::test] + async fn test_authorization_url_is_valid() { + let base_url = "https://mcp.example.com/api"; + let auth_endpoint = "https://auth.example.com/authorize"; + let mut manager = AuthorizationManager::new(base_url).await.unwrap(); + + let metadata = AuthorizationMetadata { + authorization_endpoint: auth_endpoint.to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + registration_endpoint: None, + issuer: None, + jwks_uri: None, + scopes_supported: None, + response_types_supported: Some(vec!["code".to_string()]), + code_challenge_methods_supported: Some(vec!["S256".to_string()]), + additional_fields: std::collections::HashMap::new(), + }; + manager.set_metadata(metadata); + manager.configure_client_id("test-client-id").unwrap(); + + let auth_url = manager + .get_authorization_url(&["read", "write"]) + .await + .unwrap(); + let parsed = Url::parse(&auth_url).unwrap(); + + assert!(auth_url.starts_with(auth_endpoint)); + + let params: std::collections::HashMap<_, _> = parsed.query_pairs().collect(); + + assert_eq!( + params.get("response_type").map(|v| v.as_ref()), + Some("code") + ); + assert_eq!( + params.get("client_id").map(|v| v.as_ref()), + Some("test-client-id") + ); + assert!(params.contains_key("state")); + assert_eq!( + params.get("redirect_uri").map(|v| v.as_ref()), + Some(base_url) + ); + assert!(params.contains_key("code_challenge")); + assert_eq!( + params.get("code_challenge_method").map(|v| v.as_ref()), + Some("S256") + ); + assert_eq!(params.get("resource").map(|v| v.as_ref()), Some(base_url)); + + let scope = params + .get("scope") + .map(|v| v.to_string()) + .unwrap_or_default(); + assert!(scope.contains("read")); + assert!(scope.contains("write")); + } + + #[tokio::test] + async fn authorization_url_uses_discovered_resource() { + let base_url = "https://mcp.example.com/mcp"; + let auth_endpoint = "https://auth.example.com/authorize"; + let mut manager = AuthorizationManager::new(base_url).await.unwrap(); + + let metadata = AuthorizationMetadata { + authorization_endpoint: auth_endpoint.to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + registration_endpoint: None, + issuer: None, + jwks_uri: None, + scopes_supported: None, + response_types_supported: Some(vec!["code".to_string()]), + code_challenge_methods_supported: Some(vec!["S256".to_string()]), + additional_fields: std::collections::HashMap::new(), + }; + manager.set_metadata(metadata); + manager.configure_client_id("test-client-id").unwrap(); + *manager.discovered_resource.write().await = Some("https://mcp.example.com".to_string()); + + let auth_url = manager.get_authorization_url(&["read"]).await.unwrap(); + let parsed = Url::parse(&auth_url).unwrap(); + let params: std::collections::HashMap<_, _> = parsed.query_pairs().collect(); + + assert_eq!( + params.get("resource").map(|v| v.as_ref()), + Some("https://mcp.example.com") + ); + } + + #[tokio::test] + async fn authorization_url_uses_default_resource_without_protected_resource_document() { + let base_url = "https://mcp.example.com/mcp"; + let auth_endpoint = "https://auth.example.com/authorize"; + let mut manager = AuthorizationManager::new(base_url).await.unwrap(); + + let metadata = AuthorizationMetadata { + authorization_endpoint: auth_endpoint.to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + registration_endpoint: None, + issuer: None, + jwks_uri: None, + scopes_supported: None, + response_types_supported: Some(vec!["code".to_string()]), + code_challenge_methods_supported: Some(vec!["S256".to_string()]), + additional_fields: std::collections::HashMap::new(), + }; + manager.set_metadata(metadata); + manager.configure_client_id("test-client-id").unwrap(); + + let auth_url = manager.get_authorization_url(&["read"]).await.unwrap(); + let parsed = Url::parse(&auth_url).unwrap(); + let params: std::collections::HashMap<_, _> = parsed.query_pairs().collect(); + + assert_eq!(params.get("resource").map(|v| v.as_ref()), Some(base_url)); + } + + #[test] + fn authorization_callback_parses_optional_issuer() { + let callback = AuthorizationCallback::from_redirect_url( + "http://localhost/callback?code=abc&state=csrf&iss=https%3A%2F%2Fauth.example.com", + ) + .unwrap(); + + assert_eq!(callback.code, "abc"); + assert_eq!(callback.csrf_token, "csrf"); + assert_eq!(callback.issuer.as_deref(), Some("https://auth.example.com")); + } + + #[test] + fn authorization_callback_requires_code_and_state() { + let missing_code = AuthorizationCallback::from_redirect_url( + "http://localhost/callback?state=csrf&iss=https%3A%2F%2Fauth.example.com", + ); + assert!(matches!( + missing_code, + Err(AuthError::AuthorizationFailed(message)) if message.contains("missing code") + )); + + let missing_state = AuthorizationCallback::from_redirect_url( + "http://localhost/callback?code=abc&iss=https%3A%2F%2Fauth.example.com", + ); + assert!(matches!( + missing_state, + Err(AuthError::AuthorizationFailed(message)) if message.contains("missing state") + )); + } + + #[rstest] + #[case::matching_issuer( + Some("https://auth.example.com"), + false, + Some("https://auth.example.com") + )] + #[case::missing_issuer_when_not_required(Some("https://auth.example.com"), false, None)] + fn validate_authorization_response_issuer_accepts_valid_cases( + #[case] expected_issuer: Option<&str>, + #[case] require_issuer: bool, + #[case] received_issuer: Option<&str>, + ) { + let pkce = PkceCodeVerifier::new("verifier".to_string()); + let csrf = CsrfToken::new("csrf".to_string()); + let state = StoredAuthorizationState::new_with_expected_issuer( + &pkce, + &csrf, + expected_issuer.map(str::to_owned), + require_issuer, + ); + + assert!( + AuthorizationManager::validate_authorization_response_issuer(&state, received_issuer) + .is_ok() + ); + } + + #[derive(Clone, Copy, Debug)] + enum ExpectedIssuerError { + Missing { + expected_issuer: &'static str, + }, + NotRecorded, + Mismatch { + expected_issuer: &'static str, + received_issuer: &'static str, + }, + } + + fn assert_expected_issuer_error(error: AuthError, expected: ExpectedIssuerError) { + match expected { + ExpectedIssuerError::Missing { expected_issuer } => assert!(matches!( + error, + AuthError::AuthorizationServerMissingIssuer { expected_issuer: actual } + if actual == expected_issuer + )), + ExpectedIssuerError::NotRecorded => assert!( + matches!(error, AuthError::AuthorizationFailed(message) if message.contains("expected issuer was not recorded")) + ), + ExpectedIssuerError::Mismatch { + expected_issuer, + received_issuer, + } => assert!(matches!( + error, + AuthError::AuthorizationServerMismatch { + expected_issuer: actual_expected, + received_issuer: actual_received + } if actual_expected == expected_issuer && actual_received == received_issuer + )), + } + } + + #[rstest] + #[case::requires_advertised_issuer( + Some("https://auth.example.com"), + true, + None, + ExpectedIssuerError::Missing { + expected_issuer: "https://auth.example.com", + } + )] + #[case::present_issuer_without_expected( + None, + false, + Some("https://auth.example.com"), + ExpectedIssuerError::NotRecorded + )] + #[case::required_issuer_without_expected(None, true, None, ExpectedIssuerError::NotRecorded)] + #[case::mismatched_issuer( + Some("https://auth.example.com"), + false, + Some("https://evil.example.com"), + ExpectedIssuerError::Mismatch { + expected_issuer: "https://auth.example.com", + received_issuer: "https://evil.example.com", + } + )] + fn validate_authorization_response_issuer_rejects_invalid_cases( + #[case] expected_issuer: Option<&str>, + #[case] require_issuer: bool, + #[case] received_issuer: Option<&str>, + #[case] expected_error: ExpectedIssuerError, + ) { + let pkce = PkceCodeVerifier::new("verifier".to_string()); + let csrf = CsrfToken::new("csrf".to_string()); + let state = StoredAuthorizationState::new_with_expected_issuer( + &pkce, + &csrf, + expected_issuer.map(str::to_owned), + require_issuer, + ); + + let error = + AuthorizationManager::validate_authorization_response_issuer(&state, received_issuer) + .unwrap_err(); + + assert_expected_issuer_error(error, expected_error); + } + + #[tokio::test] + async fn authorization_url_stores_expected_issuer_for_callback_validation() { + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: "https://auth.example.com/authorize".to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + issuer: Some("https://auth.example.com".to_string()), + ..Default::default() + })) + .await; + manager.configure_client_id("test-client-id").unwrap(); + + let auth_url = manager.get_authorization_url(&[]).await.unwrap(); + let parsed = Url::parse(&auth_url).unwrap(); + let state = parsed + .query_pairs() + .find_map(|(key, value)| (key == "state").then(|| value.into_owned())) + .expect("authorization URL should contain state"); + + let stored_state = manager + .state_store + .load(&state) + .await + .unwrap() + .expect("authorization state should be stored"); + assert_eq!( + stored_state.expected_issuer.as_deref(), + Some("https://auth.example.com") + ); + } + + // -- scope management -- + + #[test] + fn compute_scope_union_adds_new_scopes() { + let current = vec!["read".to_string(), "write".to_string()]; + let result = AuthorizationManager::compute_scope_union(¤t, "admin delete"); + + assert!(result.contains(&"read".to_string())); + assert!(result.contains(&"write".to_string())); + assert!(result.contains(&"admin".to_string())); + assert!(result.contains(&"delete".to_string())); + assert_eq!(result.len(), 4); + } + + #[test] + fn compute_scope_union_deduplicates() { + let current = vec!["read".to_string(), "write".to_string()]; + let result = AuthorizationManager::compute_scope_union(¤t, "read admin"); + + assert!(result.contains(&"read".to_string())); + assert!(result.contains(&"write".to_string())); + assert!(result.contains(&"admin".to_string())); + assert_eq!(result.len(), 3); + } + + #[test] + fn compute_scope_union_handles_empty_current() { + let current: Vec = vec![]; + let result = AuthorizationManager::compute_scope_union(¤t, "read write"); + + assert!(result.contains(&"read".to_string())); + assert!(result.contains(&"write".to_string())); + assert_eq!(result.len(), 2); + } + + // -- SEP-2207: offline_access -- + + #[tokio::test] + async fn select_scopes_adds_offline_access_when_as_supports_it() { + let mgr = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]), + ..Default::default() + })) + .await; + *mgr.resource_scopes.write().await = vec!["profile".to_string()]; + + let scopes = mgr.select_scopes(None, &[]); + assert!( + scopes.contains(&"offline_access".to_string()), + "offline_access should be added when AS supports it" + ); + assert!(scopes.contains(&"profile".to_string())); + } + + #[tokio::test] + async fn select_scopes_does_not_add_offline_access_when_as_does_not_support_it() { + let mgr = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + scopes_supported: Some(vec!["profile".to_string(), "email".to_string()]), + ..Default::default() + })) + .await; + *mgr.resource_scopes.write().await = vec!["profile".to_string()]; + + let scopes = mgr.select_scopes(None, &[]); + assert!( + !scopes.contains(&"offline_access".to_string()), + "offline_access should not be added when AS does not support it" + ); + } + + #[tokio::test] + async fn select_scopes_falls_back_to_defaults() { + let mgr = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + scopes_supported: None, + ..Default::default() + })) + .await; + + let scopes = mgr.select_scopes(None, &["default_scope"]); + assert_eq!(scopes, vec!["default_scope".to_string()]); + } + + #[tokio::test] + async fn select_scopes_does_not_duplicate_offline_access() { + let mgr = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]), + ..Default::default() + })) + .await; + + // When AS metadata is the scope source and already contains offline_access, + // it should appear exactly once. + let scopes = mgr.select_scopes(None, &[]); + let count = scopes.iter().filter(|s| *s == "offline_access").count(); + assert_eq!(count, 1, "offline_access should not be duplicated"); + } + + #[tokio::test] + async fn select_scopes_adds_offline_access_to_www_authenticate_scopes() { + let mgr = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]), + ..Default::default() + })) + .await; + *mgr.www_auth_scopes.write().await = vec!["profile".to_string()]; + + let scopes = mgr.select_scopes(None, &[]); + assert!(scopes.contains(&"offline_access".to_string())); + assert!(scopes.contains(&"profile".to_string())); + } + + #[tokio::test] + async fn select_scopes_adds_offline_access_to_www_authenticate_argument() { + let mgr = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]), + ..Default::default() + })) + .await; + + let scopes = mgr.select_scopes(Some("profile email"), &[]); + assert!(scopes.contains(&"offline_access".to_string())); + assert!(scopes.contains(&"profile".to_string())); + assert!(scopes.contains(&"email".to_string())); + } + + // -- SEP-2350: client-side scope accumulation in step-up authorization -- + + #[tokio::test] + async fn select_scopes_unions_challenge_with_previously_requested() { + let mgr = manager_with_metadata(None).await; + *mgr.current_scopes.write().await = vec!["read".to_string()]; + + let scopes = mgr.select_scopes(Some("write"), &[]); + + assert_eq!(scopes, vec!["read".to_string(), "write".to_string()]); + } + + #[tokio::test] + async fn select_scopes_does_not_replace_previously_requested_with_challenge() { + let mgr = manager_with_metadata(None).await; + *mgr.current_scopes.write().await = vec!["read".to_string(), "profile".to_string()]; + + let scopes = mgr.select_scopes(Some("write"), &[]); + + assert!(scopes.contains(&"read".to_string())); + assert!(scopes.contains(&"profile".to_string())); + assert!(scopes.contains(&"write".to_string())); + } + + #[tokio::test] + async fn select_scopes_accumulates_across_multiple_step_up_rounds() { + let mgr = manager_with_metadata(None).await; + *mgr.current_scopes.write().await = vec!["read".to_string()]; + + // round one: server challenges for "write" + let round_one = mgr.select_scopes(Some("write"), &[]); + assert_eq!(round_one, vec!["read".to_string(), "write".to_string()]); + *mgr.current_scopes.write().await = round_one; + + // round two: server challenges for "admin", earlier scopes are retained + let round_two = mgr.select_scopes(Some("admin"), &[]); + assert_eq!( + round_two, + vec!["read".to_string(), "write".to_string(), "admin".to_string()] + ); + } + + #[tokio::test] + async fn select_scopes_deduplicates_challenge_already_requested() { + let mgr = manager_with_metadata(None).await; + *mgr.current_scopes.write().await = vec!["read".to_string(), "write".to_string()]; + + let scopes = mgr.select_scopes(Some("write admin"), &[]); + + assert_eq!( + scopes, + vec!["read".to_string(), "write".to_string(), "admin".to_string()] + ); + } + + #[tokio::test] + async fn select_scopes_unions_resource_metadata_as_operational_requirement() { + let mgr = manager_with_metadata(None).await; + *mgr.current_scopes.write().await = vec!["read".to_string()]; + *mgr.resource_scopes.write().await = vec!["profile".to_string()]; + + let scopes = mgr.select_scopes(Some("write"), &[]); + + assert!(scopes.contains(&"read".to_string())); + assert!(scopes.contains(&"write".to_string())); + assert!(scopes.contains(&"profile".to_string())); + } + + #[test] + fn resolve_granted_scopes_uses_requested_when_response_omits_scope() { + let granted = AuthorizationManager::resolve_granted_scopes( + None, + &["read".to_string(), "write".to_string()], + &["read".to_string()], + ); + + assert_eq!(granted, vec!["read".to_string(), "write".to_string()]); + } + + #[test] + fn resolve_granted_scopes_honors_explicit_server_downgrade() { + let granted = AuthorizationManager::resolve_granted_scopes( + Some(vec!["read".to_string()]), + &["read".to_string(), "write".to_string()], + &["read".to_string()], + ); + + assert_eq!(granted, vec!["read".to_string()]); + } + + #[test] + fn resolve_granted_scopes_falls_back_to_current_when_nothing_requested() { + let granted = + AuthorizationManager::resolve_granted_scopes(None, &[], &["read".to_string()]); + + assert_eq!(granted, vec!["read".to_string()]); + } + + #[tokio::test] + async fn add_offline_access_if_supported_works_with_explicit_scopes() { + let mgr = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]), + ..Default::default() + })) + .await; + + let mut explicit = vec!["read".to_string(), "write".to_string()]; + mgr.add_offline_access_if_supported(&mut explicit); + assert!(explicit.contains(&"offline_access".to_string())); + } + + #[tokio::test] + async fn add_offline_access_if_supported_skips_empty_scopes() { + let mgr = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]), + ..Default::default() + })) + .await; + + let mut empty: Vec = vec![]; + mgr.add_offline_access_if_supported(&mut empty); + assert!( + empty.is_empty(), + "offline_access should not be the only scope" + ); + } + + #[tokio::test] + async fn scope_upgrade_adds_offline_access_when_as_supports_it() { + let mut mgr = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]), + ..Default::default() + })) + .await; + mgr.configure_client_id("my-client").unwrap(); + *mgr.current_scopes.write().await = vec!["profile".to_string()]; + + let auth_url = mgr.request_scope_upgrade("email").await.unwrap(); + let parsed = Url::parse(&auth_url).unwrap(); + let scope = parsed + .query_pairs() + .find_map(|(key, value)| (key == "scope").then(|| value.into_owned())) + .expect("scope should be present"); + let mut scope_parts: Vec<&str> = scope.split_whitespace().collect(); + scope_parts.sort_unstable(); + assert_eq!(scope_parts, vec!["email", "offline_access", "profile"]); + } + + #[test] + fn scope_upgrade_config_default_values() { + let config = ScopeUpgradeConfig::default(); + assert_eq!(config.max_upgrade_attempts, 3); + assert!(config.auto_upgrade); + } + + #[tokio::test] + async fn authorization_manager_tracks_scope_upgrade_attempts() { + let manager = AuthorizationManager::new("http://localhost").await.unwrap(); + + assert_eq!(manager.get_scope_upgrade_attempts().await, 0); + + *manager.scope_upgrade_attempts.write().await = 2; + assert_eq!(manager.get_scope_upgrade_attempts().await, 2); + + manager.reset_scope_upgrade_attempts().await; + assert_eq!(manager.get_scope_upgrade_attempts().await, 0); + } + + #[tokio::test] + async fn authorization_manager_can_attempt_scope_upgrade_respects_config() { + let mut manager = AuthorizationManager::new("http://localhost").await.unwrap(); + + assert!(manager.can_attempt_scope_upgrade().await); + + manager.set_scope_upgrade_config(ScopeUpgradeConfig { + max_upgrade_attempts: 3, + auto_upgrade: false, + }); + assert!(!manager.can_attempt_scope_upgrade().await); + + manager.set_scope_upgrade_config(ScopeUpgradeConfig { + max_upgrade_attempts: 2, + auto_upgrade: true, + }); + *manager.scope_upgrade_attempts.write().await = 2; + assert!(!manager.can_attempt_scope_upgrade().await); + + *manager.scope_upgrade_attempts.write().await = 1; + assert!(manager.can_attempt_scope_upgrade().await); + } + + // -- get_access_token -- + + use super::{OAuthTokenResponse, StoredCredentials}; + + fn make_token_response(access_token: &str, expires_in_secs: Option) -> OAuthTokenResponse { + use oauth2::{AccessToken, basic::BasicTokenType}; + let mut resp = OAuthTokenResponse::new( + AccessToken::new(access_token.to_string()), + BasicTokenType::Bearer, + VendorExtraTokenFields { + ..Default::default() + }, + ); + if let Some(secs) = expires_in_secs { + resp.set_expires_in(Some(&std::time::Duration::from_secs(secs))); + } + resp + } + + #[tokio::test] + async fn get_access_token_returns_error_when_no_credentials() { + let manager = AuthorizationManager::new("http://localhost").await.unwrap(); + let err = manager.get_access_token().await.unwrap_err(); + assert!(matches!(err, AuthError::AuthorizationRequired)); + } + + #[tokio::test] + async fn get_access_token_returns_token_when_not_expired() { + let manager = AuthorizationManager::new("http://localhost").await.unwrap(); + let stored = StoredCredentials { + client_id: "test".to_string(), + token_response: Some(make_token_response("my-access-token", Some(3600))), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, + }; + manager.credential_store.save(stored).await.unwrap(); + + let token = manager.get_access_token().await.unwrap(); + assert_eq!(token, "my-access-token"); + } + + #[tokio::test] + async fn get_access_token_requires_reauth_when_expired_and_no_refresh_token() { + let mut manager = manager_with_metadata(None).await; + manager.configure_client(test_client_config()).unwrap(); + + let stored = StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(make_token_response("stale-token", Some(3600))), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs() - 7200), + issuer: None, + }; + manager.credential_store.save(stored).await.unwrap(); + + let err = manager.get_access_token().await.unwrap_err(); + assert!( + matches!(err, AuthError::AuthorizationRequired), + "expected AuthorizationRequired when token is expired and refresh is impossible, got: {err:?}" + ); + } + + #[tokio::test] + async fn get_access_token_returns_token_without_expiry_info() { + let manager = AuthorizationManager::new("http://localhost").await.unwrap(); + let stored = StoredCredentials { + client_id: "test".to_string(), + token_response: Some(make_token_response("no-expiry-token", None)), + granted_scopes: vec![], + token_received_at: None, + issuer: None, + }; + manager.credential_store.save(stored).await.unwrap(); + + let token = manager.get_access_token().await.unwrap(); + assert_eq!(token, "no-expiry-token"); + } + + #[tokio::test] + async fn get_access_token_requires_reauth_when_within_refresh_buffer() { + let mut manager = manager_with_metadata(None).await; + manager.configure_client(test_client_config()).unwrap(); + + let stored = StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(make_token_response("almost-expired", Some(3600))), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs() - 3590), + issuer: None, + }; + manager.credential_store.save(stored).await.unwrap(); + + let err = manager.get_access_token().await.unwrap_err(); + assert!( + matches!(err, AuthError::AuthorizationRequired), + "expected AuthorizationRequired when token is within refresh buffer, got: {err:?}" + ); + } + + #[tokio::test] + async fn get_access_token_propagates_internal_errors() { + let manager = AuthorizationManager::new("http://localhost").await.unwrap(); + let stored = StoredCredentials { + client_id: "test".to_string(), + token_response: Some(make_token_response("stale-token", Some(3600))), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs() - 7200), + issuer: None, + }; + manager.credential_store.save(stored).await.unwrap(); + + let err = manager.get_access_token().await.unwrap_err(); + assert!( + matches!(err, AuthError::InternalError(_)), + "expected InternalError when OAuth client is not configured, got: {err:?}" + ); + } + + // -- ClientRegistrationRequest serialization -- + + #[test] + fn client_registration_request_includes_scope_when_present() { + let req = super::ClientRegistrationRequest { + client_name: "test".to_string(), + redirect_uris: vec!["http://localhost/callback".to_string()], + grant_types: vec!["authorization_code".to_string()], + token_endpoint_auth_method: "none".to_string(), + response_types: vec!["code".to_string()], + scope: Some("read write".to_string()), + application_type: None, + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["scope"], "read write"); + } + + #[test] + fn client_registration_request_omits_scope_when_none() { + let req = super::ClientRegistrationRequest { + client_name: "test".to_string(), + redirect_uris: vec!["http://localhost/callback".to_string()], + grant_types: vec!["authorization_code".to_string()], + token_endpoint_auth_method: "none".to_string(), + response_types: vec!["code".to_string()], + scope: None, + application_type: None, + }; + let json = serde_json::to_value(&req).unwrap(); + assert!(!json.as_object().unwrap().contains_key("scope")); + } + + // -- ClientRegistrationRequest application_type (SEP-837) -- + + #[test] + fn client_registration_request_includes_application_type_when_present() { + let req = super::ClientRegistrationRequest { + client_name: "test".to_string(), + redirect_uris: vec!["http://localhost/callback".to_string()], + grant_types: vec!["authorization_code".to_string()], + token_endpoint_auth_method: "none".to_string(), + response_types: vec!["code".to_string()], + scope: None, + application_type: Some("native".to_string()), + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["application_type"], "native"); + } + + #[test] + fn client_registration_request_omits_application_type_when_none() { + let req = super::ClientRegistrationRequest { + client_name: "test".to_string(), + redirect_uris: vec!["http://localhost/callback".to_string()], + grant_types: vec!["authorization_code".to_string()], + token_endpoint_auth_method: "none".to_string(), + response_types: vec!["code".to_string()], + scope: None, + application_type: None, + }; + let json = serde_json::to_value(&req).unwrap(); + assert!(!json.as_object().unwrap().contains_key("application_type")); + } + + // -- OAuthClientConfig application_type (SEP-837) -- + + #[test] + fn oauth_client_config_defaults_application_type_to_native() { + let config = super::OAuthClientConfig::new("client-id", "http://127.0.0.1:8080/callback"); + assert_eq!(config.application_type.as_deref(), Some("native")); + } + + #[test] + fn oauth_client_config_with_application_type_overrides_default() { + let config = super::OAuthClientConfig::new("client-id", "https://app.example.com/callback") + .with_application_type("web"); + assert_eq!(config.application_type.as_deref(), Some("web")); + } + + // -- client credentials (SEP-1046) -- + + #[cfg(feature = "auth-client-credentials-jwt")] + #[test] + fn client_authentication_audience_defaults_to_metadata_issuer() { + let metadata = AuthorizationMetadata { + authorization_endpoint: "https://auth.example.com/authorize".to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + issuer: Some("https://auth.example.com".to_string()), + ..Default::default() + }; + + let audience = super::client_authentication_audience(&metadata, None).unwrap(); + + assert_eq!(audience, "https://auth.example.com"); + } + + #[cfg(feature = "auth-client-credentials-jwt")] + #[test] + fn client_authentication_audience_honors_explicit_override() { + let metadata = AuthorizationMetadata { + authorization_endpoint: "https://auth.example.com/authorize".to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + issuer: Some("https://auth.example.com".to_string()), + ..Default::default() + }; + + let audience = super::client_authentication_audience( + &metadata, + Some("https://legacy.example.com/token"), + ) + .unwrap(); + + assert_eq!(audience, "https://legacy.example.com/token"); + } + + #[cfg(feature = "auth-client-credentials-jwt")] + #[test] + fn client_authentication_audience_rejects_missing_metadata_issuer() { + let metadata = AuthorizationMetadata { + authorization_endpoint: "https://auth.example.com/authorize".to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + ..Default::default() + }; + + let error = super::client_authentication_audience(&metadata, None).unwrap_err(); + + assert!(matches!(error, AuthError::ClientCredentialsError(_))); + } + + #[cfg(feature = "auth-client-credentials-jwt")] + #[test] + fn client_authentication_header_sets_explicit_type() { + let header = super::client_authentication_header(super::JwtSigningAlgorithm::ES256); + + assert_eq!(header.typ.as_deref(), Some("client-authentication+jwt")); + } + + #[cfg(feature = "auth-client-credentials-jwt")] + #[test] + fn client_credentials_endpoint_allows_http_between_loopback_hosts() { + let resource = Url::parse("http://localhost:8000/mcp").unwrap(); + let token_endpoint = Url::parse("http://127.0.0.1:9000/token").unwrap(); + + assert!(super::is_allowed_client_credentials_endpoint( + &resource, + &token_endpoint + )); + } + + #[cfg(feature = "auth-client-credentials-jwt")] + #[test] + fn client_credentials_endpoint_rejects_http_to_non_loopback_host() { + let resource = Url::parse("http://localhost:8000/mcp").unwrap(); + let token_endpoint = Url::parse("http://auth.example.com/token").unwrap(); + + assert!(!super::is_allowed_client_credentials_endpoint( + &resource, + &token_endpoint + )); + } + + #[tokio::test] + async fn configure_client_credentials_uses_request_body_auth_for_client_secret() { + let mut mgr = manager_with_metadata(None).await; + let config = super::ClientCredentialsConfig::ClientSecret { + client_id: "my-m2m-client".to_string(), + client_secret: "super-secret".to_string(), + scopes: vec!["read".to_string()], + resource: None, + }; + mgr.configure_client_credentials(&config).unwrap(); + let oauth_client = mgr.oauth_client.as_ref().unwrap(); + assert!(matches!(oauth_client.auth_type(), AuthType::RequestBody)); + } + + #[tokio::test] + async fn configure_client_credentials_sets_correct_client_id() { + let mut mgr = manager_with_metadata(None).await; + let config = super::ClientCredentialsConfig::ClientSecret { + client_id: "my-m2m-client".to_string(), + client_secret: "super-secret".to_string(), + scopes: vec!["read".to_string()], + resource: None, + }; + mgr.configure_client_credentials(&config).unwrap(); + let oauth_client = mgr.oauth_client.as_ref().unwrap(); + assert_eq!(oauth_client.client_id().as_str(), "my-m2m-client"); + } + + #[tokio::test] + async fn configure_client_credentials_returns_error_without_metadata() { + let mut mgr = AuthorizationManager::new("http://localhost").await.unwrap(); + let config = super::ClientCredentialsConfig::ClientSecret { + client_id: "id".to_string(), + client_secret: "secret".to_string(), + scopes: vec![], + resource: None, + }; + let err = mgr.configure_client_credentials(&config).unwrap_err(); + assert!(matches!(err, AuthError::NoAuthorizationSupport)); + } + + #[tokio::test] + async fn validate_client_credentials_metadata_rejects_unsupported_method() { + let mut additional_fields = HashMap::new(); + additional_fields.insert( + "token_endpoint_auth_methods_supported".to_string(), + // Neither client_secret_post nor client_secret_basic — should be rejected. + serde_json::json!(["tls_client_auth", "private_key_jwt"]), + ); + let meta = AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + additional_fields, + ..Default::default() + }; + let mgr = manager_with_metadata(Some(meta)).await; + let config = super::ClientCredentialsConfig::ClientSecret { + client_id: "id".to_string(), + client_secret: "secret".to_string(), + scopes: vec![], + resource: None, + }; + let err = mgr + .validate_client_credentials_metadata(&config) + .unwrap_err(); + assert!( + err.to_string().contains("tls_client_auth"), + "expected error to mention unsupported method, got: {err}" + ); + } + + fn client_secret_credentials_config() -> super::ClientCredentialsConfig { + super::ClientCredentialsConfig::ClientSecret { + client_id: "id".to_string(), + client_secret: "secret".to_string(), + scopes: vec![], + resource: None, + } + } + + fn metadata_with_auth_methods(methods: serde_json::Value) -> AuthorizationMetadata { + let mut additional_fields = HashMap::new(); + additional_fields.insert("token_endpoint_auth_methods_supported".to_string(), methods); + AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + additional_fields, + ..Default::default() + } + } + + #[rstest] + #[case::supported_methods(Some(serde_json::json!([ + "client_secret_post", + "client_secret_basic" + ])))] + #[case::field_absent(None)] + #[case::client_secret_basic_only(Some(serde_json::json!(["client_secret_basic"])))] + #[tokio::test] + async fn validate_client_credentials_metadata_accepts_supported_configurations( + #[case] auth_methods: Option, + ) { + let mgr = manager_with_metadata(auth_methods.map(metadata_with_auth_methods)).await; + let config = client_secret_credentials_config(); + + mgr.validate_client_credentials_metadata(&config).unwrap(); + } + + #[tokio::test] + async fn configure_client_credentials_uses_basic_auth_when_server_only_supports_basic() { + let mut additional_fields = HashMap::new(); + additional_fields.insert( + "token_endpoint_auth_methods_supported".to_string(), + serde_json::json!(["client_secret_basic"]), + ); + let meta = AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + additional_fields, + ..Default::default() + }; + let mut mgr = manager_with_metadata(Some(meta)).await; + let config = super::ClientCredentialsConfig::ClientSecret { + client_id: "id".to_string(), + client_secret: "secret".to_string(), + scopes: vec![], + resource: None, + }; + mgr.configure_client_credentials(&config).unwrap(); + let oauth_client = mgr.oauth_client.as_ref().unwrap(); + assert!( + !matches!(oauth_client.auth_type(), AuthType::RequestBody), + "expected HTTP Basic auth when server only supports client_secret_basic" + ); + } + + #[test] + fn client_credentials_config_returns_correct_accessor_values() { + let config = super::ClientCredentialsConfig::ClientSecret { + client_id: "test-id".to_string(), + client_secret: "test-secret".to_string(), + scopes: vec!["scope1".to_string(), "scope2".to_string()], + resource: Some("https://example.com".to_string()), + }; + assert_eq!(config.client_id(), "test-id"); + assert_eq!(config.scopes(), &["scope1", "scope2"]); + assert_eq!(config.resource(), Some("https://example.com")); + assert_eq!(config.auth_method(), "client_secret_post"); + } + + #[test] + fn extension_constant_matches_spec() { + assert_eq!( + super::EXTENSION_OAUTH_CLIENT_CREDENTIALS, + "io.modelcontextprotocol/oauth-client-credentials" + ); + } + + // -- refresh_token -- + + fn make_token_response_with_refresh( + access_token: &str, + refresh_token: &str, + ) -> OAuthTokenResponse { + use oauth2::RefreshToken; + let mut resp = make_token_response(access_token, Some(3600)); + resp.set_refresh_token(Some(RefreshToken::new(refresh_token.to_string()))); + resp + } + + async fn manager_with_refresh_error(error: &'static str) -> AuthorizationManager { + use axum::{Router, body::Body, http::Response, routing::post}; + + let app = Router::new().route( + "/token", + post(move || async move { + Response::builder() + .status(400) + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "error": error, + "error_description": "refresh failed", + }) + .to_string(), + )) + .unwrap() + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: format!("http://{addr}/authorize"), + token_endpoint: format!("http://{addr}/token"), + ..Default::default() + })) + .await; + manager.configure_client(test_client_config()).unwrap(); + manager + .credential_store + .save(StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(make_token_response_with_refresh( + "old-token", + "my-refresh-token", + )), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, + }) + .await + .unwrap(); + manager + } + + #[tokio::test] + async fn refresh_token_returns_error_when_no_stored_credentials() { + let mut manager = manager_with_metadata(None).await; + manager.configure_client(test_client_config()).unwrap(); + + let err = manager.refresh_token().await.unwrap_err(); + assert!( + matches!(err, AuthError::AuthorizationRequired), + "expected AuthorizationRequired when no credentials stored, got: {err:?}" + ); + } + + #[tokio::test] + async fn refresh_token_returns_error_when_no_token_response() { + let mut manager = manager_with_metadata(None).await; + manager.configure_client(test_client_config()).unwrap(); + + let stored = StoredCredentials { + client_id: "my-client".to_string(), + token_response: None, + granted_scopes: vec![], + token_received_at: None, + issuer: None, + }; + manager.credential_store.save(stored).await.unwrap(); + + let err = manager.refresh_token().await.unwrap_err(); + assert!( + matches!(err, AuthError::AuthorizationRequired), + "expected AuthorizationRequired when token_response is None, got: {err:?}" + ); + } + + #[tokio::test] + async fn refresh_token_returns_error_when_no_refresh_token() { + let mut manager = manager_with_metadata(None).await; + manager.configure_client(test_client_config()).unwrap(); + + let stored = StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(make_token_response("old-token", Some(3600))), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, + }; + manager.credential_store.save(stored).await.unwrap(); + + let err = manager.refresh_token().await.unwrap_err(); + assert!( + matches!(err, AuthError::AuthorizationRequired), + "expected AuthorizationRequired when no refresh token, got: {err:?}" + ); + } + + #[tokio::test] + async fn invalid_grant_refresh_requires_reauthorization() { + let manager = manager_with_refresh_error("invalid_grant").await; + + let err = manager.try_refresh_or_reauth().await.unwrap_err(); + + assert!( + matches!(err, AuthError::AuthorizationRequired), + "expected AuthorizationRequired when the refresh token is rejected, got: {err:?}" + ); + } + + #[tokio::test] + async fn temporary_refresh_failure_does_not_require_reauthorization() { + let manager = manager_with_refresh_error("temporarily_unavailable").await; + + let err = manager.try_refresh_or_reauth().await.unwrap_err(); + + assert!( + matches!(err, AuthError::TokenRefreshFailed(_)), + "expected TokenRefreshFailed for a temporary provider failure, got: {err:?}" + ); + } + + #[tokio::test] + async fn refresh_token_uses_client_configured_by_with_client() { + use axum::{Router, body::Body, http::Response, routing::post}; + + let received_header = Arc::new(std::sync::Mutex::new(None)); + let received_header_clone = Arc::clone(&received_header); + let app = Router::new().route( + "/token", + post(move |headers: axum::http::HeaderMap| { + let received_header = Arc::clone(&received_header_clone); + async move { + *received_header.lock().unwrap() = headers + .get("x-custom-client") + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + Response::builder() + .status(200) + .header("content-type", "application/json") + .body(Body::from( + r#"{"access_token":"new-token","token_type":"Bearer","expires_in":3600}"#, + )) + .unwrap() + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: format!("http://{addr}/authorize"), + token_endpoint: format!("http://{addr}/token"), + ..Default::default() + })) + .await; + let mut default_headers = reqwest::header::HeaderMap::new(); + default_headers.insert("x-custom-client", "configured".parse().unwrap()); + manager + .with_client( + reqwest::Client::builder() + .default_headers(default_headers) + .build() + .unwrap(), + ) + .unwrap(); + manager.configure_client(test_client_config()).unwrap(); + manager + .credential_store + .save(StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(make_token_response_with_refresh( + "old-token", + "my-refresh-token", + )), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, + }) + .await + .unwrap(); + + manager.refresh_token().await.unwrap(); + + assert_eq!( + received_header.lock().unwrap().as_deref(), + Some("configured") + ); + } + + #[tokio::test] + async fn exchange_code_uses_client_configured_by_with_client() { + use axum::{Router, body::Body, http::Response, routing::post}; + + let received_header = Arc::new(std::sync::Mutex::new(None)); + let received_header_clone = Arc::clone(&received_header); + let app = Router::new().route( + "/token", + post(move |headers: axum::http::HeaderMap| { + let received_header = Arc::clone(&received_header_clone); + async move { + *received_header.lock().unwrap() = headers + .get("x-custom-client") + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + Response::builder() + .status(200) + .header("content-type", "application/json") + .body(Body::from( + r#"{"access_token":"new-token","token_type":"Bearer","expires_in":3600}"#, + )) + .unwrap() + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: format!("http://{addr}/authorize"), + token_endpoint: format!("http://{addr}/token"), + ..Default::default() + })) + .await; + let mut default_headers = reqwest::header::HeaderMap::new(); + default_headers.insert("x-custom-client", "configured".parse().unwrap()); + manager + .with_client( + reqwest::Client::builder() + .default_headers(default_headers) + .build() + .unwrap(), + ) + .unwrap(); + manager.configure_client(test_client_config()).unwrap(); + let authorization_url = manager.get_authorization_url(&[]).await.unwrap(); + let state = Url::parse(&authorization_url) + .unwrap() + .query_pairs() + .find(|(name, _)| name == "state") + .unwrap() + .1 + .into_owned(); + + manager + .exchange_code_for_token("authorization-code", &state) + .await + .unwrap(); + + assert_eq!( + received_header.lock().unwrap().as_deref(), + Some("configured") + ); + } + + #[tokio::test] + async fn exchange_code_follows_redirects_with_with_client() { + use std::sync::atomic::{AtomicBool, Ordering}; + + use axum::{ + Router, + body::Body, + http::{Response, StatusCode}, + routing::post, + }; + + // The token endpoint replies with a 307 redirect; the with_client path reuses + // the caller's redirect-following client, so the request is expected to follow + // it to the final endpoint that returns the token. + let final_endpoint_hit = Arc::new(AtomicBool::new(false)); + let final_endpoint_hit_clone = Arc::clone(&final_endpoint_hit); + let app = Router::new() + .route( + "/token", + post(|| async { + Response::builder() + .status(StatusCode::TEMPORARY_REDIRECT) + .header("location", "/token-final") + .body(Body::empty()) + .unwrap() + }), + ) + .route( + "/token-final", + post(move || { + let final_endpoint_hit = Arc::clone(&final_endpoint_hit_clone); + async move { + final_endpoint_hit.store(true, Ordering::SeqCst); + Response::builder() + .status(200) + .header("content-type", "application/json") + .body(Body::from( + r#"{"access_token":"redirected-token","token_type":"Bearer","expires_in":3600}"#, + )) + .unwrap() + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: format!("http://{addr}/authorize"), + token_endpoint: format!("http://{addr}/token"), + ..Default::default() + })) + .await; + manager + .with_client(reqwest::Client::builder().build().unwrap()) + .unwrap(); + manager.configure_client(test_client_config()).unwrap(); + let authorization_url = manager.get_authorization_url(&[]).await.unwrap(); + let state = Url::parse(&authorization_url) + .unwrap() + .query_pairs() + .find(|(name, _)| name == "state") + .unwrap() + .1 + .into_owned(); + + manager + .exchange_code_for_token("authorization-code", &state) + .await + .unwrap(); + + assert!( + final_endpoint_hit.load(Ordering::SeqCst), + "with_client path should follow redirects on token exchange" + ); + } + + async fn start_token_server() -> (String, Arc>>) { + use axum::{Router, body::Body, http::Response, routing::post}; + let captured: Arc>> = Arc::new(std::sync::Mutex::new(None)); + let captured_clone = Arc::clone(&captured); + + let app = Router::new().route( + "/token", + post(move |body: axum::body::Bytes| { + let cap = Arc::clone(&captured_clone); + async move { + *cap.lock().unwrap() = + Some(String::from_utf8(body.to_vec()).unwrap()); + Response::builder() + .status(200) + .header("content-type", "application/json") + .body(Body::from( + r#"{"access_token":"new-token","token_type":"Bearer","expires_in":3600}"#, + )) + .unwrap() + } + }), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + (format!("http://{}", addr), captured) + } + + #[tokio::test] + async fn refresh_token_sends_granted_scopes_in_request() { + let (base_url, captured) = start_token_server().await; + + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: format!("{}/authorize", base_url), + token_endpoint: format!("{}/token", base_url), + ..Default::default() + })) + .await; + manager.configure_client(test_client_config()).unwrap(); + + let stored = StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(make_token_response_with_refresh( + "old-token", + "my-refresh-token", + )), + granted_scopes: vec!["read".to_string(), "write".to_string()], + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, + }; + manager.credential_store.save(stored).await.unwrap(); + + manager.refresh_token().await.unwrap(); + + let body = captured.lock().unwrap().take().unwrap(); + let params: std::collections::HashMap<_, _> = url::form_urlencoded::parse(body.as_bytes()) + .into_owned() + .collect(); + let scope = params + .get("scope") + .expect("scope should be present in refresh request"); + let mut scope_parts: Vec<&str> = scope.split_whitespace().collect(); + scope_parts.sort_unstable(); + assert_eq!(scope_parts, vec!["read", "write"]); + } + + #[tokio::test] + async fn refresh_token_includes_resource_parameter() { + let (base_url, captured) = start_token_server().await; + + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: format!("{}/authorize", base_url), + token_endpoint: format!("{}/token", base_url), + ..Default::default() + })) + .await; + manager.configure_client(test_client_config()).unwrap(); + + let stored = StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(make_token_response_with_refresh( + "old-token", + "my-refresh-token", + )), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, + }; + manager.credential_store.save(stored).await.unwrap(); + + manager.refresh_token().await.unwrap(); + + let body = captured.lock().unwrap().take().unwrap(); + let params: std::collections::HashMap<_, _> = url::form_urlencoded::parse(body.as_bytes()) + .into_owned() + .collect(); + assert_eq!( + params.get("resource").map(String::as_str), + Some("http://localhost/"), + "refresh requests must carry the RFC 8707 resource parameter, got body: {body}" + ); + } + + #[tokio::test] + async fn refresh_token_adds_offline_access_when_as_supports_it() { + let (base_url, captured) = start_token_server().await; + + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: format!("{}/authorize", base_url), + token_endpoint: format!("{}/token", base_url), + scopes_supported: Some(vec!["read".to_string(), "offline_access".to_string()]), + ..Default::default() + })) + .await; + manager.configure_client(test_client_config()).unwrap(); + + let stored = StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(make_token_response_with_refresh( + "old-token", + "my-refresh-token", + )), + granted_scopes: vec!["read".to_string()], + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, + }; + manager.credential_store.save(stored).await.unwrap(); + + manager.refresh_token().await.unwrap(); + + let body = captured.lock().unwrap().take().unwrap(); + let params: std::collections::HashMap<_, _> = url::form_urlencoded::parse(body.as_bytes()) + .into_owned() + .collect(); + let scope = params + .get("scope") + .expect("scope should be present in refresh request"); + let mut scope_parts: Vec<&str> = scope.split_whitespace().collect(); + scope_parts.sort_unstable(); + assert_eq!(scope_parts, vec!["offline_access", "read"]); + } + + #[tokio::test] + async fn refresh_token_omits_scope_when_granted_scopes_is_empty() { + let (base_url, captured) = start_token_server().await; + + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: format!("{}/authorize", base_url), + token_endpoint: format!("{}/token", base_url), + ..Default::default() + })) + .await; + manager.configure_client(test_client_config()).unwrap(); + + let stored = StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(make_token_response_with_refresh( + "old-token", + "my-refresh-token", + )), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, + }; + manager.credential_store.save(stored).await.unwrap(); + + manager.refresh_token().await.unwrap(); + + let body = captured.lock().unwrap().take().unwrap(); + let params: std::collections::HashMap<_, _> = url::form_urlencoded::parse(body.as_bytes()) + .into_owned() + .collect(); + assert!( + !params.contains_key("scope"), + "scope should be absent when granted_scopes is empty, body: {body}" + ); + } + + #[tokio::test] + async fn refresh_token_preserves_existing_refresh_token_when_response_omits_it() { + use oauth2::TokenResponse; + // start_token_server returns a response without a refresh_token, matching + // an authorization server that does not rotate refresh tokens on refresh. + let (base_url, _captured) = start_token_server().await; + + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: format!("{}/authorize", base_url), + token_endpoint: format!("{}/token", base_url), + ..Default::default() + })) + .await; + manager.configure_client(test_client_config()).unwrap(); + + let stored = StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(make_token_response_with_refresh( + "old-token", + "my-refresh-token", + )), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, + }; + manager.credential_store.save(stored).await.unwrap(); + + let result = manager.refresh_token().await.unwrap(); + assert_eq!( + result.refresh_token().map(|t| t.secret().as_str()), + Some("my-refresh-token"), + "returned response should keep the previous refresh token" + ); + + let reloaded = manager + .credential_store + .load() + .await + .unwrap() + .unwrap() + .token_response + .unwrap(); + assert_eq!( + reloaded.refresh_token().map(|t| t.secret().as_str()), + Some("my-refresh-token"), + "stored credentials should keep the previous refresh token" + ); + } + + #[tokio::test] + async fn refresh_token_replaces_refresh_token_when_response_includes_new_one() { + use axum::{Router, body::Body, http::Response, routing::post}; + use oauth2::TokenResponse; + + let app = Router::new().route( + "/token", + post(|| async { + Response::builder() + .status(200) + .header("content-type", "application/json") + .body(Body::from( + r#"{"access_token":"new-token","token_type":"Bearer","expires_in":3600,"refresh_token":"rotated-refresh-token"}"#, + )) + .unwrap() + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let base_url = format!("http://{}", addr); + + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: format!("{}/authorize", base_url), + token_endpoint: format!("{}/token", base_url), + ..Default::default() + })) + .await; + manager.configure_client(test_client_config()).unwrap(); + + let stored = StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(make_token_response_with_refresh( + "old-token", + "my-refresh-token", + )), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, + }; + manager.credential_store.save(stored).await.unwrap(); + + let result = manager.refresh_token().await.unwrap(); + assert_eq!( + result.refresh_token().map(|t| t.secret().as_str()), + Some("rotated-refresh-token"), + "a rotated refresh token from the response should replace the old one" + ); + } +} diff --git a/vendor/rmcp/src/transport/child_process.rs b/vendor/rmcp/src/transport/child_process.rs new file mode 100644 index 000000000..6e19a0c3b --- /dev/null +++ b/vendor/rmcp/src/transport/child_process.rs @@ -0,0 +1,321 @@ +use std::process::Stdio; + +use futures::future::Future; +use process_wrap::tokio::{ChildWrapper, CommandWrap}; +use tokio::process::{ChildStderr, ChildStdin, ChildStdout}; + +use super::{RxJsonRpcMessage, Transport, TxJsonRpcMessage, async_rw::AsyncRwTransport}; +use crate::RoleClient; + +const MAX_WAIT_ON_DROP_SECS: u64 = 3; +/// The parts of a child process. +type ChildProcessParts = ( + Box, + ChildStdout, + ChildStdin, + Option, +); + +/// Extract the stdio handles from a spawned child. +/// Returns `(child, stdout, stdin, stderr)` where `stderr` is `Some` only +/// if the process was spawned with `Stdio::piped()`. +#[inline] +fn child_process(mut child: Box) -> std::io::Result { + let child_stdin = match child.inner_mut().stdin().take() { + Some(stdin) => stdin, + None => return Err(std::io::Error::other("stdin was already taken")), + }; + let child_stdout = match child.inner_mut().stdout().take() { + Some(stdout) => stdout, + None => return Err(std::io::Error::other("stdout was already taken")), + }; + let child_stderr = child.inner_mut().stderr().take(); + Ok((child, child_stdout, child_stdin, child_stderr)) +} + +pub struct TokioChildProcess { + child: ChildWithCleanup, + transport: AsyncRwTransport, +} + +pub struct ChildWithCleanup { + inner: Option>, +} + +impl Drop for ChildWithCleanup { + fn drop(&mut self) { + // We should not use start_kill(), instead we should use kill() to avoid zombies + if let Some(mut inner) = self.inner.take() { + // We don't care about the result, just try to kill it + tokio::spawn(async move { + if let Err(e) = Box::into_pin(inner.kill()).await { + tracing::warn!("Error killing child process: {}", e); + } + }); + } + } +} + +impl TokioChildProcess { + /// Convenience: spawn with default `piped` stdio + pub fn new(command: impl Into) -> std::io::Result { + let (proc, _ignored) = TokioChildProcessBuilder::new(command).spawn()?; + Ok(proc) + } + + /// Builder entry-point allowing fine-grained stdio control. + pub fn builder(command: impl Into) -> TokioChildProcessBuilder { + TokioChildProcessBuilder::new(command) + } + + /// Get the process ID of the child process. + pub fn id(&self) -> Option { + self.child.inner.as_ref()?.id() + } + + /// Gracefully shutdown the child process + /// + /// This will first close the transport to the child process (the server), + /// and wait for the child process to exit normally with a timeout. + /// If the child process doesn't exit within the timeout, it will be killed. + pub async fn graceful_shutdown(&mut self) -> std::io::Result<()> { + if let Some(mut child) = self.child.inner.take() { + self.transport.close().await?; + + let wait_fut = child.wait(); + tokio::select! { + _ = tokio::time::sleep(std::time::Duration::from_secs(MAX_WAIT_ON_DROP_SECS)) => { + if let Err(e) = Box::into_pin(child.kill()).await { + tracing::warn!("Error killing child: {e}"); + return Err(e); + } + }, + res = wait_fut => { + match res { + Ok(status) => { + tracing::info!("Child exited gracefully {}", status); + } + Err(e) => { + tracing::warn!("Error waiting for child: {e}"); + return Err(e); + } + } + } + } + } + Ok(()) + } + + /// Take ownership of the inner child process + pub fn into_inner(mut self) -> Option> { + self.child.inner.take() + } +} + +/// Builder for `TokioChildProcess` allowing custom `Stdio` configuration. +pub struct TokioChildProcessBuilder { + cmd: CommandWrap, + stdin: Stdio, + stdout: Stdio, + stderr: Stdio, +} + +impl TokioChildProcessBuilder { + fn new(cmd: impl Into) -> Self { + Self { + cmd: cmd.into(), + stdin: Stdio::piped(), + stdout: Stdio::piped(), + stderr: Stdio::inherit(), + } + } + + /// Override the child stdin configuration. + pub fn stdin(mut self, io: impl Into) -> Self { + self.stdin = io.into(); + self + } + /// Override the child stdout configuration. + pub fn stdout(mut self, io: impl Into) -> Self { + self.stdout = io.into(); + self + } + /// Override the child stderr configuration. + pub fn stderr(mut self, io: impl Into) -> Self { + self.stderr = io.into(); + self + } + + /// Spawn the child process. Returns the transport plus an optional captured stderr handle. + pub fn spawn(mut self) -> std::io::Result<(TokioChildProcess, Option)> { + self.cmd + .command_mut() + .stdin(self.stdin) + .stdout(self.stdout) + .stderr(self.stderr); + + let (child, stdout, stdin, stderr_opt) = child_process(self.cmd.spawn()?)?; + + let transport = AsyncRwTransport::new(stdout, stdin); + let proc = TokioChildProcess { + child: ChildWithCleanup { inner: Some(child) }, + transport, + }; + Ok((proc, stderr_opt)) + } +} + +impl Transport for TokioChildProcess { + type Error = std::io::Error; + + fn send( + &mut self, + item: TxJsonRpcMessage, + ) -> impl Future> + Send + 'static { + self.transport.send(item) + } + + fn receive(&mut self) -> impl Future>> + Send { + self.transport.receive() + } + + fn close(&mut self) -> impl Future> + Send { + self.graceful_shutdown() + } +} + +pub trait ConfigureCommandExt { + fn configure(self, f: impl FnOnce(&mut Self)) -> Self; +} + +impl ConfigureCommandExt for tokio::process::Command { + fn configure(mut self, f: impl FnOnce(&mut Self)) -> Self { + f(&mut self); + self + } +} + +/// Resolve the absolute path to an executable using the system `PATH`, +/// then return a [`tokio::process::Command`] pointing at it. +/// +/// This is especially useful on Windows where `.cmd` / `.exe` shim scripts +/// (e.g. `npx.cmd`) are not reliably found by [`tokio::process::Command`] +/// without a fully-qualified path. +/// +/// # Example +/// ```rust,no_run +/// use rmcp::transport::{which_command, ConfigureCommandExt}; +/// +/// # fn example() -> std::io::Result<()> { +/// let cmd = which_command("npx")? +/// .configure(|cmd| { +/// cmd.arg("-y").arg("@modelcontextprotocol/server-everything"); +/// }); +/// # Ok(()) +/// # } +/// ``` +#[cfg(feature = "which-command")] +pub fn which_command( + name: impl AsRef, +) -> std::io::Result { + let resolved = which::which(name.as_ref()) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, e))?; + Ok(tokio::process::Command::new(resolved)) +} + +#[cfg(feature = "which-command")] +#[cfg(test)] +mod tests_which { + #[test] + fn which_command_resolves_known_binary() { + // `ls` exists on every Unix system, `cmd` on Windows + #[cfg(unix)] + let result = super::which_command("ls"); + #[cfg(windows)] + let result = super::which_command("cmd"); + + assert!(result.is_ok()); + } + + #[test] + fn which_command_fails_for_nonexistent() { + let result = super::which_command("this_binary_definitely_does_not_exist_12345"); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::NotFound); + } +} + +#[cfg(unix)] +#[cfg(test)] +mod tests { + use tokio::process::Command; + + use super::*; + + #[tokio::test] + async fn test_tokio_child_process_drop() { + let r = TokioChildProcess::new(Command::new("sleep").configure(|cmd| { + cmd.arg("30"); + })); + assert!(r.is_ok()); + let child_process = r.unwrap(); + let id = child_process.id(); + assert!(id.is_some()); + let id = id.unwrap(); + // Drop the child process + drop(child_process); + // Wait a moment to allow the cleanup task to run + tokio::time::sleep(std::time::Duration::from_secs(MAX_WAIT_ON_DROP_SECS + 1)).await; + // Check if the process is still running + let status = Command::new("ps") + .arg("-p") + .arg(id.to_string()) + .status() + .await; + match status { + Ok(status) => { + assert!( + !status.success(), + "Process with PID {} is still running", + id + ); + } + Err(e) => { + panic!("Failed to check process status: {}", e); + } + } + } + + #[tokio::test] + async fn test_tokio_child_process_graceful_shutdown() { + let r = TokioChildProcess::new(Command::new("sleep").configure(|cmd| { + cmd.arg("30"); + })); + assert!(r.is_ok()); + let mut child_process = r.unwrap(); + let id = child_process.id(); + assert!(id.is_some()); + let id = id.unwrap(); + child_process.graceful_shutdown().await.unwrap(); + // Wait a moment to allow the cleanup task to run + tokio::time::sleep(std::time::Duration::from_secs(MAX_WAIT_ON_DROP_SECS + 1)).await; + // Check if the process is still running + let status = Command::new("ps") + .arg("-p") + .arg(id.to_string()) + .status() + .await; + match status { + Ok(status) => { + assert!( + !status.success(), + "Process with PID {} is still running", + id + ); + } + Err(e) => { + panic!("Failed to check process status: {}", e); + } + } + } +} diff --git a/vendor/rmcp/src/transport/common.rs b/vendor/rmcp/src/transport/common.rs new file mode 100644 index 000000000..8cf00c02f --- /dev/null +++ b/vendor/rmcp/src/transport/common.rs @@ -0,0 +1,22 @@ +#[cfg(feature = "transport-streamable-http-server")] +pub mod server_side_http; + +pub mod http_header; + +#[cfg(any(feature = "client-side-sse", feature = "server-side-http"))] +pub mod mcp_headers; + +#[cfg(feature = "__reqwest")] +mod reqwest; + +// Note: This module provides SSE stream parsing and auto-reconnect utilities. +// It's used by the streamable HTTP client (which receives SSE-formatted responses), +// not the removed SSE transport. The name is historical. +#[cfg(feature = "client-side-sse")] +pub mod client_side_sse; + +#[cfg(feature = "auth")] +pub mod auth; + +#[cfg(all(unix, feature = "transport-streamable-http-client-unix-socket"))] +pub mod unix_socket; diff --git a/vendor/rmcp/src/transport/common/auth.rs b/vendor/rmcp/src/transport/common/auth.rs new file mode 100644 index 000000000..6068f4e95 --- /dev/null +++ b/vendor/rmcp/src/transport/common/auth.rs @@ -0,0 +1,2 @@ +#[cfg(feature = "transport-streamable-http-client")] +mod streamable_http_client; diff --git a/vendor/rmcp/src/transport/common/auth/streamable_http_client.rs b/vendor/rmcp/src/transport/common/auth/streamable_http_client.rs new file mode 100644 index 000000000..2053920d2 --- /dev/null +++ b/vendor/rmcp/src/transport/common/auth/streamable_http_client.rs @@ -0,0 +1,210 @@ +use std::collections::HashMap; + +use http::{HeaderName, HeaderValue}; +use tracing::debug; + +use crate::transport::{ + auth::{AuthClient, AuthError}, + streamable_http_client::{StreamableHttpClient, StreamableHttpError}, +}; + +impl AuthClient +where + C: StreamableHttpClient + Send + Sync, +{ + /// Run `call` with a token when one is available, reacting to the + /// server's auth verdict instead of requiring credentials up front: + /// + /// - no usable credentials → the request goes out unauthenticated, and a + /// 401 propagates as [`StreamableHttpError::AuthRequired`] carrying the + /// `WWW-Authenticate` challenge for the caller to authorize with; + /// - a token the server rejects (e.g. revoked) → one silent refresh, one + /// retry, then the challenge propagates. + async fn call_reacting_to_challenges( + &self, + auth_token: Option, + call: F, + ) -> Result> + where + F: Fn(Option) -> Fut, + Fut: Future>>, + { + // Missing credentials are not an error in the reactive model: the + // request goes out unauthenticated and the server's 401 challenge + // drives authorization. + let auth_token = match auth_token { + None => match self.get_access_token().await { + Ok(token) => Some(token), + Err(AuthError::AuthorizationRequired) => None, + Err(error) => return Err(error.into()), + }, + token => token, + }; + match call(auth_token.clone()).await { + Err(StreamableHttpError::AuthRequired(challenge)) => { + // One silent recovery attempt: refresh the rejected token and + // retry only when the refresh actually produced a new one. + let Some(sent_token) = auth_token else { + return Err(StreamableHttpError::AuthRequired(challenge)); + }; + let refreshed = { + let manager = self.auth_manager.lock().await; + manager.try_refresh_or_reauth().await + }; + match refreshed { + Ok(fresh_token) if fresh_token != sent_token => call(Some(fresh_token)).await, + Ok(_) => Err(StreamableHttpError::AuthRequired(challenge)), + Err(error) => { + debug!("token refresh after server rejection failed: {error}"); + Err(StreamableHttpError::AuthRequired(challenge)) + } + } + } + result => result, + } + } +} + +impl StreamableHttpClient for AuthClient +where + C: StreamableHttpClient + Send + Sync, +{ + type Error = C::Error; + + async fn delete_session( + &self, + uri: std::sync::Arc, + session_id: std::sync::Arc, + auth_token: Option, + custom_headers: HashMap, + ) -> Result<(), crate::transport::streamable_http_client::StreamableHttpError> + { + self.call_reacting_to_challenges(auth_token, |token| { + let uri = uri.clone(); + let session_id = session_id.clone(); + let custom_headers = custom_headers.clone(); + async move { + self.http_client + .delete_session(uri, session_id, token, custom_headers) + .await + } + }) + .await + } + + async fn get_stream( + &self, + uri: std::sync::Arc, + session_id: Option>, + last_event_id: Option, + auth_token: Option, + custom_headers: HashMap, + ) -> Result< + futures::stream::BoxStream<'static, Result>, + crate::transport::streamable_http_client::StreamableHttpError, + > { + self.call_reacting_to_challenges(auth_token, |token| { + let uri = uri.clone(); + let session_id = session_id.clone(); + let last_event_id = last_event_id.clone(); + let custom_headers = custom_headers.clone(); + async move { + self.http_client + .get_stream(uri, session_id, last_event_id, token, custom_headers) + .await + } + }) + .await + } + + async fn get_stream_with_max_sse_event_size( + &self, + uri: std::sync::Arc, + session_id: Option>, + last_event_id: Option, + auth_token: Option, + custom_headers: HashMap, + max_sse_event_size: usize, + ) -> Result< + futures::stream::BoxStream<'static, Result>, + crate::transport::streamable_http_client::StreamableHttpError, + > { + self.call_reacting_to_challenges(auth_token, |token| { + let uri = uri.clone(); + let session_id = session_id.clone(); + let last_event_id = last_event_id.clone(); + let custom_headers = custom_headers.clone(); + async move { + self.http_client + .get_stream_with_max_sse_event_size( + uri, + session_id, + last_event_id, + token, + custom_headers, + max_sse_event_size, + ) + .await + } + }) + .await + } + + async fn post_message( + &self, + uri: std::sync::Arc, + message: crate::model::ClientJsonRpcMessage, + session_id: Option>, + auth_token: Option, + custom_headers: HashMap, + ) -> Result< + crate::transport::streamable_http_client::StreamableHttpPostResponse, + StreamableHttpError, + > { + self.call_reacting_to_challenges(auth_token, |token| { + let uri = uri.clone(); + let message = message.clone(); + let session_id = session_id.clone(); + let custom_headers = custom_headers.clone(); + async move { + self.http_client + .post_message(uri, message, session_id, token, custom_headers) + .await + } + }) + .await + } + + async fn post_message_with_max_sse_event_size( + &self, + uri: std::sync::Arc, + message: crate::model::ClientJsonRpcMessage, + session_id: Option>, + auth_token: Option, + custom_headers: HashMap, + max_sse_event_size: usize, + ) -> Result< + crate::transport::streamable_http_client::StreamableHttpPostResponse, + StreamableHttpError, + > { + self.call_reacting_to_challenges(auth_token, |token| { + let uri = uri.clone(); + let message = message.clone(); + let session_id = session_id.clone(); + let custom_headers = custom_headers.clone(); + async move { + self.http_client + .post_message_with_max_sse_event_size( + uri, + message, + session_id, + token, + custom_headers, + max_sse_event_size, + ) + .await + } + }) + .await + } +} diff --git a/vendor/rmcp/src/transport/common/client_side_sse.rs b/vendor/rmcp/src/transport/common/client_side_sse.rs new file mode 100644 index 000000000..4ed05b44f --- /dev/null +++ b/vendor/rmcp/src/transport/common/client_side_sse.rs @@ -0,0 +1,738 @@ +use std::{ + pin::Pin, + sync::Arc, + task::{Poll, ready}, + time::Duration, +}; + +use bytes::Bytes; +use futures::{Stream, StreamExt, stream::BoxStream}; +use sse_stream::{Error as SseError, Sse, SseStream}; +use thiserror::Error; + +use crate::model::ServerJsonRpcMessage; + +pub type BoxedSseResponse = BoxStream<'static, Result>; + +/// Maximum raw size of one SSE event accepted from a remote server. +pub(crate) const DEFAULT_MAX_SSE_EVENT_SIZE: usize = 16 * 1024 * 1024; + +#[derive(Debug, Error)] +enum BoundedSseStreamError { + #[error(transparent)] + Source(Box), + #[error("SSE event exceeded the maximum size of {max_size} bytes")] + EventTooLarge { max_size: usize }, +} + +#[derive(Debug)] +struct SseEventSizeLimiter { + max_size: usize, + retained_size: usize, + line_size: usize, + line_is_comment: bool, + previous_was_cr: bool, +} + +impl SseEventSizeLimiter { + fn new(max_size: usize) -> Self { + Self { + max_size, + retained_size: 0, + line_size: 0, + line_is_comment: false, + previous_was_cr: false, + } + } + + fn observe(&mut self, chunk: &[u8]) -> Result<(), ()> { + for &byte in chunk { + if self.previous_was_cr { + self.previous_was_cr = false; + if byte == b'\n' { + continue; + } + } + + match byte { + b'\r' => { + self.finish_line()?; + self.previous_was_cr = true; + } + b'\n' => self.finish_line()?, + _ => { + if self.line_size == 0 { + self.line_is_comment = byte == b':'; + } + self.line_size = self.line_size.saturating_add(1); + self.check_limit()?; + } + } + } + Ok(()) + } + + fn finish_line(&mut self) -> Result<(), ()> { + if self.line_size == 0 { + self.retained_size = 0; + } else if !self.line_is_comment { + // The SSE parser inserts a newline when joining multiple data fields. + self.retained_size = self + .retained_size + .saturating_add(self.line_size) + .saturating_add(1); + } + self.line_size = 0; + self.line_is_comment = false; + self.check_limit() + } + + fn check_limit(&self) -> Result<(), ()> { + if self.retained_size.saturating_add(self.line_size) > self.max_size { + Err(()) + } else { + Ok(()) + } + } +} + +pin_project_lite::pin_project! { + struct BoundedSseByteStream { + #[pin] + inner: S, + limiter: SseEventSizeLimiter, + failed: bool, + } +} + +impl Stream for BoundedSseByteStream +where + S: Stream>, + E: std::error::Error + Send + Sync + 'static, +{ + type Item = Result; + + fn poll_next( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + let mut this = self.project(); + if *this.failed { + return Poll::Ready(None); + } + + match ready!(this.inner.as_mut().poll_next(cx)) { + Some(Ok(chunk)) => { + if this.limiter.observe(&chunk).is_err() { + *this.failed = true; + Poll::Ready(Some(Err(BoundedSseStreamError::EventTooLarge { + max_size: this.limiter.max_size, + }))) + } else { + Poll::Ready(Some(Ok(chunk))) + } + } + Some(Err(error)) => { + *this.failed = true; + Poll::Ready(Some(Err(BoundedSseStreamError::Source(Box::new(error))))) + } + None => Poll::Ready(None), + } + } +} + +pub(crate) fn bounded_sse_stream(stream: S, max_event_size: usize) -> BoxedSseResponse +where + S: Stream> + Send + 'static, + E: std::error::Error + Send + Sync + 'static, +{ + let stream = BoundedSseByteStream { + inner: stream, + limiter: SseEventSizeLimiter::new(max_event_size), + failed: false, + }; + SseStream::from_bytes_stream(stream).boxed() +} + +fn is_event_too_large_error(error: &SseError) -> bool { + matches!( + error, + SseError::Body(error) + if matches!( + error.downcast_ref::(), + Some(BoundedSseStreamError::EventTooLarge { .. }) + ) + ) +} + +pub trait SseRetryPolicy: std::fmt::Debug + Send + Sync { + fn retry(&self, current_times: usize) -> Option; +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct FixedInterval { + pub max_times: Option, + pub duration: Duration, +} + +impl SseRetryPolicy for FixedInterval { + fn retry(&self, current_times: usize) -> Option { + if let Some(max_times) = self.max_times + && current_times >= max_times + { + return None; + } + Some(self.duration) + } +} + +impl FixedInterval { + pub const DEFAULT_MIN_DURATION: Duration = Duration::from_millis(1000); +} + +impl Default for FixedInterval { + fn default() -> Self { + Self { + max_times: None, + duration: Self::DEFAULT_MIN_DURATION, + } + } +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct ExponentialBackoff { + pub max_times: Option, + pub base_duration: Duration, +} + +impl ExponentialBackoff { + pub const DEFAULT_DURATION: Duration = Duration::from_millis(1000); +} + +impl Default for ExponentialBackoff { + fn default() -> Self { + Self { + max_times: None, + base_duration: Self::DEFAULT_DURATION, + } + } +} + +impl SseRetryPolicy for ExponentialBackoff { + fn retry(&self, current_times: usize) -> Option { + if let Some(max_times) = self.max_times + && current_times >= max_times + { + return None; + } + Some(self.base_duration * (2u32.pow(current_times as u32))) + } +} + +#[derive(Debug, Clone, Copy, Default)] +#[non_exhaustive] +pub struct NeverRetry; + +impl SseRetryPolicy for NeverRetry { + fn retry(&self, _current_times: usize) -> Option { + None + } +} + +#[derive(Debug, Default)] +pub struct NeverReconnect { + error: Option, +} + +impl SseStreamReconnect for NeverReconnect { + type Error = E; + type Future = futures::future::Ready>; + fn retry_connection(&mut self, _last_event_id: Option<&str>) -> Self::Future { + futures::future::ready(Err(self.error.take().expect("should not be called again"))) + } +} + +/// Abstraction for SSE reconnection logic. Implementors can hook into +/// [`handle_control_event`](Self::handle_control_event) to consume control +/// frames (e.g. `event: endpoint`) that arrive when a server restarts an SSE +/// stream. The default implementation is a no-op, keeping existing behaviour +/// intact. +pub(crate) trait SseStreamReconnect { + type Error: std::error::Error; + type Future: Future> + Send; + fn retry_connection(&mut self, last_event_id: Option<&str>) -> Self::Future; + fn handle_control_event(&mut self, _event: &Sse) -> Result<(), Self::Error> { + Ok(()) + } + fn handle_stream_error( + &mut self, + error: &(dyn std::error::Error + 'static), + last_event_id: Option<&str>, + ) { + if let Some(id) = last_event_id { + tracing::warn!(%id, "sse stream error: {error}"); + } else { + tracing::warn!("sse stream error: {error}"); + } + } + fn map_fatal_stream_error(&mut self, error: SseError) -> Option { + tracing::warn!("fatal sse stream error: {error}"); + None + } +} + +pin_project_lite::pin_project! { + pub(crate) struct SseAutoReconnectStream + where R: SseStreamReconnect + { + retry_policy: Arc, + reconnect_only_after_event_id: bool, + last_event_id: Option, + server_retry_interval: Option, + connector: R, + #[pin] + state: SseAutoReconnectStreamState, + } +} + +impl SseAutoReconnectStream { + pub fn new( + stream: BoxedSseResponse, + connector: R, + retry_policy: Arc, + ) -> Self { + Self { + retry_policy, + reconnect_only_after_event_id: false, + last_event_id: None, + server_retry_interval: None, + connector, + state: SseAutoReconnectStreamState::Connected { stream }, + } + } + + pub fn new_after_event_id( + stream: BoxedSseResponse, + connector: R, + retry_policy: Arc, + ) -> Self { + Self { + retry_policy, + reconnect_only_after_event_id: true, + last_event_id: None, + server_retry_interval: None, + connector, + state: SseAutoReconnectStreamState::Connected { stream }, + } + } +} + +impl SseAutoReconnectStream> { + #[allow(dead_code)] + pub(crate) fn never_reconnect(stream: BoxedSseResponse, error_when_reconnect: E) -> Self { + Self { + retry_policy: Arc::new(NeverRetry), + reconnect_only_after_event_id: false, + last_event_id: None, + server_retry_interval: None, + connector: NeverReconnect { + error: Some(error_when_reconnect), + }, + state: SseAutoReconnectStreamState::Connected { stream }, + } + } +} + +pin_project_lite::pin_project! { + #[project = SseAutoReconnectStreamStateProj] + #[non_exhaustive] + pub enum SseAutoReconnectStreamState { + Connected { + #[pin] + stream: BoxedSseResponse, + }, + Retrying { + retry_times: usize, + #[pin] + retrying: F, + }, + WaitingNextRetry { + #[pin] + sleep: tokio::time::Sleep, + retry_times: usize, + }, + Terminated, + } +} + +impl Stream for SseAutoReconnectStream +where + R: SseStreamReconnect, +{ + type Item = Result; + fn poll_next( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + let mut this = self.as_mut().project(); + // let this_state = this.state.as_mut().project() + let state = this.state.as_mut().project(); + let next_state = match state { + SseAutoReconnectStreamStateProj::Connected { stream } => { + match ready!(stream.poll_next(cx)) { + Some(Ok(sse)) => { + if let Some(new_server_retry) = sse.retry { + *this.server_retry_interval = + Some(Duration::from_millis(new_server_retry)); + } + if let Some(ref event_id) = sse.id { + *this.last_event_id = Some(event_id.clone()); + } + // Only treat blank/`message` events as JSON-RPC payloads. + // Other control frames (endpoint, ping, etc.) are passed to + // the reconnection handler. + let is_message_event = + matches!(sse.event.as_deref(), None | Some("") | Some("message")); + if !is_message_event { + match this.connector.handle_control_event(&sse) { + Ok(()) => return self.poll_next(cx), + Err(e) => { + this.state.set(SseAutoReconnectStreamState::Terminated); + return Poll::Ready(Some(Err(e))); + } + } + } + if let Some(data) = sse.data { + match serde_json::from_str::(&data) { + Err(e) => { + // Downgrade to debug to avoid noisy logs when servers emit + // non-JSON payloads as message frames. Include last_event_id + // to aid troubleshooting while keeping default behaviour. + let last_id = this.last_event_id.as_deref().unwrap_or(""); + tracing::debug!(last_event_id=%last_id, "failed to deserialize server message: {e}"); + return self.poll_next(cx); + } + Ok(message) => { + return Poll::Ready(Some(Ok(message))); + } + }; + } else { + return self.poll_next(cx); + } + } + Some(Err(e)) => { + if is_event_too_large_error(&e) { + this.state.set(SseAutoReconnectStreamState::Terminated); + return Poll::Ready(this.connector.map_fatal_stream_error(e).map(Err)); + } + if *this.reconnect_only_after_event_id && this.last_event_id.is_none() { + this.state.set(SseAutoReconnectStreamState::Terminated); + return Poll::Ready(this.connector.map_fatal_stream_error(e).map(Err)); + } + this.connector + .handle_stream_error(&e, this.last_event_id.as_deref()); + let retrying = this + .connector + .retry_connection(this.last_event_id.as_deref()); + SseAutoReconnectStreamState::Retrying { + retry_times: 0, + retrying, + } + } + None => { + if *this.reconnect_only_after_event_id && this.last_event_id.is_none() { + tracing::debug!( + "sse response ended before an event ID was received; cannot resume" + ); + this.state.set(SseAutoReconnectStreamState::Terminated); + return Poll::Ready(None); + } + // Per SEP-1699, a graceful stream close is + // reconnectable. If the server sent a `retry` field + // we MUST wait that long before reconnecting. + let interval = this + .server_retry_interval + .take() + .or_else(|| this.retry_policy.retry(0)); + if let Some(interval) = interval { + tracing::debug!(?interval, "sse stream ended gracefully, reconnecting"); + SseAutoReconnectStreamState::WaitingNextRetry { + sleep: tokio::time::sleep(interval), + retry_times: 0, + } + } else { + tracing::debug!("sse stream terminated, no reconnect policy"); + return Poll::Ready(None); + } + } + } + } + SseAutoReconnectStreamStateProj::Retrying { + retry_times, + retrying, + } => { + let retry_result = ready!(retrying.poll(cx)); + match retry_result { + Ok(new_stream) => SseAutoReconnectStreamState::Connected { stream: new_stream }, + Err(e) => { + tracing::debug!("retry sse stream error: {e}"); + *retry_times += 1; + if let Some(interval) = this.retry_policy.retry(*retry_times) { + let interval = this + .server_retry_interval + .map(|server_retry_interval| server_retry_interval.max(interval)) + .unwrap_or(interval); + let sleep = tokio::time::sleep(interval); + SseAutoReconnectStreamState::WaitingNextRetry { + sleep, + retry_times: *retry_times, + } + } else { + tracing::error!("sse stream error: {e}, max retry times reached"); + this.state.set(SseAutoReconnectStreamState::Terminated); + return Poll::Ready(Some(Err(e))); + } + } + } + } + SseAutoReconnectStreamStateProj::WaitingNextRetry { sleep, retry_times } => { + ready!(sleep.poll(cx)); + let retrying = this + .connector + .retry_connection(this.last_event_id.as_deref()); + let retry_times = *retry_times; + SseAutoReconnectStreamState::Retrying { + retry_times, + retrying, + } + } + SseAutoReconnectStreamStateProj::Terminated => { + return Poll::Ready(None); + } + }; + // update the state + this.state.set(next_state); + self.poll_next(cx) + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + #[derive(Debug, Error)] + enum TestReconnectError { + #[error("SSE stream error: {0}")] + Sse(SseError), + #[error("unexpected reconnect")] + Reconnect, + } + + struct CountingReconnect { + attempts: Arc, + } + + impl SseStreamReconnect for CountingReconnect { + type Error = TestReconnectError; + type Future = futures::future::Ready>; + + fn retry_connection(&mut self, _last_event_id: Option<&str>) -> Self::Future { + self.attempts.fetch_add(1, Ordering::Relaxed); + futures::future::ready(Err(TestReconnectError::Reconnect)) + } + + fn map_fatal_stream_error(&mut self, error: SseError) -> Option { + Some(TestReconnectError::Sse(error)) + } + } + + #[tokio::test] + async fn bounded_sse_stream_rejects_unterminated_event_over_limit() { + let source = futures::stream::iter([ + Ok::<_, std::io::Error>(Bytes::from_static(b"data: aaaaa")), + Ok(Bytes::from_static(b"aaaaaa")), + ]); + let mut stream = bounded_sse_stream(source, 16); + + let error = stream.next().await.unwrap().unwrap_err(); + + assert!( + error.to_string().contains("maximum size of 16 bytes"), + "unexpected error: {error}" + ); + } + + #[tokio::test] + async fn bounded_sse_stream_resets_limit_after_event_terminator() { + let source = futures::stream::iter([ + Ok::<_, std::io::Error>(Bytes::from_static(b"data: a\r")), + Ok(Bytes::from_static(b"\n\r\ndata: b\n\n")), + ]); + let mut stream = bounded_sse_stream(source, 8); + + let first = stream.next().await.unwrap().unwrap(); + let second = stream.next().await.unwrap().unwrap(); + + assert_eq!( + (first.data.as_deref(), second.data.as_deref()), + (Some("a"), Some("b")) + ); + } + + #[tokio::test] + async fn bounded_sse_stream_passes_event_at_exact_limit() { + let source = + futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static(b"data: ab\n\n"))]); + let mut stream = bounded_sse_stream(source, 9); + + let event = stream.next().await.unwrap().unwrap(); + assert_eq!(event.data.as_deref(), Some("ab")); + } + + #[tokio::test] + async fn bounded_sse_stream_rejects_oversize_split_across_many_chunks() { + let source = futures::stream::iter( + std::iter::repeat_with(|| Ok::<_, std::io::Error>(Bytes::from_static(b"data: x"))) + .take(20), + ); + let mut stream = bounded_sse_stream(source, 32); + + let mut found_error = false; + while let Some(item) = stream.next().await { + if item.is_err() { + found_error = true; + break; + } + } + assert!(found_error, "expected oversize error"); + } + + #[tokio::test] + async fn bounded_sse_stream_handles_crlf_split_across_chunks() { + let source = futures::stream::iter([ + Ok::<_, std::io::Error>(Bytes::from_static(b"data: hello\r")), + Ok(Bytes::from_static(b"\n\ndata: world\n\n")), + ]); + let mut stream = bounded_sse_stream(source, 64); + + let first = stream.next().await.unwrap().unwrap(); + let second = stream.next().await.unwrap().unwrap(); + + assert_eq!(first.data.as_deref(), Some("hello")); + assert_eq!(second.data.as_deref(), Some("world")); + } + + #[tokio::test] + async fn bounded_sse_stream_discards_completed_comment_lines_from_limit() { + let source = futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static( + b": ping\n: ping\n: ping\n", + ))]); + let mut stream = bounded_sse_stream(source, 6); + + assert!(stream.next().await.is_none()); + } + + #[tokio::test] + async fn bounded_sse_stream_comments_do_not_reset_accumulated_data() { + let source = futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static( + b"data: a\n: ping\ndata: b\n", + ))]); + let mut stream = bounded_sse_stream(source, 14); + + assert!(stream.next().await.unwrap().is_err()); + } + + #[tokio::test] + async fn bounded_sse_stream_counts_multiline_data_join_newlines() { + let source = futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static( + b"data: aaa\ndata: bbb\n\n", + ))]); + let mut stream = bounded_sse_stream(source, 18); + + let error = stream.next().await.unwrap().unwrap_err(); + assert!( + error.to_string().contains("maximum size"), + "unexpected error: {error}" + ); + } + + #[tokio::test] + async fn bounded_sse_stream_propagates_source_error() { + let source = futures::stream::iter([Err::(std::io::Error::new( + std::io::ErrorKind::ConnectionReset, + "connection reset", + ))]); + let mut stream = bounded_sse_stream(source, 1024); + + let error = stream.next().await.unwrap().unwrap_err(); + assert!( + error.to_string().contains("connection reset"), + "unexpected error: {error}" + ); + } + + #[tokio::test] + async fn is_event_too_large_error_detects_oversize() { + let source = futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from(vec![b'A'; 100]))]); + let mut stream = bounded_sse_stream(source, 8); + + let error = stream.next().await.unwrap().unwrap_err(); + assert!(is_event_too_large_error(&error)); + } + + #[tokio::test] + async fn is_event_too_large_error_rejects_other_errors() { + let source = + futures::stream::iter([Err::(std::io::Error::other("something else"))]); + let mut stream = bounded_sse_stream(source, 1024); + + let error = stream.next().await.unwrap().unwrap_err(); + assert!(!is_event_too_large_error(&error)); + } + + #[tokio::test] + async fn oversized_event_returns_error_without_reconnecting() { + let source = futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from(vec![b'A'; 100]))]); + let attempts = Arc::new(AtomicUsize::new(0)); + let connector = CountingReconnect { + attempts: attempts.clone(), + }; + let stream = SseAutoReconnectStream::new( + bounded_sse_stream(source, 8), + connector, + Arc::new(NeverRetry), + ); + let mut stream = std::pin::pin!(stream); + + let result = stream.next().await; + + assert!( + matches!(result, Some(Err(TestReconnectError::Sse(_)))) + && attempts.load(Ordering::Relaxed) == 0 + ); + } + + #[tokio::test] + async fn response_without_event_id_does_not_reconnect() { + let attempts = Arc::new(AtomicUsize::new(0)); + let connector = CountingReconnect { + attempts: attempts.clone(), + }; + let stream = SseAutoReconnectStream::new_after_event_id( + futures::stream::empty().boxed(), + connector, + Arc::new(FixedInterval { + max_times: Some(1), + duration: Duration::ZERO, + }), + ); + let mut stream = std::pin::pin!(stream); + + assert!(stream.next().await.is_none()); + assert_eq!(attempts.load(Ordering::Relaxed), 0); + } +} diff --git a/vendor/rmcp/src/transport/common/http_header.rs b/vendor/rmcp/src/transport/common/http_header.rs new file mode 100644 index 000000000..6c2abbc8d --- /dev/null +++ b/vendor/rmcp/src/transport/common/http_header.rs @@ -0,0 +1,140 @@ +pub const HEADER_SESSION_ID: &str = "Mcp-Session-Id"; +pub const HEADER_LAST_EVENT_ID: &str = "Last-Event-Id"; +pub const HEADER_MCP_PROTOCOL_VERSION: &str = "MCP-Protocol-Version"; +pub const EVENT_STREAM_MIME_TYPE: &str = "text/event-stream"; +pub const JSON_MIME_TYPE: &str = "application/json"; + +// SEP-2243 standard headers, gated on protocol version >= 2026-07-28. +pub const HEADER_MCP_METHOD: &str = "Mcp-Method"; +pub const HEADER_MCP_NAME: &str = "Mcp-Name"; +pub const HEADER_MCP_PARAM_PREFIX: &str = "Mcp-Param-"; + +/// Sentinel wrapping a Base64-encoded SEP-2243 header value (`=?base64??=`). +pub const BASE64_HEADER_PREFIX: &str = "=?base64?"; +pub const BASE64_HEADER_SUFFIX: &str = "?="; + +/// Reserved headers that must not be overridden by user-supplied custom headers. +/// `MCP-Protocol-Version` is in this list but is allowed through because the worker +/// injects it after initialization. +#[allow(dead_code)] +pub(crate) const RESERVED_HEADERS: &[&str] = &[ + "accept", + HEADER_SESSION_ID, + HEADER_MCP_PROTOCOL_VERSION, // allowed through by validate_custom_header; worker injects it post-init + HEADER_LAST_EVENT_ID, +]; + +/// Checks whether a custom header name is allowed. +/// Returns `Ok(())` if allowed, `Err(name)` if rejected as reserved. +/// `MCP-Protocol-Version` is reserved but allowed through (the worker injects it post-init). +#[cfg(feature = "client-side-sse")] +pub(crate) fn validate_custom_header(name: &http::HeaderName) -> Result<(), String> { + if RESERVED_HEADERS + .iter() + .any(|&r| name.as_str().eq_ignore_ascii_case(r)) + { + if name + .as_str() + .eq_ignore_ascii_case(HEADER_MCP_PROTOCOL_VERSION) + { + return Ok(()); + } + return Err(name.to_string()); + } + Ok(()) +} + +/// Extracts the `scope=` parameter from a `WWW-Authenticate` header value. +/// Handles both quoted (`scope="files:read files:write"`) and unquoted (`scope=read:data`) forms. +#[cfg(feature = "client-side-sse")] +pub(crate) fn extract_scope_from_header(header: &str) -> Option { + let header_lowercase = header.to_ascii_lowercase(); + let scope_key = "scope="; + + if let Some(pos) = header_lowercase.find(scope_key) { + let start = pos + scope_key.len(); + let value_slice = &header[start..]; + + if let Some(stripped) = value_slice.strip_prefix('"') { + if let Some(end_quote) = stripped.find('"') { + return Some(stripped[..end_quote].to_string()); + } + } else { + let end = value_slice + .find(|c: char| c == ',' || c == ';' || c.is_whitespace()) + .unwrap_or(value_slice.len()); + if end > 0 { + return Some(value_slice[..end].to_string()); + } + } + } + + None +} + +#[cfg(test)] +mod tests { + #[cfg(feature = "client-side-sse")] + use super::*; + + #[cfg(feature = "client-side-sse")] + #[test] + fn extract_scope_quoted() { + let header = r#"Bearer error="insufficient_scope", scope="files:read files:write""#; + assert_eq!( + extract_scope_from_header(header), + Some("files:read files:write".to_string()) + ); + } + + #[cfg(feature = "client-side-sse")] + #[test] + fn extract_scope_unquoted() { + let header = r#"Bearer scope=read:data, error="insufficient_scope""#; + assert_eq!( + extract_scope_from_header(header), + Some("read:data".to_string()) + ); + } + + #[cfg(feature = "client-side-sse")] + #[test] + fn extract_scope_missing() { + let header = r#"Bearer error="invalid_token""#; + assert_eq!(extract_scope_from_header(header), None); + } + + #[cfg(feature = "client-side-sse")] + #[test] + fn extract_scope_empty_header() { + assert_eq!(extract_scope_from_header("Bearer"), None); + } + + #[cfg(feature = "client-side-sse")] + #[test] + fn validate_rejects_reserved_accept() { + let name = http::HeaderName::from_static("accept"); + assert!(validate_custom_header(&name).is_err()); + } + + #[cfg(feature = "client-side-sse")] + #[test] + fn validate_rejects_reserved_session_id() { + let name = http::HeaderName::from_static("mcp-session-id"); + assert!(validate_custom_header(&name).is_err()); + } + + #[cfg(feature = "client-side-sse")] + #[test] + fn validate_allows_mcp_protocol_version() { + let name = http::HeaderName::from_static("mcp-protocol-version"); + assert!(validate_custom_header(&name).is_ok()); + } + + #[cfg(feature = "client-side-sse")] + #[test] + fn validate_allows_custom_header() { + let name = http::HeaderName::from_static("x-custom"); + assert!(validate_custom_header(&name).is_ok()); + } +} diff --git a/vendor/rmcp/src/transport/common/mcp_headers.rs b/vendor/rmcp/src/transport/common/mcp_headers.rs new file mode 100644 index 000000000..cc5506857 --- /dev/null +++ b/vendor/rmcp/src/transport/common/mcp_headers.rs @@ -0,0 +1,658 @@ +//! SEP-2243 HTTP header standardization. +//! +//! Builds and validates the `Mcp-Method`, `Mcp-Name`, and `Mcp-Param-*` headers +//! so middle boxes can route Streamable HTTP traffic without parsing the body. +//! All emission/validation is gated by the negotiated protocol version +//! (`>= ProtocolVersion::STANDARD_HEADERS`) at the call sites. + +// Which helpers are reachable depends on the client/server feature combination, +// mirroring `server_side_http`. +#![allow(dead_code)] + +use serde_json::Value; + +use super::http_header::{ + BASE64_HEADER_PREFIX, BASE64_HEADER_SUFFIX, HEADER_MCP_METHOD, HEADER_MCP_NAME, + HEADER_MCP_PARAM_PREFIX, +}; +use crate::model::JsonObject; + +/// Methods whose `Mcp-Name` is sourced from `params.name`. +const NAME_FROM_NAME: &[&str] = &["tools/call", "prompts/get"]; +/// Methods whose `Mcp-Name` is sourced from `params.uri`. +const NAME_FROM_URI: &[&str] = &[ + "resources/read", + "resources/subscribe", + "resources/unsubscribe", +]; +/// Methods whose `Mcp-Name` is sourced from `params.taskId` (SEP-2663 Tasks +/// extension): allows intermediaries to route task polling to the server +/// instance holding the task's state. +const NAME_FROM_TASK_ID: &[&str] = &["tasks/get", "tasks/update", "tasks/cancel"]; + +/// Returns the `Mcp-Name` value for a request, if the method carries one. +fn extract_name(method: &str, params: Option<&Value>) -> Option { + let params = params?; + let key = if NAME_FROM_NAME.contains(&method) { + "name" + } else if NAME_FROM_URI.contains(&method) { + "uri" + } else if NAME_FROM_TASK_ID.contains(&method) { + "taskId" + } else { + return None; + }; + params.get(key)?.as_str().map(str::to_owned) +} + +/// Converts a JSON primitive to its SEP-2243 string form. Non-primitives yield `None`. +fn primitive_to_string(value: &Value) -> Option { + match value { + Value::String(s) => Some(s.clone()), + Value::Bool(b) => Some(b.to_string()), + Value::Number(n) => Some(n.to_string()), + _ => None, + } +} + +/// True if `value` must be Base64-wrapped to survive as an HTTP header value: +/// leading/trailing space or tab, control/non-ASCII characters, or a value that +/// already looks like the `=?base64?...?=` sentinel. +#[cfg(feature = "client-side-sse")] +fn requires_base64(value: &str) -> bool { + if value.is_empty() { + return false; + } + let bytes = value.as_bytes(); + if matches!(bytes.first(), Some(b' ' | b'\t')) || matches!(bytes.last(), Some(b' ' | b'\t')) { + return true; + } + if value + .chars() + .any(|c| (c as u32) < 0x20 || (c as u32) > 0x7E) + { + return true; + } + value.starts_with(BASE64_HEADER_PREFIX) && value.ends_with(BASE64_HEADER_SUFFIX) +} + +/// RFC 9110 §5.6.2 token character. +#[cfg(feature = "client-side-sse")] +fn is_tchar(c: char) -> bool { + c.is_ascii_alphanumeric() + || matches!( + c, + '!' | '#' + | '$' + | '%' + | '&' + | '\'' + | '*' + | '+' + | '-' + | '.' + | '^' + | '_' + | '`' + | '|' + | '~' + ) +} + +/// Top-level properties carrying an `x-mcp-header` annotation, as `(property, header)` pairs. +fn param_header_annotations(input_schema: &JsonObject) -> Vec<(String, String)> { + let mut out = Vec::new(); + if let Some(Value::Object(props)) = input_schema.get("properties") { + for (prop, schema) in props { + if let Some(Value::String(header)) = schema.get("x-mcp-header") + && !header.is_empty() + { + out.push((prop.clone(), header.clone())); + } + } + } + out +} + +/// Validates the `x-mcp-header` annotations in a tool input schema. +/// +/// Annotations must be non-empty RFC 9110 tokens, case-insensitively unique, +/// applied only to top-level primitive (`string`/`integer`/`boolean`) properties. +/// Returns the offending reason on the first violation. +#[cfg(feature = "client-side-sse")] +pub(crate) fn validate_param_header_annotations(input_schema: &JsonObject) -> Result<(), String> { + let Some(Value::Object(props)) = input_schema.get("properties") else { + return Ok(()); + }; + let mut seen = std::collections::HashSet::new(); + for (prop, schema) in props { + reject_nested_annotations(schema, prop)?; + let Some(raw) = schema.get("x-mcp-header") else { + continue; + }; + let Value::String(header) = raw else { + return Err(format!("property `{prop}`: x-mcp-header must be a string")); + }; + if header.is_empty() { + return Err(format!("property `{prop}`: x-mcp-header must not be empty")); + } + if !header.chars().all(is_tchar) { + return Err(format!( + "property `{prop}`: x-mcp-header `{header}` is not a valid HTTP token" + )); + } + if !seen.insert(header.to_ascii_lowercase()) { + return Err(format!( + "property `{prop}`: duplicate x-mcp-header `{header}` (case-insensitive)" + )); + } + match schema.get("type").and_then(Value::as_str) { + Some("string" | "integer" | "boolean") => {} + other => { + return Err(format!( + "property `{prop}`: x-mcp-header requires a primitive type \ + (string/integer/boolean), got {other:?}" + )); + } + } + } + Ok(()) +} + +/// Rejects `x-mcp-header` on nested properties (only top-level promotion is supported). +#[cfg(feature = "client-side-sse")] +fn reject_nested_annotations(schema: &Value, path: &str) -> Result<(), String> { + if let Some(Value::Object(nested)) = schema.get("properties") { + for (key, value) in nested { + if value.get("x-mcp-header").is_some() { + return Err(format!( + "property `{path}.{key}`: x-mcp-header is not supported on nested properties" + )); + } + reject_nested_annotations(value, &format!("{path}.{key}"))?; + } + } + Ok(()) +} + +/// Wraps a value as `=?base64??=` when it cannot travel as a bare header value. +#[cfg(feature = "client-side-sse")] +fn encode_header_value(value: &str) -> String { + use base64::{Engine, prelude::BASE64_STANDARD}; + if requires_base64(value) { + format!( + "{BASE64_HEADER_PREFIX}{}{BASE64_HEADER_SUFFIX}", + BASE64_STANDARD.encode(value) + ) + } else { + value.to_owned() + } +} + +/// Reverses [`encode_header_value`]. Returns `None` if the sentinel wraps invalid Base64/UTF-8. +#[cfg(feature = "server-side-http")] +fn decode_header_value(value: &str) -> Option { + use base64::{Engine, prelude::BASE64_STANDARD}; + match value + .strip_prefix(BASE64_HEADER_PREFIX) + .and_then(|inner| inner.strip_suffix(BASE64_HEADER_SUFFIX)) + { + Some(inner) => { + let bytes = BASE64_STANDARD.decode(inner).ok()?; + String::from_utf8(bytes).ok() + } + None => Some(value.to_owned()), + } +} + +/// Builds the SEP-2243 headers for an outgoing request from its JSON form. +/// +/// `tool_schema` is the cached input schema of the called tool, used to promote +/// annotated `tools/call` arguments to `Mcp-Param-*` headers. +#[cfg(feature = "client-side-sse")] +pub(crate) fn standard_request_headers( + request: &Value, + tool_schema: Option<&JsonObject>, +) -> Vec<(http::HeaderName, http::HeaderValue)> { + use http::{HeaderName, HeaderValue}; + + let mut out = Vec::new(); + let Some(method) = request.get("method").and_then(Value::as_str) else { + return out; + }; + let params = request.get("params"); + + let mut push = |name: &str, value: &str| { + if let (Ok(name), Ok(value)) = ( + HeaderName::from_bytes(name.as_bytes()), + HeaderValue::from_str(value), + ) { + out.push((name, value)); + } + }; + + push(HEADER_MCP_METHOD, method); + if let Some(name) = extract_name(method, params) { + push(HEADER_MCP_NAME, &encode_header_value(&name)); + } + + if method == "tools/call" + && let (Some(schema), Some(arguments)) = + (tool_schema, params.and_then(|p| p.get("arguments"))) + { + for (prop, header) in param_header_annotations(schema) { + let Some(arg) = arguments.get(&prop) else { + continue; + }; + let Some(encoded) = primitive_to_string(arg).map(|s| encode_header_value(&s)) else { + continue; + }; + push(&format!("{HEADER_MCP_PARAM_PREFIX}{header}"), &encoded); + } + } + out +} + +/// Validates incoming SEP-2243 headers against the request body. +/// +/// Returns `Err(reason)` when a required header is missing or its value does not +/// match the body; the caller maps this to a JSON-RPC `-32020` error (HTTP 400). +#[cfg(feature = "server-side-http")] +pub(crate) fn validate_request_headers( + headers: &http::HeaderMap, + request: &Value, + tool_schema: Option<&JsonObject>, +) -> Result<(), String> { + let Some(method) = request.get("method").and_then(Value::as_str) else { + return Ok(()); + }; + let params = request.get("params"); + + let header_method = header_str(headers, HEADER_MCP_METHOD); + match header_method { + None => return Err("missing required Mcp-Method header".to_owned()), + Some(value) if value != method => { + return Err(format!( + "Mcp-Method header `{value}` does not match body method `{method}`" + )); + } + Some(_) => {} + } + + if let Some(expected) = extract_name(method, params) { + match header_str(headers, HEADER_MCP_NAME) { + None => return Err(format!("missing required Mcp-Name header for `{method}`")), + Some(raw) => { + let decoded = decode_header_value(raw) + .ok_or_else(|| "Mcp-Name header is not valid Base64".to_owned())?; + if decoded != expected { + return Err(format!( + "Mcp-Name header `{decoded}` does not match body value `{expected}`" + )); + } + } + } + } + + if method == "tools/call" + && let Some(schema) = tool_schema + { + let arguments = params.and_then(|p| p.get("arguments")); + for (prop, header) in param_header_annotations(schema) { + let full = format!("{HEADER_MCP_PARAM_PREFIX}{header}"); + let header_value = header_str(headers, &full); + let arg = arguments.and_then(|a| a.get(&prop)); + let body_value = arg.filter(|v| !v.is_null()).and_then(primitive_to_string); + + match (header_value, body_value) { + (None, None) => {} + (Some(_), None) => { + return Err(format!( + "unexpected {full} header for absent or null `{prop}`" + )); + } + (None, Some(_)) => { + return Err(format!("missing {full} header for `{prop}`")); + } + (Some(raw), Some(expected)) => { + let decoded = decode_header_value(raw) + .ok_or_else(|| format!("{full} header is not valid Base64"))?; + if decoded != expected { + return Err(format!( + "{full} header `{decoded}` does not match body value `{expected}`" + )); + } + } + } + } + } + Ok(()) +} + +/// Case-insensitive header lookup returning the value as `&str`, if present and valid UTF-8. +#[cfg(feature = "server-side-http")] +fn header_str<'a>(headers: &'a http::HeaderMap, name: &str) -> Option<&'a str> { + headers.get(name).and_then(|value| value.to_str().ok()) +} + +#[cfg(all(test, feature = "client-side-sse", feature = "server-side-http"))] +mod tests { + use std::collections::HashMap; + + use http::{HeaderMap, HeaderName, HeaderValue}; + use serde_json::json; + + use super::*; + + fn schema_with(properties: serde_json::Value) -> JsonObject { + json!({ "type": "object", "properties": properties }) + .as_object() + .unwrap() + .clone() + } + + fn header_map(pairs: &[(&str, &str)]) -> HeaderMap { + let mut map = HeaderMap::new(); + for (name, value) in pairs { + map.insert( + HeaderName::from_bytes(name.as_bytes()).unwrap(), + HeaderValue::from_str(value).unwrap(), + ); + } + map + } + + fn assert_wrapped(value: &str) { + let encoded = encode_header_value(value); + assert!( + encoded.starts_with(BASE64_HEADER_PREFIX), + "expected {value:?} to be Base64-wrapped, got {encoded:?}" + ); + } + + mod encode_header_value { + use super::*; + + #[test] + fn passes_plain_ascii_through() { + assert_eq!(encode_header_value("us-west1"), "us-west1"); + } + + #[test] + fn passes_internal_spaces_through() { + assert_eq!(encode_header_value("a b c"), "a b c"); + } + + #[test] + fn wraps_non_ascii() { + assert_wrapped("café"); + } + + #[test] + fn wraps_leading_whitespace() { + assert_wrapped(" padded"); + } + + #[test] + fn wraps_trailing_whitespace() { + assert_wrapped("trailing "); + } + + #[test] + fn wraps_control_characters() { + assert_wrapped("line1\nline2"); + } + + #[test] + fn wraps_crlf_injection_attempt() { + assert_wrapped("a\r\nEvil: 1"); + } + + #[test] + fn wraps_sentinel_collision() { + assert_wrapped(&format!("{BASE64_HEADER_PREFIX}x{BASE64_HEADER_SUFFIX}")); + } + } + + mod decode_header_value { + use super::*; + + #[test] + fn round_trips_with_encode() { + for value in ["us-west1", "café", " padded ", "line1\nline2", "true", "42"] { + let encoded = encode_header_value(value); + assert_eq!( + decode_header_value(&encoded).as_deref(), + Some(value), + "round-trip failed for {value:?}" + ); + } + } + + #[test] + fn rejects_invalid_base64() { + let bad = format!("{BASE64_HEADER_PREFIX}!!!not-base64!!!{BASE64_HEADER_SUFFIX}"); + assert_eq!(decode_header_value(&bad), None); + } + } + + mod extract_name { + use super::*; + + #[test] + fn from_name_for_tools_call() { + let params = json!({ "name": "my_tool" }); + assert_eq!( + extract_name("tools/call", Some(¶ms)).as_deref(), + Some("my_tool") + ); + } + + #[test] + fn from_name_for_prompts_get() { + let params = json!({ "name": "my_prompt" }); + assert_eq!( + extract_name("prompts/get", Some(¶ms)).as_deref(), + Some("my_prompt") + ); + } + + #[test] + fn from_uri_for_resources_read() { + let params = json!({ "uri": "file:///x" }); + assert_eq!( + extract_name("resources/read", Some(¶ms)).as_deref(), + Some("file:///x") + ); + } + + #[test] + fn none_for_unrelated_method() { + let params = json!({ "name": "my_tool" }); + assert_eq!(extract_name("ping", Some(¶ms)), None); + } + + #[test] + fn none_when_params_absent() { + assert_eq!(extract_name("tools/call", None), None); + } + } + + mod validate_param_header_annotations { + use super::*; + + #[test] + fn accepts_primitive_types() { + let schema = schema_with(json!({ + "region": { "type": "string", "x-mcp-header": "Region" }, + "count": { "type": "integer", "x-mcp-header": "Count" }, + "flag": { "type": "boolean", "x-mcp-header": "Flag" }, + })); + assert!(validate_param_header_annotations(&schema).is_ok()); + } + + #[test] + fn rejects_number_type() { + let schema = schema_with(json!({ "n": { "type": "number", "x-mcp-header": "N" } })); + assert!(validate_param_header_annotations(&schema).is_err()); + } + + #[test] + fn rejects_complex_type() { + let schema = schema_with(json!({ "a": { "type": "array", "x-mcp-header": "A" } })); + assert!(validate_param_header_annotations(&schema).is_err()); + } + + #[test] + fn rejects_empty_header_name() { + let schema = schema_with(json!({ "r": { "type": "string", "x-mcp-header": "" } })); + assert!(validate_param_header_annotations(&schema).is_err()); + } + + #[test] + fn rejects_non_token_header_name() { + let schema = + schema_with(json!({ "r": { "type": "string", "x-mcp-header": "bad:name" } })); + assert!(validate_param_header_annotations(&schema).is_err()); + } + + #[test] + fn rejects_case_insensitive_duplicate() { + let schema = schema_with(json!({ + "a": { "type": "string", "x-mcp-header": "Region" }, + "b": { "type": "string", "x-mcp-header": "region" }, + })); + assert!(validate_param_header_annotations(&schema).is_err()); + } + + #[test] + fn rejects_nested_annotation() { + let schema = schema_with(json!({ + "outer": { + "type": "object", + "properties": { "inner": { "type": "string", "x-mcp-header": "Inner" } } + } + })); + assert!(validate_param_header_annotations(&schema).is_err()); + } + } + + mod standard_request_headers { + use super::*; + + fn tools_call_headers() -> HashMap { + let schema = schema_with(json!({ + "region": { "type": "string", "x-mcp-header": "Region" }, + })); + let request = json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { "name": "deploy", "arguments": { "region": "us-west1" } } + }); + super::super::standard_request_headers(&request, Some(&schema)) + .into_iter() + .map(|(name, value)| (name.as_str().to_owned(), value.to_str().unwrap().to_owned())) + .collect() + } + + #[test] + fn sets_method_header() { + assert_eq!( + tools_call_headers().get("mcp-method").map(String::as_str), + Some("tools/call") + ); + } + + #[test] + fn sets_name_header() { + assert_eq!( + tools_call_headers().get("mcp-name").map(String::as_str), + Some("deploy") + ); + } + + #[test] + fn sets_annotated_param_header() { + assert_eq!( + tools_call_headers() + .get("mcp-param-region") + .map(String::as_str), + Some("us-west1") + ); + } + } + + mod validate_request_headers { + use super::*; + + fn tools_call_request() -> Value { + json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { "name": "deploy" } + }) + } + + #[test] + fn accepts_matching_method_and_name() { + let headers = header_map(&[("Mcp-Method", "tools/call"), ("Mcp-Name", "deploy")]); + assert!(validate_request_headers(&headers, &tools_call_request(), None).is_ok()); + } + + #[test] + fn rejects_method_mismatch() { + let headers = header_map(&[("Mcp-Method", "tools/list"), ("Mcp-Name", "deploy")]); + assert!(validate_request_headers(&headers, &tools_call_request(), None).is_err()); + } + + #[test] + fn rejects_missing_method() { + let headers = header_map(&[("Mcp-Name", "deploy")]); + assert!(validate_request_headers(&headers, &tools_call_request(), None).is_err()); + } + + #[test] + fn rejects_name_mismatch() { + let headers = header_map(&[("Mcp-Method", "tools/call"), ("Mcp-Name", "other")]); + assert!(validate_request_headers(&headers, &tools_call_request(), None).is_err()); + } + + #[test] + fn rejects_missing_name() { + let headers = header_map(&[("Mcp-Method", "tools/call")]); + assert!(validate_request_headers(&headers, &tools_call_request(), None).is_err()); + } + + #[test] + fn accepts_matching_param() { + let schema = schema_with(json!({ + "region": { "type": "string", "x-mcp-header": "Region" }, + })); + let request = json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { "name": "deploy", "arguments": { "region": "us-west1" } } + }); + let headers = header_map(&[ + ("Mcp-Method", "tools/call"), + ("Mcp-Name", "deploy"), + ("Mcp-Param-Region", "us-west1"), + ]); + assert!(validate_request_headers(&headers, &request, Some(&schema)).is_ok()); + } + + #[test] + fn rejects_param_mismatch() { + let schema = schema_with(json!({ + "region": { "type": "string", "x-mcp-header": "Region" }, + })); + let request = json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { "name": "deploy", "arguments": { "region": "us-west1" } } + }); + let headers = header_map(&[ + ("Mcp-Method", "tools/call"), + ("Mcp-Name", "deploy"), + ("Mcp-Param-Region", "eu-central1"), + ]); + assert!(validate_request_headers(&headers, &request, Some(&schema)).is_err()); + } + } +} diff --git a/vendor/rmcp/src/transport/common/reqwest.rs b/vendor/rmcp/src/transport/common/reqwest.rs new file mode 100644 index 000000000..420759219 --- /dev/null +++ b/vendor/rmcp/src/transport/common/reqwest.rs @@ -0,0 +1,2 @@ +#[cfg(feature = "transport-streamable-http-client-reqwest")] +mod streamable_http_client; diff --git a/vendor/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/vendor/rmcp/src/transport/common/reqwest/streamable_http_client.rs new file mode 100644 index 000000000..e2eeebb4a --- /dev/null +++ b/vendor/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -0,0 +1,593 @@ +use std::{borrow::Cow, collections::HashMap, sync::Arc}; + +use futures::stream::BoxStream; +use http::{HeaderName, HeaderValue, header::WWW_AUTHENTICATE}; +use reqwest::header::ACCEPT; +use sse_stream::Sse; + +use crate::{ + model::{ClientJsonRpcMessage, JsonRpcMessage, ServerJsonRpcMessage}, + transport::{ + common::{ + client_side_sse::{DEFAULT_MAX_SSE_EVENT_SIZE, bounded_sse_stream}, + http_header::{ + EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_SESSION_ID, JSON_MIME_TYPE, + extract_scope_from_header, validate_custom_header, + }, + }, + streamable_http_client::*, + }, +}; + +impl From for StreamableHttpError { + fn from(e: reqwest::Error) -> Self { + StreamableHttpError::Client(e) + } +} + +/// Applies custom headers to a request builder, rejecting reserved headers. +fn apply_custom_headers( + mut builder: reqwest::RequestBuilder, + custom_headers: HashMap, +) -> Result> { + for (name, value) in custom_headers { + validate_custom_header(&name).map_err(StreamableHttpError::ReservedHeaderConflict)?; + builder = builder.header(name, value); + } + Ok(builder) +} + +/// Attempts to parse `body` as a JSON-RPC error message. +/// Returns `None` if the body is not parseable or is not a `JsonRpcMessage::Error`. +fn parse_json_rpc_error(body: &str) -> Option { + match serde_json::from_str::(body) { + Ok(message @ JsonRpcMessage::Error(_)) => Some(message), + _ => None, + } +} + +impl StreamableHttpClient for reqwest::Client { + type Error = reqwest::Error; + + async fn get_stream( + &self, + uri: Arc, + session_id: Option>, + last_event_id: Option, + auth_token: Option, + custom_headers: HashMap, + ) -> Result>, StreamableHttpError> { + self.get_stream_with_max_sse_event_size( + uri, + session_id, + last_event_id, + auth_token, + custom_headers, + DEFAULT_MAX_SSE_EVENT_SIZE, + ) + .await + } + + async fn get_stream_with_max_sse_event_size( + &self, + uri: Arc, + session_id: Option>, + last_event_id: Option, + auth_token: Option, + custom_headers: HashMap, + max_sse_event_size: usize, + ) -> Result>, StreamableHttpError> { + let mut request_builder = self + .get(uri.as_ref()) + .header(ACCEPT, [EVENT_STREAM_MIME_TYPE, JSON_MIME_TYPE].join(", ")); + if let Some(session_id) = session_id { + request_builder = request_builder.header(HEADER_SESSION_ID, session_id.as_ref()); + } + if let Some(last_event_id) = last_event_id { + request_builder = request_builder.header(HEADER_LAST_EVENT_ID, last_event_id); + } + if let Some(auth_header) = auth_token { + request_builder = request_builder.bearer_auth(auth_header); + } + request_builder = apply_custom_headers(request_builder, custom_headers)?; + let response = request_builder.send().await?; + if response.status() == reqwest::StatusCode::METHOD_NOT_ALLOWED { + return Err(StreamableHttpError::ServerDoesNotSupportSse); + } + let response = response.error_for_status()?; + match response.headers().get(reqwest::header::CONTENT_TYPE) { + Some(ct) => { + if !ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) + && !ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) + { + return Err(StreamableHttpError::UnexpectedContentType(Some( + String::from_utf8_lossy(ct.as_bytes()).to_string(), + ))); + } + } + None => { + return Err(StreamableHttpError::UnexpectedContentType(None)); + } + } + let event_stream = bounded_sse_stream(response.bytes_stream(), max_sse_event_size); + Ok(event_stream) + } + + async fn delete_session( + &self, + uri: Arc, + session: Arc, + auth_token: Option, + custom_headers: HashMap, + ) -> Result<(), StreamableHttpError> { + let mut request_builder = self.delete(uri.as_ref()); + if let Some(auth_header) = auth_token { + request_builder = request_builder.bearer_auth(auth_header); + } + request_builder = request_builder.header(HEADER_SESSION_ID, session.as_ref()); + request_builder = apply_custom_headers(request_builder, custom_headers)?; + let response = request_builder.send().await?; + + // if method no allowed + if response.status() == reqwest::StatusCode::METHOD_NOT_ALLOWED { + tracing::debug!("this server doesn't support deleting session"); + return Ok(()); + } + let _response = response.error_for_status()?; + Ok(()) + } + + async fn post_message( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_token: Option, + custom_headers: HashMap, + ) -> Result> { + self.post_message_with_max_sse_event_size( + uri, + message, + session_id, + auth_token, + custom_headers, + DEFAULT_MAX_SSE_EVENT_SIZE, + ) + .await + } + + async fn post_message_with_max_sse_event_size( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_token: Option, + custom_headers: HashMap, + max_sse_event_size: usize, + ) -> Result> { + let mut request = self + .post(uri.as_ref()) + .header(ACCEPT, [EVENT_STREAM_MIME_TYPE, JSON_MIME_TYPE].join(", ")); + if let Some(auth_header) = auth_token { + request = request.bearer_auth(auth_header); + } + + request = apply_custom_headers(request, custom_headers)?; + let session_was_attached = session_id.is_some(); + if let Some(session_id) = session_id { + request = request.header(HEADER_SESSION_ID, session_id.as_ref()); + } + let response = request.json(&message).send().await?; + if response.status() == reqwest::StatusCode::UNAUTHORIZED + && let Some(header) = response.headers().get(WWW_AUTHENTICATE) + { + let header = header + .to_str() + .map_err(|_| { + StreamableHttpError::UnexpectedServerResponse(Cow::from( + "invalid www-authenticate header value", + )) + })? + .to_string(); + return Err(StreamableHttpError::AuthRequired(AuthRequiredError { + www_authenticate_header: header, + })); + } + if response.status() == reqwest::StatusCode::FORBIDDEN + && let Some(header) = response.headers().get(WWW_AUTHENTICATE) + { + let header_str = header.to_str().map_err(|_| { + StreamableHttpError::UnexpectedServerResponse(Cow::from( + "invalid www-authenticate header value", + )) + })?; + let scope = extract_scope_from_header(header_str); + return Err(StreamableHttpError::InsufficientScope( + InsufficientScopeError { + www_authenticate_header: header_str.to_string(), + required_scope: scope, + }, + )); + } + let status = response.status(); + if matches!( + status, + reqwest::StatusCode::ACCEPTED | reqwest::StatusCode::NO_CONTENT + ) { + return Ok(StreamableHttpPostResponse::Accepted); + } + if status == reqwest::StatusCode::NOT_FOUND && session_was_attached { + return Err(StreamableHttpError::SessionExpired); + } + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .map(|ct| String::from_utf8_lossy(ct.as_bytes()).to_string()); + let content_length = response.content_length(); + let session_id = response + .headers() + .get(HEADER_SESSION_ID) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + // Spec requires 202 Accepted for these, but some servers return an empty 200. + // Treat empty success responses as equivalent to Accepted. + if status.is_success() + && content_length == Some(0) + && matches!( + message, + ClientJsonRpcMessage::Notification(_) + | ClientJsonRpcMessage::Response(_) + | ClientJsonRpcMessage::Error(_) + ) + { + return Ok(StreamableHttpPostResponse::Accepted); + } + // Non-success responses may carry valid JSON-RPC error payloads that + // should be surfaced as McpError rather than lost in TransportSend. + if !status.is_success() { + let body = response + .text() + .await + .unwrap_or_else(|_| "".to_owned()); + if content_type + .as_deref() + .is_some_and(|ct| ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes())) + { + match parse_json_rpc_error(&body) { + Some(message) => { + return Ok(StreamableHttpPostResponse::Json(message, session_id)); + } + None => tracing::warn!( + "HTTP {status}: could not parse JSON body as a JSON-RPC error" + ), + } + } + return Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned( + format!("HTTP {status}: {body}"), + ))); + } + match content_type.as_deref() { + Some(ct) if ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) => { + let event_stream = bounded_sse_stream(response.bytes_stream(), max_sse_event_size); + Ok(StreamableHttpPostResponse::Sse(event_stream, session_id)) + } + Some(ct) if ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) => { + // Try to parse as a valid JSON-RPC message. If the body is + // malformed (e.g. a 200 response to a notification that lacks + // an `id` field), treat it as accepted rather than failing. + match response.json::().await { + Ok(message) => Ok(StreamableHttpPostResponse::Json(message, session_id)), + Err(e) => { + tracing::warn!( + "could not parse JSON response as ServerJsonRpcMessage, treating as accepted: {e}" + ); + Ok(StreamableHttpPostResponse::Accepted) + } + } + } + _ => { + // unexpected content type + tracing::error!("unexpected content type: {:?}", content_type); + Err(StreamableHttpError::UnexpectedContentType(content_type)) + } + } + } +} + +impl StreamableHttpClientTransport { + /// Creates a new transport using reqwest with the specified URI. + /// + /// This is a convenience method that creates a transport using the default + /// reqwest client. This method is only available when the + /// `transport-streamable-http-client-reqwest` feature is enabled. + /// + /// # Arguments + /// + /// * `uri` - The server URI to connect to + /// + /// # Example + /// + /// ```rust,no_run + /// use rmcp::transport::StreamableHttpClientTransport; + /// + /// // Enable the reqwest feature in Cargo.toml: + /// // rmcp = { version = "0.5", features = ["transport-streamable-http-client-reqwest"] } + /// + /// let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/mcp"); + /// ``` + /// + /// # Feature requirement + /// + /// This method requires the `transport-streamable-http-client-reqwest` feature. + pub fn from_uri(uri: impl Into>) -> Self { + StreamableHttpClientTransport::with_client( + Self::default_http_client(), + StreamableHttpClientTransportConfig { + uri: uri.into(), + auth_header: None, + ..Default::default() + }, + ) + } + + /// Build this transport form a config + /// + /// # Arguments + /// + /// * `config` - The config to use with this transport + pub fn from_config(config: StreamableHttpClientTransportConfig) -> Self { + StreamableHttpClientTransport::with_client(Self::default_http_client(), config) + } + + /// Build the default reqwest client for this transport. + /// + /// Disables idle connection pooling to avoid ~40 ms stalls caused by + /// TCP Delayed ACK on Linux when the previous response body was not + /// fully consumed before the pool attempts to reuse the connection. + /// + /// Automatic redirects are disabled so caller-supplied custom headers + /// cannot be replayed to a redirect target. + fn default_http_client() -> reqwest::Client { + reqwest::Client::builder() + .pool_max_idle_per_host(0) + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("failed to build default reqwest client") + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::parse_json_rpc_error; + use crate::{ + model::{ClientJsonRpcMessage, ClientRequest, JsonRpcMessage, PingRequest, RequestId}, + transport::streamable_http_client::{AuthRequiredError, InsufficientScopeError}, + }; + + #[test] + fn auth_required_error_new() { + let err = AuthRequiredError::new("Bearer realm=\"test\"".to_string()); + assert_eq!(err.www_authenticate_header, "Bearer realm=\"test\""); + } + + #[test] + fn insufficient_scope_error_can_upgrade() { + let with_scope = InsufficientScopeError::new( + "Bearer scope=\"admin\"".to_string(), + Some("admin".to_string()), + ); + assert!(with_scope.can_upgrade()); + assert_eq!(with_scope.get_required_scope(), Some("admin")); + + let without_scope = + InsufficientScopeError::new("Bearer error=\"insufficient_scope\"".to_string(), None); + assert!(!without_scope.can_upgrade()); + assert_eq!(without_scope.get_required_scope(), None); + } + + #[test] + fn parse_json_rpc_error_returns_error_variant() { + let body = + r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Invalid Request"}}"#; + assert!(matches!( + parse_json_rpc_error(body), + Some(JsonRpcMessage::Error(_)) + )); + } + + #[rstest] + #[case::non_error_request(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#)] + #[case::notification( + r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}"# + )] + #[case::plain_text("not json at all")] + #[case::empty("")] + #[case::truncated_json(r#"{"broken":"#)] + fn parse_json_rpc_error_rejects_non_error_bodies(#[case] body: &str) { + assert!(parse_json_rpc_error(body).is_none()); + } + + #[tokio::test] + async fn post_sse_response_honors_configured_event_limit() -> anyhow::Result<()> { + use std::{collections::HashMap, net::SocketAddr, sync::Arc}; + + use axum::{Router, routing::post}; + use futures::StreamExt; + + use crate::transport::streamable_http_client::{ + StreamableHttpClient, StreamableHttpPostResponse, + }; + + let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await?; + let addr = listener.local_addr()?; + let server = tokio::spawn(async move { + let app = Router::new().route( + "/mcp", + post(|| async { + ( + [(http::header::CONTENT_TYPE, "text/event-stream")], + "data: this event is too large\n", + ) + }), + ); + axum::serve(listener, app).await + }); + let message = ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ); + let client = reqwest::Client::new(); + + let response = client + .post_message_with_max_sse_event_size( + Arc::::from(format!("http://{addr}/mcp")), + message, + None, + None, + HashMap::new(), + 16, + ) + .await?; + let StreamableHttpPostResponse::Sse(mut stream, _) = response else { + anyhow::bail!("expected SSE response"); + }; + let error = stream.next().await.unwrap().unwrap_err(); + + server.abort(); + assert!(error.to_string().contains("maximum size of 16 bytes")); + Ok(()) + } + + #[tokio::test] + async fn default_http_client_does_not_leak_custom_headers_to_redirect_target() + -> anyhow::Result<()> { + use std::{collections::HashMap, net::SocketAddr, sync::Arc}; + + use axum::{ + Router, extract::State, http::StatusCode, response::IntoResponse, routing::post, + }; + use http::{HeaderMap, HeaderName, HeaderValue, header::LOCATION}; + use tokio::sync::Mutex; + + use super::StreamableHttpClientTransport; + use crate::transport::streamable_http_client::{StreamableHttpClient, StreamableHttpError}; + + const API_KEY_HEADER: &str = "x-api-key"; + const API_KEY_VALUE: &str = "secret"; + + type CapturedHeader = Arc>>; + + #[derive(Clone)] + struct RedirectState { + location: String, + captured_header: CapturedHeader, + } + + async fn capture_api_key_header(headers: &HeaderMap, captured_header: &CapturedHeader) { + if let Some(value) = headers + .get(API_KEY_HEADER) + .and_then(|value| value.to_str().ok()) + { + *captured_header.lock().await = Some(value.to_owned()); + } + } + + async fn redirect_handler( + State(state): State, + headers: HeaderMap, + ) -> impl IntoResponse { + capture_api_key_header(&headers, &state.captured_header).await; + + ( + StatusCode::TEMPORARY_REDIRECT, + [(LOCATION, state.location)], + "", + ) + } + + async fn redirected_handler( + State(captured_header): State, + headers: HeaderMap, + ) -> impl IntoResponse { + capture_api_key_header(&headers, &captured_header).await; + + ( + StatusCode::OK, + [(http::header::CONTENT_TYPE, "application/json")], + r#"{"jsonrpc":"2.0","id":1,"result":{}}"#, + ) + } + + let redirected_header = Arc::new(Mutex::new(None)); + let redirected_listener = + tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await?; + let redirected_addr = redirected_listener.local_addr()?; + let redirected_server = tokio::spawn({ + let redirected_header = redirected_header.clone(); + async move { + let app = Router::new() + .route("/capture", post(redirected_handler)) + .with_state(redirected_header); + axum::serve(redirected_listener, app).await + } + }); + + let original_header = Arc::new(Mutex::new(None)); + let redirect_listener = + tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await?; + let redirect_addr = redirect_listener.local_addr()?; + let redirect_server = tokio::spawn({ + let state = RedirectState { + location: format!("http://{redirected_addr}/capture"), + captured_header: original_header.clone(), + }; + async move { + let app = Router::new() + .route("/mcp", post(redirect_handler)) + .with_state(state); + axum::serve(redirect_listener, app).await + } + }); + + let mut custom_headers = HashMap::new(); + custom_headers.insert( + HeaderName::from_static(API_KEY_HEADER), + HeaderValue::from_static(API_KEY_VALUE), + ); + let message = ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ); + + let client = StreamableHttpClientTransport::::default_http_client(); + let result = client + .post_message( + Arc::::from(format!("http://{redirect_addr}/mcp")), + message, + None, + None, + custom_headers, + ) + .await; + + assert!( + matches!( + result, + Err(StreamableHttpError::UnexpectedServerResponse(_)) + ), + "redirect response should be returned to the transport, got {result:?}" + ); + assert_eq!(original_header.lock().await.as_deref(), Some(API_KEY_VALUE)); + assert!( + redirected_header.lock().await.is_none(), + "custom headers should not be sent to redirect targets" + ); + + redirect_server.abort(); + redirected_server.abort(); + + Ok(()) + } +} diff --git a/vendor/rmcp/src/transport/common/server_side_http.rs b/vendor/rmcp/src/transport/common/server_side_http.rs new file mode 100644 index 000000000..09df6d285 --- /dev/null +++ b/vendor/rmcp/src/transport/common/server_side_http.rs @@ -0,0 +1,378 @@ +#![allow(dead_code)] +use std::{convert::Infallible, fmt::Display, sync::Arc, time::Duration}; + +use bytes::{Buf, Bytes}; +use http::Response; +use http_body::Body; +use http_body_util::{BodyExt, Empty, Full, combinators::BoxBody}; +use sse_stream::{KeepAlive, Sse, SseBody}; +use tokio_util::sync::{CancellationToken, DropGuard}; + +use super::http_header::EVENT_STREAM_MIME_TYPE; +use crate::model::{ClientJsonRpcMessage, ServerJsonRpcMessage}; + +pub type SessionId = Arc; + +pub fn session_id() -> SessionId { + uuid::Uuid::new_v4().to_string().into() +} + +pub const DEFAULT_AUTO_PING_INTERVAL: Duration = Duration::from_secs(15); + +pub(crate) type BoxResponse = Response>; + +pub(crate) fn accepted_response() -> Response> { + Response::builder() + .status(http::StatusCode::ACCEPTED) + .body(Empty::new().boxed()) + .expect("valid response") +} +pin_project_lite::pin_project! { + struct TokioTimer { + #[pin] + sleep: tokio::time::Sleep, + } +} +impl Future for TokioTimer { + type Output = (); + + fn poll( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + let this = self.project(); + this.sleep.poll(cx) + } +} +impl sse_stream::Timer for TokioTimer { + fn from_duration(duration: Duration) -> Self { + Self { + sleep: tokio::time::sleep(duration), + } + } + + fn reset(self: std::pin::Pin<&mut Self>, when: std::time::Instant) { + let this = self.project(); + this.sleep.reset(tokio::time::Instant::from_std(when)); + } +} + +pin_project_lite::pin_project! { + struct CancelOnDropStream { + #[pin] + inner: S, + _drop_guard: DropGuard, + } +} + +impl futures::Stream for CancelOnDropStream { + type Item = S::Item; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.project().inner.poll_next(cx) + } +} + +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub struct ServerSseMessage { + /// The event ID for this message. When set, clients can use this ID + /// with the `Last-Event-ID` header to resume the stream from this point. + pub event_id: Option, + /// The JSON-RPC message content. Set to `None` for priming events. + /// See [SEP-1699](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699) + pub message: Option>, + /// The retry interval hint for clients. Clients should wait this duration + /// before attempting to reconnect. This maps to the SSE `retry:` field. + pub retry: Option, +} + +impl ServerSseMessage { + /// Create a message carrying a JSON-RPC response/notification with an event ID. + pub fn new(event_id: impl Into, message: ServerJsonRpcMessage) -> Self { + Self { + event_id: Some(event_id.into()), + message: Some(Arc::new(message)), + retry: None, + } + } + + /// Wrap a JSON-RPC message without an event ID or retry hint. + pub fn from_message(message: ServerJsonRpcMessage) -> Self { + Self { + event_id: None, + message: Some(Arc::new(message)), + retry: None, + } + } + + /// Create a priming event that tells the client to reconnect after `retry` + /// if the connection drops. + /// See [SEP-1699](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699). + pub fn priming(event_id: impl Into, retry: Duration) -> Self { + Self { + event_id: Some(event_id.into()), + message: None, + retry: Some(retry), + } + } + + /// Create a retry hint without changing the client's last event ID. + pub fn retry(retry: Duration) -> Self { + Self { + event_id: None, + message: None, + retry: Some(retry), + } + } +} + +pub(crate) fn sse_stream_response( + stream: impl futures::Stream + Send + Sync + 'static, + keep_alive: Option, + ct: CancellationToken, +) -> Response> { + use futures::StreamExt; + let cancelled = ct.clone(); + let stream = stream + .map(|message| { + let mut sse = if let Some(ref msg) = message.message { + let data = serde_json::to_string(msg.as_ref()).expect("valid message"); + Sse::default().data(data) + } else { + // Priming event: empty data per SEP-1699 (just "data:\n") + Sse::default().data("") + }; + + sse.id = message.event_id; + + if let Some(retry) = message.retry { + sse.retry = Some(retry.as_millis() as u64); + } + + Result::::Ok(sse) + }) + .take_until(async move { cancelled.cancelled().await }); + let stream = CancelOnDropStream { + inner: stream, + _drop_guard: ct.drop_guard(), + }; + let stream = SseBody::new(stream); + + let stream = match keep_alive { + Some(duration) => stream + .with_keep_alive::(KeepAlive::new().interval(duration)) + .boxed(), + None => stream.boxed(), + }; + + Response::builder() + .status(http::StatusCode::OK) + .header(http::header::CONTENT_TYPE, EVENT_STREAM_MIME_TYPE) + .header(http::header::CACHE_CONTROL, "no-cache") + .header("X-Accel-Buffering", "no") + .body(stream) + .expect("valid response") +} + +pub(crate) const fn internal_error_response( + context: &str, +) -> impl FnOnce(E) -> Response> { + move |error| { + tracing::error!("Internal server error when {context}: {error}"); + Response::builder() + .status(http::StatusCode::INTERNAL_SERVER_ERROR) + .body( + Full::new(Bytes::from(format!( + "Encounter an error when {context}: {error}" + ))) + .boxed(), + ) + .expect("valid response") + } +} + +pub(crate) fn unexpected_message_response(expect: &str) -> Response> { + Response::builder() + .status(http::StatusCode::UNPROCESSABLE_ENTITY) + .body(Full::new(Bytes::from(format!("Unexpected message, expect {expect}"))).boxed()) + .expect("valid response") +} + +pub(crate) async fn expect_json( + body: B, + max_bytes: usize, +) -> Result>> +where + B: Body + Send + 'static, + B::Error: Display, +{ + let mut collected = bytes::BytesMut::new(); + let mut body = std::pin::pin!(body); + loop { + let frame = futures::future::poll_fn(|cx| body.as_mut().poll_frame(cx)).await; + match frame { + None => break, + Some(Ok(frame)) => { + let Ok(mut data) = frame.into_data() else { + continue; + }; + if data.remaining() > max_bytes.saturating_sub(collected.len()) { + let response = Response::builder() + .status(http::StatusCode::PAYLOAD_TOO_LARGE) + .body( + Full::new(Bytes::from(format!( + "Payload Too Large: request body exceeds {max_bytes} bytes" + ))) + .boxed(), + ) + .expect("valid response"); + return Err(response); + } + while data.has_remaining() { + let chunk = data.chunk(); + let chunk_len = chunk.len(); + collected.extend_from_slice(chunk); + data.advance(chunk_len); + } + } + Some(Err(e)) => { + let response = Response::builder() + .status(http::StatusCode::INTERNAL_SERVER_ERROR) + .body( + Full::new(Bytes::from(format!("Failed to read request body: {e}"))).boxed(), + ) + .expect("valid response"); + return Err(response); + } + } + } + + match serde_json::from_slice::(&collected) { + Ok(message) => Ok(message), + Err(e) => { + let response = Response::builder() + .status(http::StatusCode::UNSUPPORTED_MEDIA_TYPE) + .body( + Full::new(Bytes::from(format!("fail to deserialize request body {e}"))).boxed(), + ) + .expect("valid response"); + Err(response) + } + } +} + +#[cfg(test)] +mod tests { + use std::convert::Infallible; + + use futures::stream; + use http_body::Frame; + use http_body_util::StreamBody; + + use super::*; + use crate::model::{EmptyResult, JsonRpcResponse, JsonRpcVersion2_0, RequestId, ServerResult}; + + const INITIALIZE_REQUEST: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}"#; + + fn dummy_message() -> ServerJsonRpcMessage { + ServerJsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: JsonRpcVersion2_0, + id: RequestId::Number(1), + result: ServerResult::EmptyResult(EmptyResult {}), + }) + } + + #[test] + fn default_has_all_none() { + let msg = ServerSseMessage::default(); + assert!(msg.event_id.is_none()); + assert!(msg.message.is_none()); + assert!(msg.retry.is_none()); + } + + #[test] + fn new_sets_event_id_and_message() { + let msg = ServerSseMessage::new("42", dummy_message()); + assert_eq!(msg.event_id.as_deref(), Some("42")); + assert!(msg.message.is_some()); + assert!(msg.retry.is_none()); + } + + #[test] + fn from_message_has_no_event_id() { + let msg = ServerSseMessage::from_message(dummy_message()); + assert!(msg.event_id.is_none()); + assert!(msg.message.is_some()); + assert!(msg.retry.is_none()); + } + + #[test] + fn priming_sets_event_id_and_retry() { + let msg = ServerSseMessage::priming("0", Duration::from_secs(5)); + assert_eq!(msg.event_id.as_deref(), Some("0")); + assert!(msg.message.is_none()); + assert_eq!(msg.retry, Some(Duration::from_secs(5))); + } + + #[tokio::test] + async fn expect_json_accepts_body_under_limit() { + let body = Full::new(Bytes::from_static(INITIALIZE_REQUEST.as_bytes())); + let result = expect_json(body, 4 * 1024 * 1024).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn expect_json_accepts_non_contiguous_body_data() { + let split = INITIALIZE_REQUEST.len() / 2; + let data = Bytes::copy_from_slice(&INITIALIZE_REQUEST.as_bytes()[..split]).chain( + Bytes::copy_from_slice(&INITIALIZE_REQUEST.as_bytes()[split..]), + ); + let result = expect_json(Full::new(data), 4 * 1024 * 1024).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn expect_json_accepts_multi_frame_body() { + let split = INITIALIZE_REQUEST.len() / 2; + let body = StreamBody::new(stream::iter([ + Ok::<_, Infallible>(Frame::data(Bytes::copy_from_slice( + &INITIALIZE_REQUEST.as_bytes()[..split], + ))), + Ok(Frame::data(Bytes::copy_from_slice( + &INITIALIZE_REQUEST.as_bytes()[split..], + ))), + ])); + let result = expect_json(body, 4 * 1024 * 1024).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn expect_json_rejects_oversized_body() { + let big_body = Full::new(Bytes::from(vec![b'x'; 128])); + let result = expect_json(big_body, 64).await; + let response = result.unwrap_err(); + assert_eq!(response.status(), http::StatusCode::PAYLOAD_TOO_LARGE); + } + + #[tokio::test] + async fn expect_json_rejects_oversized_multi_frame_body() { + let body = StreamBody::new(stream::iter([ + Ok::<_, Infallible>(Frame::data(Bytes::from_static(b"12345678"))), + Ok(Frame::data(Bytes::from_static(b"9"))), + ])); + let response = expect_json(body, 8).await.unwrap_err(); + assert_eq!(response.status(), http::StatusCode::PAYLOAD_TOO_LARGE); + } + + #[tokio::test] + async fn expect_json_returns_415_for_invalid_json_under_limit() { + let body = Full::new(Bytes::from("not valid json")); + let result = expect_json(body, 4 * 1024 * 1024).await; + let response = result.unwrap_err(); + assert_eq!(response.status(), http::StatusCode::UNSUPPORTED_MEDIA_TYPE); + } +} diff --git a/vendor/rmcp/src/transport/common/unix_socket.rs b/vendor/rmcp/src/transport/common/unix_socket.rs new file mode 100644 index 000000000..5f995db2b --- /dev/null +++ b/vendor/rmcp/src/transport/common/unix_socket.rs @@ -0,0 +1,614 @@ +use std::{borrow::Cow, collections::HashMap, sync::Arc}; + +use bytes::Bytes; +use futures::stream::BoxStream; +use http::{HeaderName, HeaderValue, Method, Request, StatusCode, header::WWW_AUTHENTICATE}; +use http_body_util::{BodyExt, Full}; +use hyper::body::Incoming; +use hyper_util::rt::TokioIo; +use sse_stream::Sse; +use tokio::net::UnixStream; + +use crate::{ + model::{ClientJsonRpcMessage, ServerJsonRpcMessage}, + transport::{ + common::{ + client_side_sse::{DEFAULT_MAX_SSE_EVENT_SIZE, bounded_sse_stream}, + http_header::{ + EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_SESSION_ID, JSON_MIME_TYPE, + extract_scope_from_header, validate_custom_header, + }, + }, + streamable_http_client::*, + }, +}; + +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum UnixSocketError { + #[error("hyper error: {0}")] + Hyper(#[from] hyper::Error), + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("HTTP error: {0}")] + Http(#[from] http::Error), + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), +} + +impl From for StreamableHttpError { + fn from(e: UnixSocketError) -> Self { + StreamableHttpError::Client(e) + } +} + +/// HTTP client that routes requests through a Unix domain socket. +/// +/// Implements [`StreamableHttpClient`] using `hyper` over `tokio::net::UnixStream`, +/// enabling MCP hosts in Kubernetes environments to connect through Envoy sidecars +/// or other Unix socket-based proxies. +/// +/// Each request opens a new Unix socket connection (no connection pooling). +/// This is appropriate when connecting through a sidecar proxy that manages +/// its own upstream connection pool. +/// +/// # Example +/// +/// ```rust,no_run +/// use rmcp::transport::{StreamableHttpClientTransport, UnixSocketHttpClient}; +/// use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; +/// +/// let client = UnixSocketHttpClient::new("/var/run/envoy.sock", "http://mcp-server.internal/mcp"); +/// let config = StreamableHttpClientTransportConfig::with_uri("http://mcp-server.internal/mcp"); +/// let transport = StreamableHttpClientTransport::with_client(client, config); +/// ``` +#[derive(Clone, Debug)] +pub struct UnixSocketHttpClient { + socket_path: Arc, + host_header: HeaderValue, +} + +impl UnixSocketHttpClient { + /// Creates a new Unix socket HTTP client. + /// + /// # Arguments + /// + /// * `socket_path` - Path to the Unix domain socket. Use `@name` syntax for Linux + /// abstract sockets (e.g., `@egress.sock` becomes `\0egress.sock`). + /// * `uri` - The MCP server URI. The authority (host:port) is extracted for the + /// HTTP `Host` header, since hyper does not auto-set it for Unix socket connections. + /// + /// # Panics + /// + /// Panics if `socket_path` is empty or is `@` with no name (empty abstract socket). + pub fn new(socket_path: &str, uri: &str) -> Self { + assert!( + !socket_path.is_empty() && socket_path != "@", + "socket_path must not be empty or a bare '@' (empty abstract socket name)" + ); + + let host_header = uri + .parse::() + .ok() + .and_then(|u| u.authority().cloned()) + .and_then(|a| HeaderValue::from_str(a.as_str()).ok()) + .unwrap_or_else(|| HeaderValue::from_static("localhost")); + + Self { + socket_path: resolve_socket_path(socket_path).into(), + host_header, + } + } +} + +/// Converts the `@`-prefixed abstract socket notation to the null-byte prefix +/// expected by the Linux kernel. Filesystem socket paths are returned unchanged. +fn resolve_socket_path(raw: &str) -> String { + if let Some(name) = raw.strip_prefix('@') { + format!("\0{name}") + } else { + raw.to_string() + } +} + +async fn connect_unix(socket_path: &str) -> Result { + #[cfg(target_os = "linux")] + if let Some(abstract_name) = socket_path.strip_prefix('\0') { + let abstract_name = abstract_name.to_string(); + let std_stream = tokio::task::spawn_blocking(move || { + use std::os::linux::net::SocketAddrExt; + let addr = std::os::unix::net::SocketAddr::from_abstract_name(&abstract_name)?; + let stream = std::os::unix::net::UnixStream::connect_addr(&addr)?; + stream.set_nonblocking(true)?; + Ok::<_, std::io::Error>(stream) + }) + .await + .map_err(std::io::Error::other)??; + return UnixStream::from_std(std_stream); + } + + UnixStream::connect(socket_path).await +} + +/// Opens a new Unix socket connection and sends the HTTP request. +/// One connection per request — the sidecar proxy handles connection pooling. +async fn send_http_request( + socket_path: &str, + request: Request>, +) -> Result, UnixSocketError> { + let stream = connect_unix(socket_path).await?; + let io = TokioIo::new(stream); + let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await?; + + tokio::spawn(async move { + if let Err(e) = conn.await { + tracing::warn!("unix socket HTTP/1.1 connection error: {e}"); + } + }); + + Ok(sender.send_request(request).await?) +} + +/// Applies custom headers to a request builder, rejecting reserved headers. +fn apply_custom_headers( + mut builder: http::request::Builder, + custom_headers: HashMap, +) -> Result> { + for (name, value) in custom_headers { + validate_custom_header(&name).map_err(StreamableHttpError::ReservedHeaderConflict)?; + builder = builder.header(name, value); + } + Ok(builder) +} + +impl StreamableHttpClient for UnixSocketHttpClient { + type Error = UnixSocketError; + + async fn post_message( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_token: Option, + custom_headers: HashMap, + ) -> Result> { + self.post_message_with_max_sse_event_size( + uri, + message, + session_id, + auth_token, + custom_headers, + DEFAULT_MAX_SSE_EVENT_SIZE, + ) + .await + } + + async fn post_message_with_max_sse_event_size( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_token: Option, + custom_headers: HashMap, + max_sse_event_size: usize, + ) -> Result> { + let json_body = serde_json::to_string(&message) + .map_err(|e| StreamableHttpError::Client(UnixSocketError::Json(e)))?; + + let mut builder = Request::builder() + .method(Method::POST) + .uri(uri.as_ref()) + .header(http::header::HOST, self.host_header.clone()) + .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) + .header( + http::header::ACCEPT, + format!("{EVENT_STREAM_MIME_TYPE}, {JSON_MIME_TYPE}"), + ); + + if let Some(auth) = auth_token { + builder = builder.header(http::header::AUTHORIZATION, format!("Bearer {auth}")); + } + + builder = apply_custom_headers(builder, custom_headers)?; + + let session_was_attached = session_id.is_some(); + if let Some(sid) = session_id { + builder = builder.header(HEADER_SESSION_ID, sid.as_ref()); + } + + let request = builder + .body(Full::new(Bytes::from(json_body))) + .map_err(|e| StreamableHttpError::Client(UnixSocketError::Http(e)))?; + + let response = send_http_request(&self.socket_path, request) + .await + .map_err(StreamableHttpError::Client)?; + + let status = response.status(); + + if status == StatusCode::UNAUTHORIZED + && let Some(header) = response.headers().get(WWW_AUTHENTICATE) + { + let www_authenticate_header = header + .to_str() + .map_err(|_| { + StreamableHttpError::UnexpectedServerResponse(Cow::from( + "invalid www-authenticate header value", + )) + })? + .to_string(); + return Err(StreamableHttpError::AuthRequired(AuthRequiredError { + www_authenticate_header, + })); + } + + if status == StatusCode::FORBIDDEN + && let Some(header) = response.headers().get(WWW_AUTHENTICATE) + { + let header_str = header.to_str().map_err(|_| { + StreamableHttpError::UnexpectedServerResponse(Cow::from( + "invalid www-authenticate header value", + )) + })?; + let scope = extract_scope_from_header(header_str); + return Err(StreamableHttpError::InsufficientScope( + InsufficientScopeError { + www_authenticate_header: header_str.to_string(), + required_scope: scope, + }, + )); + } + + if matches!(status, StatusCode::ACCEPTED | StatusCode::NO_CONTENT) { + return Ok(StreamableHttpPostResponse::Accepted); + } + + if status == StatusCode::NOT_FOUND && session_was_attached { + return Err(StreamableHttpError::SessionExpired); + } + + if !status.is_success() { + let body = response + .into_body() + .collect() + .await + .map(|c| String::from_utf8_lossy(&c.to_bytes()).into_owned()) + .unwrap_or_else(|_| "".to_owned()); + return Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned( + format!("HTTP {status}: {body}"), + ))); + } + + let content_type = response.headers().get(http::header::CONTENT_TYPE).cloned(); + let content_length = response + .headers() + .get(http::header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()); + let session_id = response + .headers() + .get(HEADER_SESSION_ID) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + if status.is_success() + && content_length == Some(0) + && matches!( + message, + ClientJsonRpcMessage::Notification(_) + | ClientJsonRpcMessage::Response(_) + | ClientJsonRpcMessage::Error(_) + ) + { + return Ok(StreamableHttpPostResponse::Accepted); + } + + match content_type { + Some(ref ct) if ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) => { + let sse_stream = + bounded_sse_stream(response.into_body().into_data_stream(), max_sse_event_size); + Ok(StreamableHttpPostResponse::Sse(sse_stream, session_id)) + } + Some(ref ct) if ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) => { + let body = response + .into_body() + .collect() + .await + .map_err(|e| StreamableHttpError::Client(UnixSocketError::Hyper(e)))? + .to_bytes(); + match serde_json::from_slice::(&body) { + Ok(message) => Ok(StreamableHttpPostResponse::Json(message, session_id)), + Err(e) => { + tracing::warn!( + "could not parse JSON response as ServerJsonRpcMessage, treating as accepted: {e}" + ); + Ok(StreamableHttpPostResponse::Accepted) + } + } + } + _ => Err(StreamableHttpError::UnexpectedContentType( + content_type.map(|ct| String::from_utf8_lossy(ct.as_bytes()).into_owned()), + )), + } + } + + async fn delete_session( + &self, + uri: Arc, + session_id: Arc, + auth_token: Option, + custom_headers: HashMap, + ) -> Result<(), StreamableHttpError> { + let mut builder = Request::builder() + .method(Method::DELETE) + .uri(uri.as_ref()) + .header(http::header::HOST, self.host_header.clone()) + .header(HEADER_SESSION_ID, session_id.as_ref()); + + if let Some(auth) = auth_token { + builder = builder.header(http::header::AUTHORIZATION, format!("Bearer {auth}")); + } + + builder = apply_custom_headers(builder, custom_headers)?; + + let request = builder + .body(Full::new(Bytes::new())) + .map_err(|e| StreamableHttpError::Client(UnixSocketError::Http(e)))?; + + let response = send_http_request(&self.socket_path, request) + .await + .map_err(StreamableHttpError::Client)?; + + if response.status() == StatusCode::METHOD_NOT_ALLOWED { + tracing::debug!("this server doesn't support deleting session"); + return Ok(()); + } + + if !response.status().is_success() { + return Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned( + format!("delete_session returned {}", response.status()), + ))); + } + + Ok(()) + } + + async fn get_stream( + &self, + uri: Arc, + session_id: Option>, + last_event_id: Option, + auth_token: Option, + custom_headers: HashMap, + ) -> Result>, StreamableHttpError> + { + self.get_stream_with_max_sse_event_size( + uri, + session_id, + last_event_id, + auth_token, + custom_headers, + DEFAULT_MAX_SSE_EVENT_SIZE, + ) + .await + } + + async fn get_stream_with_max_sse_event_size( + &self, + uri: Arc, + session_id: Option>, + last_event_id: Option, + auth_token: Option, + custom_headers: HashMap, + max_sse_event_size: usize, + ) -> Result>, StreamableHttpError> + { + let mut builder = Request::builder() + .method(Method::GET) + .uri(uri.as_ref()) + .header(http::header::HOST, self.host_header.clone()) + .header( + http::header::ACCEPT, + format!("{EVENT_STREAM_MIME_TYPE}, {JSON_MIME_TYPE}"), + ); + + if let Some(session_id) = session_id { + builder = builder.header(HEADER_SESSION_ID, session_id.as_ref()); + } + + if let Some(last_id) = last_event_id { + builder = builder.header(HEADER_LAST_EVENT_ID, last_id); + } + + if let Some(auth) = auth_token { + builder = builder.header(http::header::AUTHORIZATION, format!("Bearer {auth}")); + } + + builder = apply_custom_headers(builder, custom_headers)?; + + let request = builder + .body(Full::new(Bytes::new())) + .map_err(|e| StreamableHttpError::Client(UnixSocketError::Http(e)))?; + + let response = send_http_request(&self.socket_path, request) + .await + .map_err(StreamableHttpError::Client)?; + + if response.status() == StatusCode::METHOD_NOT_ALLOWED { + return Err(StreamableHttpError::ServerDoesNotSupportSse); + } + + if response.status() == StatusCode::UNAUTHORIZED + && let Some(header) = response.headers().get(WWW_AUTHENTICATE) + { + let www_authenticate_header = header + .to_str() + .map_err(|_| { + StreamableHttpError::UnexpectedServerResponse(Cow::from( + "invalid www-authenticate header value", + )) + })? + .to_string(); + return Err(StreamableHttpError::AuthRequired(AuthRequiredError { + www_authenticate_header, + })); + } + + if response.status() == StatusCode::FORBIDDEN + && let Some(header) = response.headers().get(WWW_AUTHENTICATE) + { + let header_str = header.to_str().map_err(|_| { + StreamableHttpError::UnexpectedServerResponse(Cow::from( + "invalid www-authenticate header value", + )) + })?; + let scope = extract_scope_from_header(header_str); + return Err(StreamableHttpError::InsufficientScope( + InsufficientScopeError { + www_authenticate_header: header_str.to_string(), + required_scope: scope, + }, + )); + } + + if !response.status().is_success() { + return Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned( + format!("get_stream returned {}", response.status()), + ))); + } + + match response.headers().get(http::header::CONTENT_TYPE) { + Some(ct) => { + if !ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) + && !ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) + { + return Err(StreamableHttpError::UnexpectedContentType(Some( + String::from_utf8_lossy(ct.as_bytes()).to_string(), + ))); + } + } + None => { + return Err(StreamableHttpError::UnexpectedContentType(None)); + } + } + + Ok(bounded_sse_stream( + response.into_body().into_data_stream(), + max_sse_event_size, + )) + } +} + +impl StreamableHttpClientTransport { + /// Creates a new transport connecting through a Unix domain socket. + /// + /// # Arguments + /// + /// * `socket_path` - Path to the Unix domain socket. Use `@name` for Linux abstract sockets. + /// * `uri` - The MCP server URI (used for HTTP Host header and request path). + pub fn from_unix_socket(socket_path: &str, uri: impl Into>) -> Self { + let uri: Arc = uri.into(); + let client = UnixSocketHttpClient::new(socket_path, &uri); + let config = StreamableHttpClientTransportConfig { + uri, + ..Default::default() + }; + StreamableHttpClientTransport::with_client(client, config) + } + + /// Creates a new transport connecting through a Unix domain socket with custom config. + /// + /// # Arguments + /// + /// * `socket_path` - Path to the Unix domain socket. Use `@name` for Linux abstract sockets. + /// * `config` - Transport configuration (URI, retry policy, custom headers, etc.). + pub fn from_unix_socket_with_config( + socket_path: &str, + config: StreamableHttpClientTransportConfig, + ) -> Self { + let client = UnixSocketHttpClient::new(socket_path, &config.uri); + StreamableHttpClientTransport::with_client(client, config) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_abstract_socket() { + assert_eq!(resolve_socket_path("@egress.sock"), "\0egress.sock"); + } + + #[test] + fn resolve_filesystem_socket() { + assert_eq!( + resolve_socket_path("/var/run/envoy.sock"), + "/var/run/envoy.sock" + ); + } + + #[test] + fn resolve_empty_abstract() { + assert_eq!(resolve_socket_path("@"), "\0"); + } + + #[test] + #[should_panic(expected = "socket_path must not be empty")] + fn rejects_bare_at_symbol() { + UnixSocketHttpClient::new("@", "http://localhost/mcp"); + } + + #[test] + #[should_panic(expected = "socket_path must not be empty")] + fn rejects_empty_path() { + UnixSocketHttpClient::new("", "http://localhost/mcp"); + } + + #[test] + fn host_header_auto_derived() { + let client = + UnixSocketHttpClient::new("/var/run/envoy.sock", "http://mcp-server.internal/mcp"); + assert_eq!(client.host_header, "mcp-server.internal"); + } + + #[test] + fn host_header_with_port() { + let client = + UnixSocketHttpClient::new("/var/run/envoy.sock", "http://mcp-server.internal:8080/mcp"); + assert_eq!(client.host_header, "mcp-server.internal:8080"); + } + + #[test] + fn host_header_fallback_on_path_only_uri() { + let client = UnixSocketHttpClient::new("/var/run/envoy.sock", "/mcp"); + assert_eq!(client.host_header, "localhost"); + } + + #[test] + fn reserved_header_rejected() { + let mut headers = HashMap::new(); + headers.insert( + HeaderName::from_static("accept"), + HeaderValue::from_static("text/plain"), + ); + let builder = Request::builder(); + let result = apply_custom_headers(builder, headers); + assert!(matches!( + result, + Err(StreamableHttpError::ReservedHeaderConflict(_)) + )); + } + + #[test] + fn mcp_protocol_version_allowed_through() { + let mut headers = HashMap::new(); + headers.insert( + HeaderName::from_static("mcp-protocol-version"), + HeaderValue::from_static("2025-03-26"), + ); + let builder = Request::builder().uri("http://localhost/mcp").method("GET"); + let result = apply_custom_headers(builder, headers); + assert!(result.is_ok()); + } +} diff --git a/vendor/rmcp/src/transport/io.rs b/vendor/rmcp/src/transport/io.rs new file mode 100644 index 000000000..adc2dba0e --- /dev/null +++ b/vendor/rmcp/src/transport/io.rs @@ -0,0 +1,6 @@ +/// # StdIO Transport +/// +/// Create a pair of [`tokio::io::Stdin`] and [`tokio::io::Stdout`]. +pub fn stdio() -> (tokio::io::Stdin, tokio::io::Stdout) { + (tokio::io::stdin(), tokio::io::stdout()) +} diff --git a/vendor/rmcp/src/transport/sink_stream.rs b/vendor/rmcp/src/transport/sink_stream.rs new file mode 100644 index 000000000..6286b53b3 --- /dev/null +++ b/vendor/rmcp/src/transport/sink_stream.rs @@ -0,0 +1,80 @@ +use std::sync::Arc; + +use futures::{Sink, Stream}; +use tokio::sync::Mutex; + +use super::{IntoTransport, Transport}; +use crate::service::{RxJsonRpcMessage, ServiceRole, TxJsonRpcMessage}; + +pub struct SinkStreamTransport { + stream: St, + sink: Arc>, +} + +impl SinkStreamTransport { + pub fn new(sink: Si, stream: St) -> Self { + Self { + stream, + sink: Arc::new(Mutex::new(sink)), + } + } +} + +impl Transport for SinkStreamTransport +where + St: Send + Stream> + Unpin, + Si: Send + Sink> + Unpin + 'static, + Si::Error: std::error::Error + Send + Sync + 'static, +{ + type Error = Si::Error; + + fn send( + &mut self, + item: TxJsonRpcMessage, + ) -> impl Future> + Send + 'static { + use futures::SinkExt; + let lock = self.sink.clone(); + async move { + let mut write = lock.lock().await; + write.send(item).await + } + } + + fn receive(&mut self) -> impl Future>> { + use futures::StreamExt; + self.stream.next() + } + + async fn close(&mut self) -> Result<(), Self::Error> { + Ok(()) + } +} + +#[non_exhaustive] +pub enum TransportAdapterSinkStream {} + +impl IntoTransport for (Si, St) +where + Role: ServiceRole, + Si: Send + Sink> + Unpin + 'static, + St: Send + Stream> + Unpin + 'static, + Si::Error: std::error::Error + Send + Sync + 'static, +{ + fn into_transport(self) -> impl Transport + 'static { + SinkStreamTransport::new(self.0, self.1) + } +} + +#[non_exhaustive] +pub enum TransportAdapterAsyncCombinedRW {} +impl IntoTransport for S +where + Role: ServiceRole, + S: Sink> + Stream> + Send + 'static, + S::Error: std::error::Error + Send + Sync + 'static, +{ + fn into_transport(self) -> impl Transport + 'static { + use futures::StreamExt; + IntoTransport::::into_transport(self.split()) + } +} diff --git a/vendor/rmcp/src/transport/streamable_http_client.rs b/vendor/rmcp/src/transport/streamable_http_client.rs new file mode 100644 index 000000000..fe563ee3f --- /dev/null +++ b/vendor/rmcp/src/transport/streamable_http_client.rs @@ -0,0 +1,2186 @@ +use std::{ + borrow::Cow, + collections::{HashMap, HashSet}, + sync::Arc, + time::Duration, +}; + +use futures::{Stream, StreamExt, future::BoxFuture, stream::BoxStream}; +use http::{HeaderName, HeaderValue}; +pub use sse_stream::Error as SseError; +use sse_stream::Sse; +use thiserror::Error; +use tokio_util::sync::CancellationToken; +use tracing::debug; + +use super::common::client_side_sse::{ + DEFAULT_MAX_SSE_EVENT_SIZE, ExponentialBackoff, SseRetryPolicy, SseStreamReconnect, +}; +use crate::{ + RoleClient, + model::{ + ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorData, GetExtensions, GetMeta, + InitializedNotification, JsonObject, ProtocolVersion, RequestId, ServerJsonRpcMessage, + ServerResult, + }, + service::InboundStreamOrigin, + transport::{ + common::{client_side_sse::SseAutoReconnectStream, mcp_headers}, + worker::{Worker, WorkerQuitReason, WorkerSendRequest, WorkerTransport}, + }, +}; + +type BoxedSseStream = BoxStream<'static, Result>; +type SseTaskResult = (Option, Result<(), StreamableHttpError>); +const SESSION_CLEANUP_TIMEOUT: Duration = Duration::from_secs(5); + +fn build_request_headers( + base: &HashMap, + message: &ClientJsonRpcMessage, + tool_cache: &HashMap>, + version: &ProtocolVersion, +) -> HashMap { + use serde_json::Value; + + let mut headers = base.clone(); + if *version >= ProtocolVersion::STANDARD_HEADERS + && let Ok(value) = serde_json::to_value(message) + { + let schema = value + .get("method") + .and_then(Value::as_str) + .filter(|method| *method == "tools/call") + .and_then(|_| value.get("params")) + .and_then(|params| params.get("name")) + .and_then(Value::as_str) + .and_then(|name| tool_cache.get(name)) + .map(Arc::as_ref); + for (name, val) in mcp_headers::standard_request_headers(&value, schema) { + headers.insert(name, val); + } + } + headers +} + +fn request_version_headers( + base: &HashMap, + message: &ClientJsonRpcMessage, + fallback: &ProtocolVersion, + tool_cache: &HashMap>, +) -> (ProtocolVersion, HashMap) { + let version = match message { + ClientJsonRpcMessage::Request(request) => request + .request + .get_meta() + .protocol_version() + .unwrap_or_else(|| fallback.clone()), + _ => fallback.clone(), + }; + let mut headers = build_request_headers(base, message, tool_cache, &version); + if let Ok(value) = HeaderValue::from_str(version.as_str()) { + headers.insert(HeaderName::from_static("mcp-protocol-version"), value); + } + (version, headers) +} + +fn cache_tools_from_response( + cache: &mut HashMap>, + message: &mut ServerJsonRpcMessage, + protocol_version: &ProtocolVersion, +) { + if protocol_version < &ProtocolVersion::STANDARD_HEADERS { + return; + } + if let ServerJsonRpcMessage::Response(response) = message + && let ServerResult::ListToolsResult(list) = &mut response.result + { + list.tools.retain(|tool| { + let Err(reason) = + mcp_headers::validate_param_header_annotations(&tool.input_schema) + else { + cache.insert(tool.name.to_string(), tool.input_schema.clone()); + return true; + }; + tracing::warn!(tool = %tool.name, "rejecting invalid x-mcp-header annotations: {reason}"); + false + }); + } +} + +fn negotiate_version_headers( + init_response: &ServerJsonRpcMessage, + base: HashMap, +) -> (ProtocolVersion, HashMap) { + let mut version = ProtocolVersion::default(); + let mut headers = base; + if let ServerJsonRpcMessage::Response(response) = init_response + && let ServerResult::InitializeResult(init_result) = &response.result + { + version = init_result.protocol_version.clone(); + // HeaderName::from_static requires lowercase + if let Ok(hv) = HeaderValue::from_str(init_result.protocol_version.as_str()) { + headers.insert(HeaderName::from_static("mcp-protocol-version"), hv); + } + } + (version, headers) +} + +#[derive(Debug, Error)] +#[error("authorization required: {www_authenticate_header}")] +#[non_exhaustive] +pub struct AuthRequiredError { + pub www_authenticate_header: String, +} + +impl AuthRequiredError { + /// Create a new `AuthRequiredError` instance. + pub fn new(www_authenticate_header: String) -> Self { + Self { + www_authenticate_header, + } + } +} + +#[derive(Debug, Error)] +#[error("insufficient scope: {www_authenticate_header}")] +#[non_exhaustive] +pub struct InsufficientScopeError { + pub www_authenticate_header: String, + pub required_scope: Option, +} + +impl InsufficientScopeError { + /// Create a new `InsufficientScopeError` instance. + pub fn new(www_authenticate_header: String, required_scope: Option) -> Self { + Self { + www_authenticate_header, + required_scope, + } + } + + /// check if scope upgrade is possible (i.e., we know what scope is required) + pub fn can_upgrade(&self) -> bool { + self.required_scope.is_some() + } + + /// get the required scope for upgrade + pub fn get_required_scope(&self) -> Option<&str> { + self.required_scope.as_deref() + } +} + +#[derive(Error, Debug)] +#[non_exhaustive] +pub enum StreamableHttpError { + #[error("SSE error: {0}")] + Sse(#[from] SseError), + #[error("Io error: {0}")] + Io(#[from] std::io::Error), + #[error("Client error: {0}")] + Client(E), + #[error("unexpected end of stream")] + UnexpectedEndOfStream, + #[error("unexpected server response: {0}")] + UnexpectedServerResponse(Cow<'static, str>), + #[error("Unexpected content type: {0:?}")] + UnexpectedContentType(Option), + #[error("Server does not support SSE")] + ServerDoesNotSupportSse, + #[error("Server does not support delete session")] + ServerDoesNotSupportDeleteSession, + #[error("Tokio join error: {0}")] + TokioJoinError(#[from] tokio::task::JoinError), + #[error("Deserialize error: {0}")] + Deserialize(#[from] serde_json::Error), + #[error("Transport channel closed")] + TransportChannelClosed, + #[error("Missing session id in HTTP response")] + MissingSessionIdInResponse, + #[cfg(feature = "auth")] + #[error("Auth error: {0}")] + Auth(#[from] crate::transport::auth::AuthError), + #[error("Auth required")] + AuthRequired(#[source] AuthRequiredError), + #[error("Insufficient scope")] + InsufficientScope(#[source] InsufficientScopeError), + #[error("Header name '{0}' is reserved and conflicts with default headers")] + ReservedHeaderConflict(String), + #[error("Session expired (HTTP 404)")] + SessionExpired, +} + +impl StreamableHttpError { + /// The `WWW-Authenticate` challenge carried by this error, when the + /// server answered 401 ([`AuthRequired`](Self::AuthRequired)) or 403 + /// ([`InsufficientScope`](Self::InsufficientScope)). Feed it to + /// [`AuthorizationRequest::with_challenge`](crate::transport::auth::AuthorizationRequest::with_challenge) + /// to authorize reactively. + #[cfg(feature = "auth")] + pub fn auth_challenge(&self) -> Option<&str> { + match self { + Self::AuthRequired(error) => Some(&error.www_authenticate_header), + Self::InsufficientScope(error) => Some(&error.www_authenticate_header), + _ => None, + } + } +} + +#[derive(Debug, Clone, Error)] +#[non_exhaustive] +pub enum StreamableHttpProtocolError { + #[error("Missing session id in response")] + MissingSessionIdInResponse, +} + +#[expect( + clippy::large_enum_variant, + reason = "boxing the streaming response would add an allocation to the common response path" +)] +#[non_exhaustive] +pub enum StreamableHttpPostResponse { + Accepted, + Json(ServerJsonRpcMessage, Option), + Sse(BoxedSseStream, Option), +} + +impl std::fmt::Debug for StreamableHttpPostResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Accepted => write!(f, "Accepted"), + Self::Json(arg0, arg1) => f.debug_tuple("Json").field(arg0).field(arg1).finish(), + Self::Sse(_, arg1) => f.debug_tuple("Sse").field(arg1).finish(), + } + } +} + +impl StreamableHttpPostResponse { + pub async fn expect_initialized( + self, + ) -> Result<(ServerJsonRpcMessage, Option), StreamableHttpError> + where + E: std::error::Error + Send + Sync + 'static, + { + match self { + Self::Json(message, session_id) => Ok((message, session_id)), + Self::Sse(mut stream, session_id) => { + while let Some(event) = stream.next().await { + let event = event?; + let payload = event.data.unwrap_or_default(); + if payload.trim().is_empty() { + continue; + } + + let message: ServerJsonRpcMessage = serde_json::from_str(&payload)?; + + if matches!(message, ServerJsonRpcMessage::Response(_)) { + return Ok((message, session_id)); + } + + debug!( + ?message, + "received message before initialize response; continuing to drain stream" + ); + } + + Err(StreamableHttpError::UnexpectedServerResponse( + "empty sse stream".into(), + )) + } + _ => Err(StreamableHttpError::UnexpectedServerResponse( + "expect initialized, accepted".into(), + )), + } + } + + pub fn expect_json(self) -> Result> + where + E: std::error::Error + Send + Sync + 'static, + { + match self { + Self::Json(message, ..) => Ok(message), + got => Err(StreamableHttpError::UnexpectedServerResponse( + format!("expect json, got {got:?}").into(), + )), + } + } + + pub fn expect_accepted_or_json(self) -> Result<(), StreamableHttpError> + where + E: std::error::Error + Send + Sync + 'static, + { + match self { + Self::Accepted => Ok(()), + // Tolerate servers that return 200 with JSON for notifications + Self::Json(..) => Ok(()), + got => Err(StreamableHttpError::UnexpectedServerResponse( + format!("expect accepted or json, got {got:?}").into(), + )), + } + } +} + +/// HTTP backend used by [`StreamableHttpClientTransport`]. +/// +/// Custom implementations that parse SSE responses must override +/// [`Self::post_message_with_max_sse_event_size`] and +/// [`Self::get_stream_with_max_sse_event_size`] to enforce the transport's +/// configured event-size limit. +pub trait StreamableHttpClient: Clone + Send + 'static { + type Error: std::error::Error + Send + Sync + 'static; + fn post_message( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_header: Option, + custom_headers: HashMap, + ) -> impl Future>> + + Send + + '_; + /// Send a message while enforcing a maximum raw SSE event size. + /// + /// `max_sse_event_size` is not a per-request option: it is the + /// transport-wide [`StreamableHttpClientTransportConfig::max_sse_event_size`] + /// value, passed identically on every call because the limit must be applied + /// inside the client (at the raw byte layer, before SSE parsing) rather than + /// by the caller. + /// + /// Custom clients that parse SSE responses should override this method. + /// The default implementation delegates to [`Self::post_message`]. + fn post_message_with_max_sse_event_size( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_header: Option, + custom_headers: HashMap, + _max_sse_event_size: usize, + ) -> impl Future>> + + Send + + '_ { + self.post_message(uri, message, session_id, auth_header, custom_headers) + } + fn delete_session( + &self, + uri: Arc, + session_id: Arc, + auth_header: Option, + custom_headers: HashMap, + ) -> impl Future>> + Send + '_; + /// Open an SSE stream, optionally scoped to a legacy session. + /// + /// `session_id` is `None` when resuming a stateless response using only + /// `last_event_id`. + fn get_stream( + &self, + uri: Arc, + session_id: Option>, + last_event_id: Option, + auth_header: Option, + custom_headers: HashMap, + ) -> impl Future< + Output = Result< + BoxStream<'static, Result>, + StreamableHttpError, + >, + > + Send + + '_; + /// Open an SSE stream while enforcing a maximum raw event size. + /// + /// `max_sse_event_size` is not a per-request option: it is the + /// transport-wide [`StreamableHttpClientTransportConfig::max_sse_event_size`] + /// value, passed identically on every call because the limit must be applied + /// inside the client (at the raw byte layer, before SSE parsing) rather than + /// by the caller. + /// + /// Custom clients that parse SSE responses should override this method. + /// The default implementation delegates to [`Self::get_stream`]. + fn get_stream_with_max_sse_event_size( + &self, + uri: Arc, + session_id: Option>, + last_event_id: Option, + auth_header: Option, + custom_headers: HashMap, + _max_sse_event_size: usize, + ) -> impl Future< + Output = Result< + BoxStream<'static, Result>, + StreamableHttpError, + >, + > + Send + + '_ { + self.get_stream(uri, session_id, last_event_id, auth_header, custom_headers) + } +} + +#[non_exhaustive] +pub struct RetryConfig { + pub max_times: Option, + pub min_duration: Duration, +} + +struct StreamableHttpClientReconnect { + pub client: C, + pub session_id: Option>, + pub uri: Arc, + pub auth_header: Option, + pub custom_headers: HashMap, + pub max_sse_event_size: usize, +} + +impl SseStreamReconnect for StreamableHttpClientReconnect { + type Error = StreamableHttpError; + type Future = BoxFuture<'static, Result>; + fn retry_connection(&mut self, last_event_id: Option<&str>) -> Self::Future { + let client = self.client.clone(); + let uri = self.uri.clone(); + let session_id = self.session_id.clone(); + let auth_header = self.auth_header.clone(); + let custom_headers = self.custom_headers.clone(); + let max_sse_event_size = self.max_sse_event_size; + let last_event_id = last_event_id.map(|s| s.to_owned()); + Box::pin(async move { + client + .get_stream_with_max_sse_event_size( + uri, + session_id, + last_event_id, + auth_header, + custom_headers, + max_sse_event_size, + ) + .await + }) + } + + fn map_fatal_stream_error(&mut self, error: SseError) -> Option { + Some(StreamableHttpError::Sse(error)) + } +} + +/// Info retained for cleaning up the session when the worker exits. +struct SessionCleanupInfo { + client: C, + uri: Arc, + session_id: Arc, + auth_header: Option, + protocol_headers: HashMap, +} + +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub struct StreamableHttpClientWorker { + pub client: C, + pub config: StreamableHttpClientTransportConfig, +} + +impl StreamableHttpClientWorker { + pub fn new_simple(url: impl Into>) -> Self { + Self { + client: C::default(), + config: StreamableHttpClientTransportConfig { + uri: url.into(), + ..Default::default() + }, + } + } +} + +impl StreamableHttpClientWorker { + pub fn new(client: C, config: StreamableHttpClientTransportConfig) -> Self { + Self { client, config } + } +} + +impl StreamableHttpClientWorker { + fn client_request_id(message: &ClientJsonRpcMessage) -> Option { + match message { + ClientJsonRpcMessage::Request(request) => Some(request.id.clone()), + _ => None, + } + } + + fn server_response_id(message: &ServerJsonRpcMessage) -> Option<&RequestId> { + match message { + ServerJsonRpcMessage::Response(response) => Some(&response.id), + ServerJsonRpcMessage::Error(error) => error.id.as_ref(), + _ => None, + } + } + + fn mark_stream_response_pending( + pending_stream_response_ids: &mut HashSet, + request_id: Option, + ) { + if let Some(request_id) = request_id { + pending_stream_response_ids.insert(request_id); + } + } + + fn clear_stream_response_pending( + pending_stream_response_ids: &mut HashSet, + message: &ServerJsonRpcMessage, + ) { + let Some(response_id) = Self::server_response_id(message) else { + return; + }; + if pending_stream_response_ids.remove(response_id) { + return; + } + if let Some(id) = response_id.numeric_string_value() { + pending_stream_response_ids.remove(&RequestId::Number(id)); + } + } + + async fn drain_queued_stream_messages( + sse_worker_rx: &mut tokio::sync::mpsc::Receiver, + context: &mut super::worker::WorkerContext, + pending_stream_response_ids: &mut HashSet, + ) -> Result<(), WorkerQuitReason>> { + loop { + match sse_worker_rx.try_recv() { + Ok(message) => { + Self::clear_stream_response_pending(pending_stream_response_ids, &message); + context.send_to_handler(message).await?; + } + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => return Ok(()), + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => return Ok(()), + } + } + } + + async fn fail_pending_stream_responses( + context: &mut super::worker::WorkerContext, + pending_stream_response_ids: &mut HashSet, + ) -> Result<(), WorkerQuitReason>> { + if pending_stream_response_ids.is_empty() { + return Ok(()); + } + + let pending_ids = std::mem::take(pending_stream_response_ids); + for id in pending_ids { + context + .send_to_handler(ServerJsonRpcMessage::error( + ErrorData::internal_error( + "streamable HTTP session was re-initialized before the response arrived", + None, + ), + Some(id), + )) + .await?; + } + Ok(()) + } + + /// Convert an SSE stream into JSON-RPC messages with reconnect semantics. + /// + /// This is used for request-scoped SSE responses as well as the standalone + /// GET stream. A request-scoped stream can close before its response arrives, + /// and SEP-1699 requires the client to honor `retry` and resume with + /// `Last-Event-ID` in that case. + fn reconnecting_sse_to_jsonrpc( + stream: BoxedSseStream, + client: C, + session_id: Option>, + uri: Arc, + auth_header: Option, + custom_headers: HashMap, + max_sse_event_size: usize, + retry_config: Arc, + ) -> impl Stream>> + Send + 'static + { + SseAutoReconnectStream::new_after_event_id( + stream, + StreamableHttpClientReconnect { + client, + session_id, + uri, + auth_header, + custom_headers, + max_sse_event_size, + }, + retry_config, + ) + } + + /// Convert a POST response SSE stream into JSON-RPC messages. + /// + /// Request-scoped streams resume via GET once the server has supplied an + /// event ID. The session header remains optional for stateless transports. + fn response_sse_to_jsonrpc( + stream: BoxedSseStream, + session_id: Option>, + client: C, + uri: Arc, + auth_header: Option, + custom_headers: HashMap, + max_sse_event_size: usize, + retry_config: Arc, + ) -> BoxStream<'static, Result>> { + Self::reconnecting_sse_to_jsonrpc( + stream, + client, + session_id, + uri, + auth_header, + custom_headers, + max_sse_event_size, + retry_config, + ) + .boxed() + } + + async fn execute_sse_stream( + sse_stream: impl Stream>> + + Send + + 'static, + sse_worker_tx: tokio::sync::mpsc::Sender, + origin: InboundStreamOrigin, + close_on_response: bool, + ct: CancellationToken, + ) -> Result<(), StreamableHttpError> { + let mut sse_stream = std::pin::pin!(sse_stream); + loop { + let message = tokio::select! { + event = sse_stream.next() => { + event + } + _ = ct.cancelled() => { + tracing::debug!("cancelled"); + break; + } + }; + let Some(mut message) = message.transpose()? else { + break; + }; + // SEP-2260: mark inbound requests with the stream they arrived on + // for the client receive-side association check. + if let ServerJsonRpcMessage::Request(request) = &mut message { + request.request.extensions_mut().insert(origin.clone()); + } + let is_response = matches!( + message, + ServerJsonRpcMessage::Response(_) | ServerJsonRpcMessage::Error(_) + ); + let yield_result = sse_worker_tx.send(message).await; + if yield_result.is_err() { + tracing::trace!("streamable http transport worker dropped, exiting"); + break; + } + if close_on_response && is_response { + tracing::debug!("got response, draining sse stream for connection reuse"); + // Consume the remaining stream so the HTTP/1.1 connection + // returns to the pool cleanly. + let _ = tokio::time::timeout(std::time::Duration::from_millis(50), async { + while sse_stream.next().await.is_some() {} + }) + .await; + break; + } + } + Ok(()) + } + + fn spawn_common_stream( + streams: &mut tokio::task::JoinSet>, + client: C, + session_id: Arc, + config: &StreamableHttpClientTransportConfig, + protocol_headers: HashMap, + sse_worker_tx: tokio::sync::mpsc::Sender, + transport_task_ct: CancellationToken, + ) { + let uri = config.uri.clone(); + let auth_header = config.auth_header.clone(); + let retry_config = config.retry_config.clone(); + let reconnect_uri = config.uri.clone(); + let reconnect_auth_header = config.auth_header.clone(); + let max_sse_event_size = config.max_sse_event_size; + + streams.spawn(async move { + let result = match client + .get_stream_with_max_sse_event_size( + uri, + Some(session_id.clone()), + None, + auth_header, + protocol_headers.clone(), + max_sse_event_size, + ) + .await + { + Ok(stream) => { + let sse_stream = SseAutoReconnectStream::new( + stream, + StreamableHttpClientReconnect { + client, + session_id: Some(session_id), + uri: reconnect_uri, + auth_header: reconnect_auth_header, + custom_headers: protocol_headers, + max_sse_event_size, + }, + retry_config, + ); + Self::execute_sse_stream( + sse_stream, + sse_worker_tx, + InboundStreamOrigin::Unassociated, + false, + transport_task_ct.child_token(), + ) + .await + } + Err(StreamableHttpError::ServerDoesNotSupportSse) => { + tracing::debug!("server doesn't support sse, skip common stream"); + Ok(()) + } + Err(error) => { + tracing::error!("fail to get common stream: {error}"); + Err(error) + } + }; + (None, result) + }); + } + + /// Performs a transparent re-initialization handshake after a session-expired 404. + /// + /// Takes an owned clone of the client (avoiding `&self` across `.await` so the + /// future remains `Send` without requiring `C: Sync`). POSTs the saved + /// initialize request without a session ID, extracts the new session ID and + /// protocol version, sends `notifications/initialized`, and returns the new + /// `(session_id, protocol_headers)` pair. The init result message is **not** + /// forwarded to the handler because the handler already processed the original + /// initialization. + async fn perform_reinitialization( + client: C, + saved_init_request: ClientJsonRpcMessage, + uri: Arc, + auth_header: Option, + custom_headers: HashMap, + max_sse_event_size: usize, + ) -> Result< + ( + Option>, + ProtocolVersion, + HashMap, + ), + StreamableHttpError, + > { + let (init_msg, new_session_id_str) = client + .post_message_with_max_sse_event_size( + uri.clone(), + saved_init_request, + None, + auth_header.clone(), + custom_headers.clone(), + max_sse_event_size, + ) + .await? + .expect_initialized::() + .await?; + + let new_session_id: Option> = new_session_id_str.map(|s| Arc::from(s.as_str())); + + let (negotiated_version, new_protocol_headers) = + negotiate_version_headers(&init_msg, custom_headers); + + let initialized_notification = ClientJsonRpcMessage::notification( + ClientNotification::InitializedNotification(InitializedNotification { + method: Default::default(), + extensions: Default::default(), + }), + ); + // SEP-2243: notifications carry no Mcp-Param-*, so an empty tool cache suffices. + let initialized_headers = build_request_headers( + &new_protocol_headers, + &initialized_notification, + &HashMap::new(), + &negotiated_version, + ); + client + .post_message_with_max_sse_event_size( + uri, + initialized_notification, + new_session_id.clone(), + auth_header, + initialized_headers, + max_sse_event_size, + ) + .await? + .expect_accepted_or_json::()?; + + Ok((new_session_id, negotiated_version, new_protocol_headers)) + } +} + +impl Worker for StreamableHttpClientWorker { + type Role = RoleClient; + type Error = StreamableHttpError; + fn err_closed() -> Self::Error { + StreamableHttpError::TransportChannelClosed + } + fn err_join(e: tokio::task::JoinError) -> Self::Error { + StreamableHttpError::TokioJoinError(e) + } + fn config(&self) -> super::worker::WorkerConfig { + super::worker::WorkerConfig { + name: Some("StreamableHttpClientWorker".into()), + channel_buffer_capacity: self.config.channel_buffer_capacity, + } + } + async fn run( + self, + mut context: super::worker::WorkerContext, + ) -> Result<(), WorkerQuitReason> { + let channel_buffer_capacity = self.config.channel_buffer_capacity; + let (sse_worker_tx, mut sse_worker_rx) = + tokio::sync::mpsc::channel::(channel_buffer_capacity); + let config = self.config.clone(); + let transport_task_ct = context.cancellation_token.clone(); + let _drop_guard = transport_task_ct.clone().drop_guard(); + let WorkerSendRequest { + responder, + message: startup_request, + } = context.recv_from_handler().await?; + let is_legacy_startup = matches!( + &startup_request, + ClientJsonRpcMessage::Request(request) + if matches!(&request.request, ClientRequest::InitializeRequest(_)) + ); + let mut saved_init_request = is_legacy_startup.then(|| startup_request.clone()); + let empty_tool_cache = HashMap::new(); + let (bootstrap_version, bootstrap_headers) = if is_legacy_startup { + (ProtocolVersion::default(), config.custom_headers.clone()) + } else { + request_version_headers( + &config.custom_headers, + &startup_request, + &ProtocolVersion::default(), + &empty_tool_cache, + ) + }; + let (message, session_id) = match self + .client + .post_message_with_max_sse_event_size( + config.uri.clone(), + startup_request, + None, + config.auth_header.clone(), + bootstrap_headers.clone(), + config.max_sse_event_size, + ) + .await + { + Ok(res) => { + let _ = responder.send(Ok(())); + res.expect_initialized::().await.map_err( + WorkerQuitReason::fatal_context("process initialize response"), + )? + } + Err(err) => { + let msg = format!("{:?}", err); + let _ = responder.send(Err(err)); + return Err(WorkerQuitReason::fatal( + StreamableHttpError::TransportChannelClosed, + msg, + )); + } + }; + let mut uses_modern_http = !is_legacy_startup; + let mut session_id: Option> = if uses_modern_http { + None + } else if let Some(session_id) = session_id { + Some(session_id.into()) + } else { + if !self.config.allow_stateless { + return Err(WorkerQuitReason::fatal( + StreamableHttpError::::MissingSessionIdInResponse, + "process initialize response", + )); + } + None + }; + let (mut negotiated_version, mut protocol_headers) = if is_legacy_startup { + negotiate_version_headers(&message, config.custom_headers.clone()) + } else { + (bootstrap_version, bootstrap_headers) + }; + // SEP-2243: tool input schemas (name -> schema) cached from tools/list responses, + // used to promote annotated tools/call arguments to Mcp-Param-* headers. + let mut tool_header_cache: HashMap> = HashMap::new(); + + // Store session info for cleanup when run() exits (not spawned, so cleanup completes before close() returns) + let mut session_cleanup_info = session_id.as_ref().map(|sid| SessionCleanupInfo { + client: self.client.clone(), + uri: config.uri.clone(), + session_id: sid.clone(), + auth_header: config.auth_header.clone(), + protocol_headers: protocol_headers.clone(), + }); + + context.send_to_handler(message).await?; + if is_legacy_startup { + let initialized_notification = context.recv_from_handler().await?; + let initialized_headers = build_request_headers( + &protocol_headers, + &initialized_notification.message, + &tool_header_cache, + &negotiated_version, + ); + self.client + .post_message_with_max_sse_event_size( + config.uri.clone(), + initialized_notification.message, + session_id.clone(), + config.auth_header.clone(), + initialized_headers, + config.max_sse_event_size, + ) + .await + .map_err(WorkerQuitReason::fatal_context( + "send initialized notification", + ))? + .expect_accepted_or_json::() + .map_err(WorkerQuitReason::fatal_context( + "process initialized notification response", + ))?; + let _ = initialized_notification.responder.send(Ok(())); + } + #[expect( + clippy::large_enum_variant, + reason = "the event is short-lived and boxing would add allocation in the event loop" + )] + enum Event { + ClientMessage(WorkerSendRequest), + ServerMessage(ServerJsonRpcMessage), + StreamResult { + request_id: Option, + result: Result<(), StreamableHttpError>, + }, + } + let mut streams = tokio::task::JoinSet::new(); + let mut pending_stream_response_ids = HashSet::new(); + let mut request_stream_cancellations = HashMap::::new(); + let mut awaiting_fallback_initialized = false; + if let Some(session_id) = &session_id { + Self::spawn_common_stream( + &mut streams, + self.client.clone(), + session_id.clone(), + &config, + protocol_headers.clone(), + sse_worker_tx.clone(), + transport_task_ct.clone(), + ); + } + // Main event loop - capture exit reason so we can do cleanup before returning + let loop_result: Result<(), WorkerQuitReason> = 'main_loop: loop { + let event = tokio::select! { + _ = transport_task_ct.cancelled() => { + tracing::debug!("cancelled"); + break 'main_loop Err(WorkerQuitReason::Cancelled); + } + message = context.recv_from_handler() => { + match message { + Ok(msg) => Event::ClientMessage(msg), + Err(e) => break 'main_loop Err(e), + } + }, + message = sse_worker_rx.recv() => { + let Some(message) = message else { + tracing::trace!("transport dropped, exiting"); + break 'main_loop Err(WorkerQuitReason::HandlerTerminated); + }; + Event::ServerMessage(message) + }, + terminated_stream = streams.join_next(), if !streams.is_empty() => { + match terminated_stream { + Some(result) => { + match result { + Ok((request_id, result)) => { + Event::StreamResult { request_id, result } + } + Err(error) => Event::StreamResult { + request_id: None, + result: Err(StreamableHttpError::TokioJoinError(error)), + }, + } + } + None => { + continue + } + } + } + }; + match event { + Event::ClientMessage(send_request) => { + let WorkerSendRequest { message, responder } = send_request; + let cancellation_request_id = match &message { + ClientJsonRpcMessage::Notification(notification) => { + match ¬ification.notification { + ClientNotification::CancelledNotification(cancelled) => { + cancelled.params.request_id.clone() + } + _ => None, + } + } + _ => None, + }; + if uses_modern_http && let Some(request_id) = cancellation_request_id { + if let Some(stream_ct) = request_stream_cancellations.remove(&request_id) { + stream_ct.cancel(); + } + pending_stream_response_ids.remove(&request_id); + let _ = responder.send(Ok(())); + continue; + } + let is_fallback_initialize = saved_init_request.is_none() + && matches!( + &message, + ClientJsonRpcMessage::Request(request) + if matches!( + &request.request, + ClientRequest::InitializeRequest(_) + ) + ); + if is_fallback_initialize { + saved_init_request = Some(message.clone()); + // Servers do not assign sessions to `server/discover`, so a + // fallback initialize starts from a clean slate: no session + // ID, no cleanup state, and no streams to tear down. + debug_assert!( + session_id.is_none() + && session_cleanup_info.is_none() + && streams.is_empty(), + "discover bootstrap must not create session state" + ); + uses_modern_http = false; + + let response = self + .client + .post_message_with_max_sse_event_size( + config.uri.clone(), + message, + None, + config.auth_header.clone(), + config.custom_headers.clone(), + config.max_sse_event_size, + ) + .await; + let response = match response { + Ok(response) => { + let _ = responder.send(Ok(())); + response + } + Err(error) => { + let _ = responder.send(Err(error)); + continue; + } + }; + let (initialize_response, new_session_id) = response + .expect_initialized::() + .await + .map_err(WorkerQuitReason::fatal_context( + "process fallback initialize response", + ))?; + session_id = new_session_id.map(Arc::from); + if session_id.is_none() && !config.allow_stateless { + return Err(WorkerQuitReason::fatal( + StreamableHttpError::::MissingSessionIdInResponse, + "process fallback initialize response", + )); + } + (negotiated_version, protocol_headers) = negotiate_version_headers( + &initialize_response, + config.custom_headers.clone(), + ); + session_cleanup_info = + session_id.as_ref().map(|session_id| SessionCleanupInfo { + client: self.client.clone(), + uri: config.uri.clone(), + session_id: session_id.clone(), + auth_header: config.auth_header.clone(), + protocol_headers: protocol_headers.clone(), + }); + context.send_to_handler(initialize_response).await?; + awaiting_fallback_initialized = true; + continue; + } + + let request_id = Self::client_request_id(&message); + let inline_version = match &message { + ClientJsonRpcMessage::Request(request) => { + request.request.get_meta().protocol_version() + } + _ => None, + }; + let is_initialized_notification = matches!( + &message, + ClientJsonRpcMessage::Notification(notification) + if matches!( + ¬ification.notification, + ClientNotification::InitializedNotification(_) + ) + ); + // Pass a clone to the first attempt so `message` is retained for a + // potential re-init retry. `post_message` takes ownership and the + // trait cannot be changed, so the clone is unavoidable. + let (request_version, request_headers) = request_version_headers( + &protocol_headers, + &message, + &negotiated_version, + &tool_header_cache, + ); + if inline_version.is_some() { + negotiated_version = request_version.clone(); + if let Ok(value) = HeaderValue::from_str(request_version.as_str()) { + protocol_headers + .insert(HeaderName::from_static("mcp-protocol-version"), value); + } + if let Some(cleanup) = &mut session_cleanup_info { + cleanup.protocol_headers = protocol_headers.clone(); + } + } + let response = self + .client + .post_message_with_max_sse_event_size( + config.uri.clone(), + message.clone(), + session_id.clone(), + config.auth_header.clone(), + request_headers, + config.max_sse_event_size, + ) + .await; + let send_result = match response { + Err(StreamableHttpError::SessionExpired) => { + if let Some(saved_init_request) = saved_init_request + .as_ref() + .filter(|_| config.reinit_on_expired_session) + { + // The server discarded the session (HTTP 404). Perform a + // fresh handshake once and replay the original message. + tracing::info!( + "session expired (HTTP 404), attempting transparent re-initialization" + ); + match Self::perform_reinitialization( + self.client.clone(), + saved_init_request.clone(), + config.uri.clone(), + config.auth_header.clone(), + config.custom_headers.clone(), + config.max_sse_event_size, + ) + .await + { + Ok(( + new_session_id, + new_negotiated_version, + new_protocol_headers, + )) => { + // Old streams hold the stale session ID. Stop them first + // so no late stale-session messages can arrive after the + // pending requests below are completed. + streams.abort_all(); + while streams.join_next().await.is_some() {} + + // Forward any already queued response messages and fail + // the remaining accepted requests so callers do not wait + // forever for responses that can no longer arrive. + Self::drain_queued_stream_messages( + &mut sse_worker_rx, + &mut context, + &mut pending_stream_response_ids, + ) + .await?; + Self::fail_pending_stream_responses( + &mut context, + &mut pending_stream_response_ids, + ) + .await?; + + session_id = new_session_id; + negotiated_version = new_negotiated_version; + protocol_headers = new_protocol_headers; + session_cleanup_info = + session_id.as_ref().map(|sid| SessionCleanupInfo { + client: self.client.clone(), + uri: config.uri.clone(), + session_id: sid.clone(), + auth_header: config.auth_header.clone(), + protocol_headers: protocol_headers.clone(), + }); + + if let Some(new_sid) = &session_id { + Self::spawn_common_stream( + &mut streams, + self.client.clone(), + new_sid.clone(), + &config, + protocol_headers.clone(), + sse_worker_tx.clone(), + transport_task_ct.clone(), + ); + } + + let (_, retry_headers) = request_version_headers( + &protocol_headers, + &message, + &negotiated_version, + &tool_header_cache, + ); + let retry_response = self + .client + .post_message_with_max_sse_event_size( + config.uri.clone(), + message, + session_id.clone(), + config.auth_header.clone(), + retry_headers, + config.max_sse_event_size, + ) + .await; + match retry_response { + Err(e) => Err(e), + Ok(StreamableHttpPostResponse::Accepted) => { + Self::mark_stream_response_pending( + &mut pending_stream_response_ids, + request_id, + ); + tracing::trace!( + "client message accepted after re-init" + ); + Ok(()) + } + Ok(StreamableHttpPostResponse::Json(mut msg, ..)) => { + cache_tools_from_response( + &mut tool_header_cache, + &mut msg, + &negotiated_version, + ); + context.send_to_handler(msg).await?; + Ok(()) + } + Ok(StreamableHttpPostResponse::Sse(stream, ..)) => { + let stream_request_id = request_id.clone(); + Self::mark_stream_response_pending( + &mut pending_stream_response_ids, + request_id, + ); + let sse_stream = Self::response_sse_to_jsonrpc( + stream, + session_id.clone(), + self.client.clone(), + config.uri.clone(), + config.auth_header.clone(), + protocol_headers.clone(), + config.max_sse_event_size, + self.config.retry_config.clone(), + ); + let stream_ct = transport_task_ct.child_token(); + if uses_modern_http + && let Some(request_id) = + stream_request_id.as_ref() + { + request_stream_cancellations.insert( + request_id.clone(), + stream_ct.clone(), + ); + } + let stream_tx = sse_worker_tx.clone(); + let origin = match &stream_request_id { + Some(id) => { + InboundStreamOrigin::OutboundRequest( + id.clone(), + ) + } + None => InboundStreamOrigin::Unassociated, + }; + streams.spawn(async move { + let result = Self::execute_sse_stream( + sse_stream, stream_tx, origin, true, + stream_ct, + ) + .await; + (stream_request_id, result) + }); + tracing::trace!("got new sse stream after re-init"); + Ok(()) + } + } + } + Err(reinit_err) => Err(reinit_err), + } + } else { + Err(StreamableHttpError::SessionExpired) + } + } + Err(e) => Err(e), + Ok(StreamableHttpPostResponse::Accepted) => { + Self::mark_stream_response_pending( + &mut pending_stream_response_ids, + request_id, + ); + tracing::trace!("client message accepted"); + Ok(()) + } + Ok(StreamableHttpPostResponse::Json(mut message, ..)) => { + cache_tools_from_response( + &mut tool_header_cache, + &mut message, + &negotiated_version, + ); + context.send_to_handler(message).await?; + Ok(()) + } + Ok(StreamableHttpPostResponse::Sse(stream, ..)) => { + let stream_request_id = request_id.clone(); + Self::mark_stream_response_pending( + &mut pending_stream_response_ids, + request_id, + ); + let sse_stream = Self::response_sse_to_jsonrpc( + stream, + session_id.clone(), + self.client.clone(), + config.uri.clone(), + config.auth_header.clone(), + protocol_headers.clone(), + config.max_sse_event_size, + self.config.retry_config.clone(), + ); + let stream_ct = transport_task_ct.child_token(); + if uses_modern_http && let Some(request_id) = stream_request_id.as_ref() + { + request_stream_cancellations + .insert(request_id.clone(), stream_ct.clone()); + } + let stream_tx = sse_worker_tx.clone(); + let origin = match &stream_request_id { + Some(id) => InboundStreamOrigin::OutboundRequest(id.clone()), + None => InboundStreamOrigin::Unassociated, + }; + streams.spawn(async move { + let result = Self::execute_sse_stream( + sse_stream, stream_tx, origin, true, stream_ct, + ) + .await; + (stream_request_id, result) + }); + tracing::trace!("got new sse stream"); + Ok(()) + } + }; + if send_result.is_ok() + && awaiting_fallback_initialized + && is_initialized_notification + { + if let Some(session_id) = &session_id { + Self::spawn_common_stream( + &mut streams, + self.client.clone(), + session_id.clone(), + &config, + protocol_headers.clone(), + sse_worker_tx.clone(), + transport_task_ct.clone(), + ); + } + awaiting_fallback_initialized = false; + } + let _ = responder.send(send_result); + } + Event::ServerMessage(mut json_rpc_message) => { + if let Some(response_id) = Self::server_response_id(&json_rpc_message) + && let Some(stream_ct) = crate::service::remove_pending_request( + &mut request_stream_cancellations, + response_id, + ) + { + stream_ct.cancel(); + } + Self::clear_stream_response_pending( + &mut pending_stream_response_ids, + &json_rpc_message, + ); + cache_tools_from_response( + &mut tool_header_cache, + &mut json_rpc_message, + &negotiated_version, + ); + // send the message to the handler + if let Err(e) = context.send_to_handler(json_rpc_message).await { + break 'main_loop Err(e); + } + } + Event::StreamResult { request_id, result } => { + if let Some(request_id) = request_id { + Self::drain_queued_stream_messages( + &mut sse_worker_rx, + &mut context, + &mut pending_stream_response_ids, + ) + .await?; + request_stream_cancellations.remove(&request_id); + if pending_stream_response_ids.remove(&request_id) { + context + .send_to_handler(ServerJsonRpcMessage::error( + ErrorData::transport_closed( + "streamable HTTP response stream closed before its final response", + ), + Some(request_id), + )) + .await?; + } + } + if result.is_err() { + tracing::warn!( + "sse client event stream terminated with error: {:?}", + result + ); + } + } + } + }; + + // Cleanup session before returning (ensures close() waits for session deletion) + // Use a timeout to prevent indefinite hangs if the server is unresponsive + if let Some(cleanup) = session_cleanup_info { + let cleanup_session_id = cleanup.session_id.clone(); + match tokio::time::timeout( + SESSION_CLEANUP_TIMEOUT, + cleanup.client.delete_session( + cleanup.uri, + cleanup.session_id, + cleanup.auth_header, + cleanup.protocol_headers, + ), + ) + .await + { + Ok(Ok(_)) => { + tracing::info!( + session_id = cleanup_session_id.as_ref(), + "delete session success" + ) + } + Ok(Err(StreamableHttpError::ServerDoesNotSupportDeleteSession)) => { + tracing::info!( + session_id = cleanup_session_id.as_ref(), + "server doesn't support delete session" + ) + } + Ok(Err(e)) => { + tracing::error!( + session_id = cleanup_session_id.as_ref(), + "fail to delete session: {e}" + ); + } + Err(_elapsed) => { + tracing::warn!( + session_id = cleanup_session_id.as_ref(), + "session cleanup timed out after {:?}", + SESSION_CLEANUP_TIMEOUT + ); + } + } + } + + loop_result + } +} + +/// A client-agnostic HTTP transport for RMCP that supports streaming responses. +/// +/// This transport allows you to choose your preferred HTTP client implementation +/// by implementing the [`StreamableHttpClient`] trait. The transport handles +/// session management, SSE streaming, and automatic reconnection. +/// +/// # Usage +/// +/// ## Using reqwest +/// +/// ```rust,no_run +/// use rmcp::transport::StreamableHttpClientTransport; +/// +/// // Enable the reqwest feature in Cargo.toml: +/// // rmcp = { version = "0.5", features = ["transport-streamable-http-client-reqwest"] } +/// +/// let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/mcp"); +/// ``` +/// +/// ## Using a custom HTTP client +/// +/// ```rust,no_run +/// use rmcp::transport::streamable_http_client::{ +/// StreamableHttpClient, +/// StreamableHttpClientTransport, +/// StreamableHttpClientTransportConfig +/// }; +/// use std::sync::Arc; +/// use std::collections::HashMap; +/// use futures::stream::BoxStream; +/// use rmcp::model::ClientJsonRpcMessage; +/// use http::{HeaderName, HeaderValue}; +/// use sse_stream::{Sse, Error as SseError}; +/// +/// #[derive(Clone)] +/// struct MyHttpClient; +/// +/// #[derive(Debug, thiserror::Error)] +/// struct MyError; +/// +/// impl std::fmt::Display for MyError { +/// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +/// write!(f, "MyError") +/// } +/// } +/// +/// impl StreamableHttpClient for MyHttpClient { +/// type Error = MyError; +/// +/// async fn post_message( +/// &self, +/// _uri: Arc, +/// _message: ClientJsonRpcMessage, +/// _session_id: Option>, +/// _auth_header: Option, +/// _custom_headers: HashMap, +/// ) -> Result> { +/// todo!() +/// } +/// +/// async fn delete_session( +/// &self, +/// _uri: Arc, +/// _session_id: Arc, +/// _auth_header: Option, +/// _custom_headers: HashMap, +/// ) -> Result<(), rmcp::transport::streamable_http_client::StreamableHttpError> { +/// todo!() +/// } +/// +/// async fn get_stream( +/// &self, +/// _uri: Arc, +/// _session_id: Option>, +/// _last_event_id: Option, +/// _auth_header: Option, +/// _custom_headers: HashMap, +/// ) -> Result>, rmcp::transport::streamable_http_client::StreamableHttpError> { +/// todo!() +/// } +/// } +/// +/// let transport = StreamableHttpClientTransport::with_client( +/// MyHttpClient, +/// StreamableHttpClientTransportConfig::with_uri("http://localhost:8000/mcp") +/// ); +/// ``` +/// +/// # Feature Flags +/// +/// - `transport-streamable-http-client`: Base feature providing the generic transport infrastructure +/// - `transport-streamable-http-client-reqwest`: Includes reqwest HTTP client support with convenience methods +pub type StreamableHttpClientTransport = WorkerTransport>; + +impl StreamableHttpClientTransport { + /// Creates a new transport with a custom HTTP client implementation. + /// + /// This method allows you to use any HTTP client that implements the [`StreamableHttpClient`] trait. + /// Use this when you want to use a custom HTTP client or when the reqwest feature is not enabled. + /// + /// # Arguments + /// + /// * `client` - Your HTTP client implementation + /// * `config` - Transport configuration including the server URI + /// + /// # Example + /// + /// ```rust,no_run + /// use rmcp::transport::streamable_http_client::{ + /// StreamableHttpClient, + /// StreamableHttpClientTransport, + /// StreamableHttpClientTransportConfig + /// }; + /// use std::sync::Arc; + /// use std::collections::HashMap; + /// use futures::stream::BoxStream; + /// use rmcp::model::ClientJsonRpcMessage; + /// use http::{HeaderName, HeaderValue}; + /// use sse_stream::{Sse, Error as SseError}; + /// + /// // Define your custom client + /// #[derive(Clone)] + /// struct MyHttpClient; + /// + /// #[derive(Debug, thiserror::Error)] + /// struct MyError; + /// + /// impl std::fmt::Display for MyError { + /// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + /// write!(f, "MyError") + /// } + /// } + /// + /// impl StreamableHttpClient for MyHttpClient { + /// type Error = MyError; + /// + /// async fn post_message( + /// &self, + /// _uri: Arc, + /// _message: ClientJsonRpcMessage, + /// _session_id: Option>, + /// _auth_header: Option, + /// _custom_headers: HashMap, + /// ) -> Result> { + /// todo!() + /// } + /// + /// async fn delete_session( + /// &self, + /// _uri: Arc, + /// _session_id: Arc, + /// _auth_header: Option, + /// _custom_headers: HashMap, + /// ) -> Result<(), rmcp::transport::streamable_http_client::StreamableHttpError> { + /// todo!() + /// } + /// + /// async fn get_stream( + /// &self, + /// _uri: Arc, + /// _session_id: Option>, + /// _last_event_id: Option, + /// _auth_header: Option, + /// _custom_headers: HashMap, + /// ) -> Result>, rmcp::transport::streamable_http_client::StreamableHttpError> { + /// todo!() + /// } + /// } + /// + /// let transport = StreamableHttpClientTransport::with_client( + /// MyHttpClient, + /// StreamableHttpClientTransportConfig::with_uri("http://localhost:8000/mcp") + /// ); + /// ``` + pub fn with_client(client: C, config: StreamableHttpClientTransportConfig) -> Self { + let worker = StreamableHttpClientWorker::new(client, config); + WorkerTransport::spawn(worker) + } +} +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct StreamableHttpClientTransportConfig { + pub uri: Arc, + pub retry_config: Arc, + pub channel_buffer_capacity: usize, + /// if true, the transport will not require a session to be established + pub allow_stateless: bool, + /// The value to send in the authorization header + pub auth_header: Option, + /// Custom HTTP headers to include with every request + pub custom_headers: HashMap, + /// Maximum raw size of one SSE event accepted from the server. + /// + /// The built-in reqwest and Unix socket clients enforce this value. Custom + /// [`StreamableHttpClient`] implementations must override the corresponding + /// `*_with_max_sse_event_size` methods to enforce it. + pub max_sse_event_size: usize, + /// Enables transparent recovery when the server reports an expired session (`HTTP 404`). + /// + /// When enabled, the transport performs one automatic recovery attempt: + /// 1. Replays the original `initialize` handshake to create a new session. + /// 2. Re-establishes streaming state for that session. + /// 3. Retries the in-flight request that failed with `SessionExpired`. + /// + /// This recovery is best-effort and bounded to a single attempt. If recovery fails, + /// the original failure path is preserved and the error is returned to the caller. + pub reinit_on_expired_session: bool, +} + +impl StreamableHttpClientTransportConfig { + pub fn with_uri(uri: impl Into>) -> Self { + Self { + uri: uri.into(), + ..Default::default() + } + } + + /// Set the authorization header to send with requests + /// + /// # Arguments + /// + /// * `value` - A bearer token without the `Bearer ` prefix + pub fn auth_header>(mut self, value: T) -> Self { + // set our authorization header + self.auth_header = Some(value.into()); + self + } + + /// Set custom HTTP headers to include with every request + /// + /// # Arguments + /// + /// * `custom_headers` - A HashMap of header names to header values + /// + /// # Example + /// + /// ```rust,no_run + /// use std::collections::HashMap; + /// use http::{HeaderName, HeaderValue}; + /// use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; + /// + /// let mut headers = HashMap::new(); + /// headers.insert( + /// HeaderName::from_static("x-custom-header"), + /// HeaderValue::from_static("custom-value") + /// ); + /// + /// let config = StreamableHttpClientTransportConfig::with_uri("http://localhost:8000") + /// .custom_headers(headers); + /// ``` + pub fn custom_headers(mut self, custom_headers: HashMap) -> Self { + self.custom_headers = custom_headers; + self + } + + /// Set the maximum raw size of one SSE event accepted from the server. + pub fn max_sse_event_size(mut self, bytes: usize) -> Self { + self.max_sse_event_size = bytes; + self + } + + /// Set whether the transport should attempt transparent re-initialization on session expiration + /// See [`Self::reinit_on_expired_session`] for details. + /// # Example + /// ```rust,no_run + /// use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; + /// let config = StreamableHttpClientTransportConfig::with_uri("http://localhost:8000") + /// .reinit_on_expired_session(true); + /// ``` + pub fn reinit_on_expired_session(mut self, enable: bool) -> Self { + self.reinit_on_expired_session = enable; + self + } +} + +impl Default for StreamableHttpClientTransportConfig { + fn default() -> Self { + Self { + uri: "localhost".into(), + retry_config: Arc::new(ExponentialBackoff::default()), + channel_buffer_capacity: 16, + allow_stateless: true, + auth_header: None, + custom_headers: HashMap::new(), + max_sse_event_size: DEFAULT_MAX_SSE_EVENT_SIZE, + reinit_on_expired_session: true, + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use serde_json::json; + + use super::*; + use crate::{ + model::{ + GetExtensions, ListToolsResult, NumberOrString, ServerRequest, ServerResult, Tool, + }, + service::InboundStreamOrigin, + }; + + #[expect( + deprecated, + reason = "Sampling is deprecated by SEP-2577 but remains the canonical restricted request" + )] + fn sampling_request_message(id: i64) -> ServerJsonRpcMessage { + use crate::model::{CreateMessageRequest, CreateMessageRequestParams, SamplingMessage}; + ServerJsonRpcMessage::request( + ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new(vec![SamplingMessage::user_text("hi")], 16), + )), + NumberOrString::Number(id), + ) + } + + #[tokio::test] + async fn execute_sse_stream_marks_inbound_requests_with_origin() { + for origin in [ + InboundStreamOrigin::Unassociated, + InboundStreamOrigin::OutboundRequest(RequestId::Number(3)), + ] { + let response = ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(ListToolsResult::default()), + NumberOrString::Number(1), + ); + let stream = futures::stream::iter([Ok(sampling_request_message(9)), Ok(response)]); + let (tx, mut rx) = tokio::sync::mpsc::channel(4); + StreamableHttpClientWorker::::execute_sse_stream( + stream, + tx, + origin.clone(), + false, + CancellationToken::new(), + ) + .await + .expect("stream completes"); + + let ServerJsonRpcMessage::Request(request) = + rx.recv().await.expect("request forwarded") + else { + panic!("expected request first"); + }; + assert_eq!( + request.request.extensions().get::(), + Some(&origin), + "inbound requests must carry their stream origin" + ); + // Responses are correlated by JSON-RPC id; no marker needed or added. + assert!(matches!( + rx.recv().await.expect("response forwarded"), + ServerJsonRpcMessage::Response(_) + )); + } + } + + type ReconnectAttempt = (Option, Option); + + #[derive(Clone, Default)] + struct StatelessReconnectClient { + reconnects: Arc>>, + } + + impl StreamableHttpClient for StatelessReconnectClient { + type Error = std::io::Error; + + async fn post_message( + &self, + _uri: Arc, + _message: ClientJsonRpcMessage, + _session_id: Option>, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result> { + Err(StreamableHttpError::UnexpectedServerResponse( + "unexpected POST".into(), + )) + } + + async fn delete_session( + &self, + _uri: Arc, + _session_id: Arc, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result<(), StreamableHttpError> { + Ok(()) + } + + async fn get_stream( + &self, + _uri: Arc, + session_id: Option>, + last_event_id: Option, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result> { + self.reconnects + .lock() + .expect("lock reconnects") + .push((session_id.map(|id| id.to_string()), last_event_id)); + let response = ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(ListToolsResult::default()), + NumberOrString::Number(1), + ); + Ok(futures::stream::once(async move { + Ok(Sse { + event: None, + data: Some(serde_json::to_string(&response).expect("serialize response")), + id: Some("event-1".into()), + retry: None, + }) + }) + .boxed()) + } + } + + #[tokio::test] + async fn stateless_response_reconnects_with_last_event_id() { + let initial = futures::stream::iter([Ok(Sse { + event: None, + data: None, + id: Some("event-0".into()), + retry: Some(0), + })]) + .boxed(); + let client = StatelessReconnectClient::default(); + let reconnects = client.reconnects.clone(); + let stream = + StreamableHttpClientWorker::::response_sse_to_jsonrpc( + initial, + None, + client, + Arc::from("http://localhost/mcp"), + None, + HashMap::new(), + DEFAULT_MAX_SSE_EVENT_SIZE, + Arc::new(ExponentialBackoff { + max_times: Some(1), + base_duration: Duration::ZERO, + }), + ); + let mut stream = std::pin::pin!(stream); + + let message = stream.next().await.expect("replayed response").unwrap(); + + assert!(matches!(message, ServerJsonRpcMessage::Response(_))); + assert_eq!( + reconnects.lock().expect("lock reconnects").as_slice(), + &[(None, Some("event-0".into()))] + ); + } + + #[derive(Clone, Default)] + struct ResumedRequestClient { + reconnects: Arc>>, + } + + impl StreamableHttpClient for ResumedRequestClient { + type Error = std::io::Error; + + async fn post_message( + &self, + _uri: Arc, + _message: ClientJsonRpcMessage, + _session_id: Option>, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result> { + Err(StreamableHttpError::UnexpectedServerResponse( + "unexpected POST".into(), + )) + } + + async fn delete_session( + &self, + _uri: Arc, + _session_id: Arc, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result<(), StreamableHttpError> { + Ok(()) + } + + async fn get_stream( + &self, + _uri: Arc, + session_id: Option>, + last_event_id: Option, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result> { + self.reconnects + .lock() + .expect("lock reconnects") + .push((session_id.map(|id| id.to_string()), last_event_id)); + let request = sampling_request_message(9); + let response = ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(ListToolsResult::default()), + NumberOrString::Number(1), + ); + // Stay open after the response, like a live connection, so the + // post-response drain in `execute_sse_stream` doesn't trigger + // further reconnects. + Ok(futures::stream::iter([request, response].map(|message| { + Ok(Sse { + event: None, + data: Some(serde_json::to_string(&message).expect("serialize message")), + id: None, + retry: None, + }) + })) + .chain(futures::stream::pending()) + .boxed()) + } + } + + /// SEP-1699 resumes a broken POST SSE stream via GET + Last-Event-ID + /// beneath `execute_sse_stream`, so the SEP-2260 origin marker must span + /// resumes; if reconnection were hoisted above the marker attach point, + /// replayed associated requests would be wrongly rejected with -32602. + #[tokio::test] + async fn resumed_post_stream_requests_keep_outbound_origin() { + let initial = futures::stream::iter([Ok(Sse { + event: None, + data: None, + id: Some("e1".into()), + retry: Some(0), + })]) + .boxed(); + let client = ResumedRequestClient::default(); + let reconnects = client.reconnects.clone(); + let sse_stream = + StreamableHttpClientWorker::::response_sse_to_jsonrpc( + initial, + None, + client, + Arc::from("http://localhost/mcp"), + None, + HashMap::new(), + DEFAULT_MAX_SSE_EVENT_SIZE, + Arc::new(ExponentialBackoff { + max_times: Some(1), + base_duration: Duration::ZERO, + }), + ); + + let origin = InboundStreamOrigin::OutboundRequest(RequestId::Number(3)); + let (tx, mut rx) = tokio::sync::mpsc::channel(4); + StreamableHttpClientWorker::::execute_sse_stream( + sse_stream, + tx, + origin.clone(), + true, + CancellationToken::new(), + ) + .await + .expect("stream completes"); + + assert_eq!( + reconnects.lock().expect("lock reconnects").as_slice(), + &[(None, Some("e1".into()))], + "the request must arrive on the resumed connection" + ); + let ServerJsonRpcMessage::Request(request) = rx.recv().await.expect("request forwarded") + else { + panic!("expected request first"); + }; + assert_eq!( + request.request.extensions().get::(), + Some(&origin), + "origin marker must survive SSE resumption" + ); + assert!(matches!( + rx.recv().await.expect("response forwarded"), + ServerJsonRpcMessage::Response(_) + )); + } + + fn tool(name: &'static str, annotation: serde_json::Value) -> Tool { + let schema = json!({ + "type": "object", + "properties": { + "value": annotation, + }, + }); + Tool::new( + name, + name, + Arc::new(schema.as_object().expect("object schema").clone()), + ) + } + + #[test] + fn cache_tools_removes_invalid_header_annotations() { + let valid = tool( + "valid", + json!({ "type": "string", "x-mcp-header": "Value" }), + ); + let invalid = tool("invalid", json!({ "type": "string", "x-mcp-header": "" })); + let mut message = ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(ListToolsResult::with_all_items(vec![valid, invalid])), + NumberOrString::Number(1), + ); + let mut cache = HashMap::new(); + + cache_tools_from_response(&mut cache, &mut message, &ProtocolVersion::V_2026_07_28); + + let ServerJsonRpcMessage::Response(response) = &mut message else { + panic!("expected tools/list response"); + }; + let ServerResult::ListToolsResult(result) = &mut response.result else { + panic!("expected tools/list result"); + }; + assert_eq!( + ( + result + .tools + .iter() + .map(|tool| tool.name.as_ref()) + .collect::>(), + cache.keys().map(String::as_str).collect::>(), + ), + (vec!["valid"], vec!["valid"]) + ); + } + + #[test] + fn cache_tools_preserves_pre_standard_header_results() { + let invalid = tool("legacy", json!({ "type": "string", "x-mcp-header": "" })); + let mut message = ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(ListToolsResult::with_all_items(vec![invalid])), + NumberOrString::Number(1), + ); + let mut cache = HashMap::new(); + + cache_tools_from_response(&mut cache, &mut message, &ProtocolVersion::V_2025_11_25); + + let ServerJsonRpcMessage::Response(response) = message else { + panic!("expected tools/list response"); + }; + let ServerResult::ListToolsResult(result) = response.result else { + panic!("expected tools/list result"); + }; + assert_eq!( + result + .tools + .iter() + .map(|tool| tool.name.as_ref()) + .collect::>(), + vec!["legacy"] + ); + } + + #[cfg(feature = "transport-streamable-http-client-reqwest")] + #[test] + fn clear_stream_response_pending_accepts_stringified_numeric_id() { + let mut pending = HashSet::from([NumberOrString::Number(1)]); + let response = ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(ListToolsResult::default()), + NumberOrString::String("1".into()), + ); + + StreamableHttpClientWorker::::clear_stream_response_pending( + &mut pending, + &response, + ); + + assert!(pending.is_empty()); + } + + #[cfg(feature = "transport-streamable-http-client-reqwest")] + #[test] + fn clear_stream_response_pending_prefers_exact_string_id() { + let string_id = NumberOrString::String("1".into()); + let mut pending = HashSet::from([NumberOrString::Number(1), string_id.clone()]); + let response = ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(ListToolsResult::default()), + string_id, + ); + + StreamableHttpClientWorker::::clear_stream_response_pending( + &mut pending, + &response, + ); + + assert_eq!(pending, HashSet::from([NumberOrString::Number(1)])); + } +} diff --git a/vendor/rmcp/src/transport/streamable_http_server.rs b/vendor/rmcp/src/transport/streamable_http_server.rs new file mode 100644 index 000000000..df1945ab2 --- /dev/null +++ b/vendor/rmcp/src/transport/streamable_http_server.rs @@ -0,0 +1,6 @@ +pub mod session; +#[cfg(all(feature = "transport-streamable-http-server", not(feature = "local")))] +pub mod tower; +pub use session::{RestoreOutcome, SessionId, SessionManager, SessionRestoreMarker}; +#[cfg(all(feature = "transport-streamable-http-server", not(feature = "local")))] +pub use tower::{StreamableHttpServerConfig, StreamableHttpService}; diff --git a/vendor/rmcp/src/transport/streamable_http_server/session.rs b/vendor/rmcp/src/transport/streamable_http_server/session.rs new file mode 100644 index 000000000..ea2e85b24 --- /dev/null +++ b/vendor/rmcp/src/transport/streamable_http_server/session.rs @@ -0,0 +1,174 @@ +//! Session management for the Streamable HTTP transport. +//! +//! A *session* groups the logically related interactions between a single MCP +//! client and the server, starting from the `initialize` handshake. The server +//! assigns each session a unique [`SessionId`] (returned to the client via the +//! `Mcp-Session-Id` response header) and the client includes that ID on every +//! subsequent request. +//! +//! Two tool calls carrying the same session ID come from the same logical +//! session; different IDs mean different clients or conversations. +//! +//! # Implementations +//! +//! * [`local::LocalSessionManager`] — in-memory session store (default). +//! * [`never::NeverSessionManager`] — rejects all session operations, used +//! when legacy session mode is disabled. +//! +//! # Custom session managers +//! +//! Implement the [`SessionManager`] trait to back sessions with a database, +//! Redis, or any other external store. + +use std::sync::Arc; + +use futures::Stream; + +pub use crate::transport::common::server_side_http::{ServerSseMessage, SessionId}; +use crate::{ + RoleServer, + model::{ClientJsonRpcMessage, ServerJsonRpcMessage}, +}; + +pub mod local; +pub mod never; +pub mod store; + +pub use store::{ + EventId, EventStore, EventStoreError, EventStream, SessionState, SessionStore, + SessionStoreError, StreamId, +}; + +/// Extension marker inserted into the `initialize` request extensions during a +/// session restore replay. Handlers can check for its presence to distinguish a +/// cross-instance restore from a genuine client-initiated `initialize` request. +/// +/// ```rust,ignore +/// if req.extensions().get::().is_some() { +/// // this is a restore replay, not a fresh client connection +/// } +/// ``` +#[non_exhaustive] +#[derive(Debug, Clone)] +pub struct SessionRestoreMarker { + pub id: SessionId, +} + +/// The outcome of a [`SessionManager::restore_session`] call. +#[non_exhaustive] +#[derive(Debug)] +pub enum RestoreOutcome { + /// The session was just re-created from external state; the caller must + /// spawn an MCP handler against the returned transport and replay the + /// `initialize` handshake. + Restored(T), + /// The session was already present in memory (e.g. a concurrent request + /// already restored it). The caller should proceed as if `has_session` + /// had returned `true` — no further action is required. + AlreadyPresent, + /// This session manager does not support external-store restore. + /// The caller should fall through to the normal 404 response. + NotSupported, +} + +/// Controls how MCP sessions are created, validated, and closed. +/// +/// The `StreamableHttpService` calls into this +/// trait for every HTTP request that carries (or should carry) a session ID. +/// +/// See the [module-level docs](self) for background on sessions. +/// +/// # SEP-2260 request association +/// +/// Server-initiated requests issued while handling a client request carry an +/// [`OriginatingRequestId`](crate::service::OriginatingRequestId) marker in +/// their non-serialized `Extensions`; the bundled local session manager uses +/// it to deliver such requests on the originating request's SSE stream. +/// Implementations that serialize messages between processes lose the marker +/// and fall back to the standalone stream, violating SEP-2260 for 2026-07-28+ +/// clients; they need their own association mechanism. +pub trait SessionManager: Send + Sync + 'static { + type Error: std::error::Error + Send + 'static; + type Transport: crate::transport::Transport; + + /// Create a new session and return its ID together with the transport + /// that will be used to exchange MCP messages within this session. + fn create_session( + &self, + ) -> impl Future> + Send; + + /// Forward the first message (the `initialize` request) to the session. + fn initialize_session( + &self, + id: &SessionId, + message: ClientJsonRpcMessage, + ) -> impl Future> + Send; + + /// Return `true` if a session with the given ID exists and is active. + fn has_session(&self, id: &SessionId) + -> impl Future> + Send; + + /// Close and remove the session. Corresponds to an HTTP DELETE request + /// with `Mcp-Session-Id`. + fn close_session(&self, id: &SessionId) + -> impl Future> + Send; + + /// Route a client request into the session and return an SSE stream + /// carrying the server's response(s). + fn create_stream( + &self, + id: &SessionId, + message: ClientJsonRpcMessage, + ) -> impl Future< + Output = Result + Send + Sync + 'static, Self::Error>, + > + Send; + + /// Accept a notification, response, or error message from the client + /// without producing a response stream. + fn accept_message( + &self, + id: &SessionId, + message: ClientJsonRpcMessage, + ) -> impl Future> + Send; + + /// Create an SSE stream not tied to a specific client request (HTTP GET). + fn create_standalone_stream( + &self, + id: &SessionId, + ) -> impl Future< + Output = Result + Send + Sync + 'static, Self::Error>, + > + Send; + + /// Resume an SSE stream from the given `Last-Event-ID`, replaying any + /// events the client missed. + fn resume( + &self, + id: &SessionId, + last_event_id: String, + ) -> impl Future< + Output = Result + Send + Sync + 'static, Self::Error>, + > + Send; + + /// Attempt to restore a previously-known session from external state, + /// creating a fresh in-memory session worker with the given `id`. + /// + /// See [`RestoreOutcome`] for the three possible results: + /// - [`RestoreOutcome::Restored`] — session re-created; caller must spawn + /// an MCP handler and replay the `initialize` handshake. + /// - [`RestoreOutcome::AlreadyPresent`] — session is already in memory + /// (e.g. a concurrent request restored it first); caller proceeds + /// normally. + /// - [`RestoreOutcome::NotSupported`] (default) — this session manager + /// does not support external-store restore; caller returns 404. + fn restore_session( + &self, + _id: SessionId, + ) -> impl Future, Self::Error>> + Send { + futures::future::ready(Ok(RestoreOutcome::NotSupported)) + } + + /// Return the shared event store used for resumable SSE streams. + fn event_store(&self) -> Option> { + None + } +} diff --git a/vendor/rmcp/src/transport/streamable_http_server/session/local.rs b/vendor/rmcp/src/transport/streamable_http_server/session/local.rs new file mode 100644 index 000000000..cc9e14893 --- /dev/null +++ b/vendor/rmcp/src/transport/streamable_http_server/session/local.rs @@ -0,0 +1,1374 @@ +use std::{ + collections::{HashMap, HashSet, VecDeque}, + num::ParseIntError, + sync::Arc, + time::{Duration, Instant}, +}; + +use futures::{Stream, StreamExt}; +use thiserror::Error; +use tokio::sync::{ + mpsc::{Receiver, Sender}, + oneshot, +}; +use tokio_stream::wrappers::ReceiverStream; +use tracing::instrument; + +use crate::{ + RoleServer, + model::{ + CancelledNotificationParam, ClientJsonRpcMessage, ClientNotification, ClientRequest, + JsonRpcNotification, JsonRpcRequest, Notification, ProgressNotificationParam, + ProgressToken, RequestId, ServerJsonRpcMessage, ServerNotification, + }, + transport::{ + WorkerTransport, + common::server_side_http::{SessionId, session_id}, + worker::{Worker, WorkerContext, WorkerQuitReason, WorkerSendRequest}, + }, +}; + +#[derive(Debug, Default)] +#[non_exhaustive] +pub struct LocalSessionManager { + pub sessions: tokio::sync::RwLock>, + pub session_config: SessionConfig, + event_store: Option>, +} + +impl LocalSessionManager { + /// Configure this session manager to use a shared event store. + pub fn with_event_store(mut self, event_store: Arc) -> Self { + self.event_store = Some(event_store); + self + } +} + +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum LocalSessionManagerError { + #[error("Session not found: {0}")] + SessionNotFound(SessionId), + #[error("Session error: {0}")] + SessionError(#[from] SessionError), + #[error("Invalid event id: {0}")] + InvalidEventId(#[from] EventIdParseError), +} +impl SessionManager for LocalSessionManager { + type Error = LocalSessionManagerError; + type Transport = WorkerTransport; + async fn create_session(&self) -> Result<(SessionId, Self::Transport), Self::Error> { + let id = session_id(); + let (handle, worker) = create_local_session_with_event_store( + id.clone(), + self.session_config.clone(), + self.event_store.clone(), + ); + self.sessions.write().await.insert(id.clone(), handle); + Ok((id, WorkerTransport::spawn(worker))) + } + async fn initialize_session( + &self, + id: &SessionId, + message: ClientJsonRpcMessage, + ) -> Result { + let sessions = self.sessions.read().await; + let handle = sessions + .get(id) + .ok_or(LocalSessionManagerError::SessionNotFound(id.clone()))?; + let response = handle.initialize(message).await?; + Ok(response) + } + async fn close_session(&self, id: &SessionId) -> Result<(), Self::Error> { + let handle = { + let mut sessions = self.sessions.write().await; + sessions.remove(id) + }; + if let Some(handle) = handle { + match handle.close().await { + // Worker already exited — nothing left to clean up. + Ok(()) | Err(SessionError::SessionServiceTerminated) => {} + Err(e) => return Err(e.into()), + } + } + Ok(()) + } + async fn has_session(&self, id: &SessionId) -> Result { + let sessions = self.sessions.read().await; + Ok(sessions.contains_key(id)) + } + async fn create_stream( + &self, + id: &SessionId, + message: ClientJsonRpcMessage, + ) -> Result + Send + 'static, Self::Error> { + let sessions = self.sessions.read().await; + let handle = sessions + .get(id) + .ok_or(LocalSessionManagerError::SessionNotFound(id.clone()))?; + let receiver = handle.establish_request_wise_channel().await?; + let http_request_id = receiver.http_request_id; + handle.push_message(message, http_request_id).await?; + Ok(ReceiverStream::new(receiver.inner)) + } + + async fn create_standalone_stream( + &self, + id: &SessionId, + ) -> Result + Send + 'static, Self::Error> { + let sessions = self.sessions.read().await; + let handle = sessions + .get(id) + .ok_or(LocalSessionManagerError::SessionNotFound(id.clone()))?; + let receiver = handle.establish_common_channel().await?; + Ok(ReceiverStream::new(receiver.inner)) + } + + async fn resume( + &self, + id: &SessionId, + last_event_id: String, + ) -> Result + Send + 'static, Self::Error> { + if let Some(event_store) = &self.event_store { + let stream = event_store + .replay_events_after(&last_event_id) + .await + .map_err(SessionError::EventStore)?; + return Ok(stream.left_stream()); + } + let sessions = self.sessions.read().await; + let handle = sessions + .get(id) + .ok_or(LocalSessionManagerError::SessionNotFound(id.clone()))?; + let receiver = handle.resume(last_event_id.parse()?).await?; + Ok(ReceiverStream::new(receiver.inner).right_stream()) + } + + async fn accept_message( + &self, + id: &SessionId, + message: ClientJsonRpcMessage, + ) -> Result<(), Self::Error> { + let sessions = self.sessions.read().await; + let handle = sessions + .get(id) + .ok_or(LocalSessionManagerError::SessionNotFound(id.clone()))?; + handle.push_message(message, None).await?; + Ok(()) + } + + async fn restore_session( + &self, + id: SessionId, + ) -> Result, Self::Error> { + let mut sessions = self.sessions.write().await; + if sessions.contains_key(&id) { + // A concurrent request already restored this session. + return Ok(RestoreOutcome::AlreadyPresent); + } + let (handle, worker) = create_local_session_with_event_store( + id.clone(), + self.session_config.clone(), + self.event_store.clone(), + ); + sessions.insert(id, handle); + Ok(RestoreOutcome::Restored(WorkerTransport::spawn(worker))) + } + + fn event_store(&self) -> Option> { + self.event_store.clone() + } +} + +/// `/request_id>` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct EventId { + http_request_id: Option, + index: usize, +} + +impl std::fmt::Display for EventId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.index)?; + match &self.http_request_id { + Some(http_request_id) => write!(f, "/{http_request_id}"), + None => write!(f, ""), + } + } +} + +#[derive(Debug, Clone, Error)] +#[non_exhaustive] +pub enum EventIdParseError { + #[error("Invalid index: {0}")] + InvalidIndex(ParseIntError), + #[error("Invalid numeric request id: {0}")] + InvalidNumericRequestId(ParseIntError), + #[error("Missing request id type")] + InvalidRequestIdType, + #[error("Missing request id")] + MissingRequestId, +} + +impl std::str::FromStr for EventId { + type Err = EventIdParseError; + fn from_str(s: &str) -> Result { + if let Some((index, request_id)) = s.split_once("/") { + let index = usize::from_str(index).map_err(EventIdParseError::InvalidIndex)?; + let request_id = u64::from_str(request_id).map_err(EventIdParseError::InvalidIndex)?; + Ok(EventId { + http_request_id: Some(request_id), + index, + }) + } else { + let index = usize::from_str(s).map_err(EventIdParseError::InvalidIndex)?; + Ok(EventId { + http_request_id: None, + index, + }) + } + } +} + +use super::{ + EventStore, EventStoreError, RestoreOutcome, ServerSseMessage, SessionManager, StreamId, +}; + +struct CachedTx { + tx: Sender, + cache: VecDeque, + http_request_id: Option, + capacity: usize, + starting_index: usize, + stream_id: StreamId, + event_store: Option>, +} + +impl CachedTx { + fn new( + tx: Sender, + http_request_id: Option, + starting_index: usize, + stream_id: StreamId, + event_store: Option>, + ) -> Self { + Self { + cache: VecDeque::with_capacity(tx.capacity()), + capacity: tx.capacity(), + tx, + http_request_id, + starting_index, + stream_id, + event_store, + } + } + fn new_common( + tx: Sender, + session_id: &SessionId, + event_store: Option>, + ) -> Self { + Self::new(tx, None, 0, format!("{session_id}:common"), event_store) + } + + fn next_event_id(&self) -> EventId { + let index = self.cache.back().map_or(self.starting_index, |m| { + m.event_id + .as_deref() + .unwrap_or_default() + .parse::() + .expect("valid event id") + .index + + 1 + }); + EventId { + http_request_id: self.http_request_id, + index, + } + } + + async fn send(&mut self, message: ServerJsonRpcMessage) -> Result<(), SessionError> { + self.store_cache_and_send(ServerSseMessage::from_message(message)) + .await + } + + async fn send_priming(&mut self, retry: Duration) -> Result<(), SessionError> { + self.store_cache_and_send(ServerSseMessage::retry(retry)) + .await + } + + async fn store_cache_and_send( + &mut self, + mut event: ServerSseMessage, + ) -> Result<(), SessionError> { + let event_id = if let Some(event_store) = &self.event_store { + event_store + .store_event(&self.stream_id, &event) + .await + .map_err(SessionError::EventStore)? + } else { + self.next_event_id().to_string() + }; + event.event_id = Some(event_id); + self.cache_and_send(event).await; + Ok(()) + } + + async fn cache_and_send(&mut self, message: ServerSseMessage) { + if self.cache.len() >= self.capacity { + self.cache.pop_front(); + self.cache.push_back(message.clone()); + } else { + self.cache.push_back(message.clone()); + } + let _ = self.tx.send(message).await.inspect_err(|e| { + let event_id = &e.0.event_id; + tracing::trace!(?event_id, "trying to send message in a closed session") + }); + } + + async fn sync(&mut self, index: usize) -> Result<(), SessionError> { + let Some(front) = self.cache.front() else { + return Ok(()); + }; + let front_event_id = front + .event_id + .as_deref() + .unwrap_or_default() + .parse::()?; + let sync_index = index.saturating_sub(front_event_id.index); + if sync_index > self.cache.len() { + // invalid index + return Err(SessionError::InvalidEventId); + } + for message in self.cache.iter().skip(sync_index) { + let send_result = self.tx.send(message.clone()).await; + if send_result.is_err() { + let event_id: EventId = message.event_id.as_deref().unwrap_or_default().parse()?; + return Err(SessionError::ChannelClosed(Some(event_id.index as u64))); + } + } + Ok(()) + } +} + +struct HttpRequestWise { + resources: HashSet, + tx: CachedTx, + completed_at: Option, +} + +type HttpRequestId = u64; +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +enum ResourceKey { + McpRequestId(RequestId), + ProgressToken(ProgressToken), +} + +pub struct LocalSessionWorker { + id: SessionId, + next_http_request_id: HttpRequestId, + tx_router: HashMap, + resource_router: HashMap, + common: CachedTx, + /// Shadow senders for secondary SSE streams (e.g. from POST EventSource + /// reconnections). These keep the HTTP connections alive via SSE keep-alive + /// without receiving notifications, preventing MCP clients from entering + /// infinite reconnect loops when multiple EventSource connections compete + /// to replace the common channel. + shadow_txs: Vec>, + event_rx: Receiver, + session_config: SessionConfig, + event_store: Option>, +} + +impl LocalSessionWorker { + pub fn id(&self) -> &SessionId { + &self.id + } +} + +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum SessionError { + #[error("Invalid request id: {0}")] + DuplicatedRequestId(HttpRequestId), + #[error("Channel closed: {0:?}")] + ChannelClosed(Option), + #[error("Cannot parse event id: {0}")] + EventIdParseError(#[from] EventIdParseError), + #[error("Session service terminated")] + SessionServiceTerminated, + #[error("Invalid event id")] + InvalidEventId, + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("Event store error: {0}")] + EventStore(#[source] EventStoreError), +} + +impl From for std::io::Error { + fn from(value: SessionError) -> Self { + match value { + SessionError::Io(io) => io, + _ => std::io::Error::other(format!("Session error: {value}")), + } + } +} + +enum OutboundChannel { + RequestWise { id: HttpRequestId, close: bool }, + Common, +} +#[derive(Debug)] +#[non_exhaustive] +pub struct StreamableHttpMessageReceiver { + pub http_request_id: Option, + pub inner: Receiver, +} + +impl LocalSessionWorker { + fn unregister_resource(&mut self, resource: &ResourceKey) { + let Some(http_request_id) = self.resource_router.remove(resource) else { + return; + }; + tracing::trace!(?resource, http_request_id, "unregister resource"); + let Some(channel) = self.tx_router.get_mut(&http_request_id) else { + tracing::warn!(http_request_id, "http request wise channel not found"); + return; + }; + if !channel.resources.is_empty() && !matches!(resource, ResourceKey::McpRequestId(_)) { + return; + } + tracing::debug!(http_request_id, "close http request wise channel"); + let resources: Vec<_> = channel.resources.drain().collect(); + channel.completed_at = Some(Instant::now()); + // Close the sender so the client's SSE stream ends, + // but keep the entry so the cache is available for + // late resume requests. + let (closed_tx, _) = tokio::sync::mpsc::channel(1); + channel.tx.tx = closed_tx; + for resource in resources { + self.resource_router.remove(&resource); + } + } + fn register_resource(&mut self, resource: ResourceKey, http_request_id: HttpRequestId) { + tracing::trace!(?resource, http_request_id, "register resource"); + if let Some(channel) = self.tx_router.get_mut(&http_request_id) { + channel.resources.insert(resource.clone()); + self.resource_router.insert(resource, http_request_id); + } + } + fn register_request( + &mut self, + request: &JsonRpcRequest, + http_request_id: HttpRequestId, + ) { + use crate::model::GetMeta; + self.register_resource( + ResourceKey::McpRequestId(request.id.clone()), + http_request_id, + ); + if let Some(progress_token) = request.request.get_meta().get_progress_token() { + self.register_resource( + ResourceKey::ProgressToken(progress_token.clone()), + http_request_id, + ); + } + } + fn catch_cancellation_notification( + &mut self, + notification: &JsonRpcNotification, + ) { + if let ClientNotification::CancelledNotification(n) = ¬ification.notification + && let Some(request_id) = n.params.request_id.clone() + { + let resource = ResourceKey::McpRequestId(request_id); + self.unregister_resource(&resource); + } + } + fn evict_expired_channels(&mut self) { + let ttl = self.session_config.completed_cache_ttl; + self.tx_router + .retain(|_, rw| rw.completed_at.is_none_or(|at| at.elapsed() < ttl)); + } + fn next_http_request_id(&mut self) -> HttpRequestId { + let id = self.next_http_request_id; + self.next_http_request_id = self.next_http_request_id.wrapping_add(1); + id + } + async fn establish_request_wise_channel( + &mut self, + ) -> Result { + let http_request_id = self.next_http_request_id(); + let (tx, rx) = tokio::sync::mpsc::channel(self.session_config.channel_capacity); + let mut cached_tx = CachedTx::new( + tx, + Some(http_request_id), + 0, + uuid::Uuid::new_v4().to_string(), + self.event_store.clone(), + ); + if let Some(retry) = self.session_config.sse_retry { + cached_tx.send_priming(retry).await?; + } + self.tx_router.insert( + http_request_id, + HttpRequestWise { + resources: Default::default(), + tx: cached_tx, + completed_at: None, + }, + ); + tracing::debug!(http_request_id, "establish new request wise channel"); + Ok(StreamableHttpMessageReceiver { + http_request_id: Some(http_request_id), + inner: rx, + }) + } + fn resolve_outbound_channel(&self, message: &ServerJsonRpcMessage) -> OutboundChannel { + match &message { + // SEP-2260: requests carrying an OriginatingRequestId marker ride the + // originating request's SSE stream, never the standalone GET stream. + ServerJsonRpcMessage::Request(json_rpc_request) => { + use crate::model::GetExtensions; + let originating = json_rpc_request + .request + .extensions() + .get::(); + match originating { + Some(originating) => match self + .resource_router + .get(&ResourceKey::McpRequestId(originating.0.clone())) + { + Some(id) => OutboundChannel::RequestWise { + id: *id, + close: false, + }, + None => { + tracing::warn!( + originating_request_id = %originating.0, + "associated server request could not be routed to its \ + originating stream (request completed or association \ + lost); falling back to standalone stream" + ); + OutboundChannel::Common + } + }, + None => OutboundChannel::Common, + } + } + ServerJsonRpcMessage::Notification(JsonRpcNotification { + notification: + ServerNotification::ProgressNotification(Notification { + params: ProgressNotificationParam { progress_token, .. }, + .. + }), + .. + }) => { + let id = self + .resource_router + .get(&ResourceKey::ProgressToken(progress_token.clone())); + + if let Some(id) = id { + OutboundChannel::RequestWise { + id: *id, + close: false, + } + } else { + OutboundChannel::Common + } + } + ServerJsonRpcMessage::Notification(JsonRpcNotification { + notification: + ServerNotification::CancelledNotification(Notification { + params: CancelledNotificationParam { request_id, .. }, + .. + }), + .. + }) => { + if let Some(req_id) = request_id { + if let Some(id) = self + .resource_router + .get(&ResourceKey::McpRequestId(req_id.clone())) + { + OutboundChannel::RequestWise { + id: *id, + close: false, + } + } else { + OutboundChannel::Common + } + } else { + OutboundChannel::Common + } + } + ServerJsonRpcMessage::Notification(_) => OutboundChannel::Common, + ServerJsonRpcMessage::Response(json_rpc_response) => { + if let Some(id) = self + .resource_router + .get(&ResourceKey::McpRequestId(json_rpc_response.id.clone())) + { + OutboundChannel::RequestWise { + id: *id, + close: true, + } + } else { + OutboundChannel::Common + } + } + ServerJsonRpcMessage::Error(json_rpc_error) => { + if let Some(id) = json_rpc_error.id.clone().and_then(|rid| { + self.resource_router + .get(&ResourceKey::McpRequestId(rid)) + .copied() + }) { + OutboundChannel::RequestWise { id, close: true } + } else { + OutboundChannel::Common + } + } + } + } + async fn handle_server_message( + &mut self, + message: ServerJsonRpcMessage, + ) -> Result<(), SessionError> { + let outbound_channel = self.resolve_outbound_channel(&message); + match outbound_channel { + OutboundChannel::RequestWise { id, close } => { + if let Some(request_wise) = self.tx_router.get_mut(&id) { + request_wise.tx.send(message).await?; + if close && let Some(channel) = self.tx_router.remove(&id) { + for resource in channel.resources { + self.resource_router.remove(&resource); + } + } + } else { + return Err(SessionError::ChannelClosed(Some(id))); + } + } + OutboundChannel::Common => self.common.send(message).await?, + } + Ok(()) + } + async fn resume( + &mut self, + last_event_id: EventId, + ) -> Result { + // Clean up closed shadow senders before processing + self.shadow_txs.retain(|tx| !tx.is_closed()); + + match last_event_id.http_request_id { + Some(http_request_id) => { + let request_wise = self + .tx_router + .get_mut(&http_request_id) + .ok_or(SessionError::ChannelClosed(Some(http_request_id)))?; + let is_completed = request_wise.completed_at.is_some(); + let (tx, rx) = tokio::sync::mpsc::channel(self.session_config.channel_capacity); + request_wise.tx.tx = tx; + let index = last_event_id.index; + request_wise.tx.sync(index).await?; + if is_completed { + // Drop the sender after replaying so the stream ends + // instead of hanging indefinitely. + let (closed_tx, _) = tokio::sync::mpsc::channel(1); + request_wise.tx.tx = closed_tx; + } + Ok(StreamableHttpMessageReceiver { + http_request_id: Some(http_request_id), + inner: rx, + }) + } + None => { + self.resume_or_shadow_common(Some(last_event_id.index)) + .await + } + } + } + + async fn establish_common_channel( + &mut self, + ) -> Result { + let last_event_index = self.event_store.is_none().then_some(0); + self.resume_or_shadow_common(last_event_index).await + } + + /// Resume the common channel, or create a shadow stream if the primary is + /// still active. + /// + /// When the primary common channel is dead (receiver dropped), replace it + /// so this stream becomes the new primary notification channel. Cached + /// messages are replayed from `last_event_index` so the client receives + /// any events it missed (including server-initiated requests). + /// + /// When the primary is still active, create a "shadow" stream — an idle SSE + /// connection kept alive by keep-alive pings. This prevents multiple + /// EventSource connections (e.g. from POST response reconnections) from + /// killing each other by repeatedly replacing the common channel sender. + async fn resume_or_shadow_common( + &mut self, + last_event_index: Option, + ) -> Result { + let is_replacing_dead_primary = self.common.tx.is_closed(); + let capacity = if is_replacing_dead_primary { + self.session_config.channel_capacity + } else { + 1 // Shadow streams only need keep-alive pings + }; + let (tx, rx) = tokio::sync::mpsc::channel(capacity); + if is_replacing_dead_primary { + // Primary common channel is dead — replace it. + tracing::debug!("Replacing dead common channel with new primary"); + self.common.tx = tx; + if let Some(last_event_index) = last_event_index { + self.common.sync(last_event_index).await?; + } + } else { + // Primary common channel is still active. Create a shadow stream + // that stays alive via SSE keep-alive but doesn't receive + // notifications. This prevents competing EventSource connections + // from killing each other's channels. + const MAX_SHADOW_STREAMS: usize = 32; + + if self.shadow_txs.len() >= MAX_SHADOW_STREAMS { + tracing::warn!( + shadow_count = self.shadow_txs.len(), + "Shadow stream limit reached, dropping oldest" + ); + self.shadow_txs.remove(0); + } + tracing::debug!( + shadow_count = self.shadow_txs.len(), + "Common channel active, creating shadow stream" + ); + self.shadow_txs.push(tx); + } + Ok(StreamableHttpMessageReceiver { + http_request_id: None, + inner: rx, + }) + } + + async fn close_sse_stream( + &mut self, + http_request_id: Option, + retry_interval: Option, + ) -> Result<(), SessionError> { + match http_request_id { + // Close a request-wise stream + Some(id) => { + let request_wise = self + .tx_router + .get_mut(&id) + .ok_or(SessionError::ChannelClosed(Some(id)))?; + + // Send priming event if retry interval is specified + if let Some(interval) = retry_interval { + request_wise.tx.send_priming(interval).await?; + } + + // Close the stream by dropping the sender + let (tx, _rx) = tokio::sync::mpsc::channel(1); + request_wise.tx.tx = tx; + + tracing::debug!( + http_request_id = id, + "closed SSE stream for server-initiated disconnection" + ); + Ok(()) + } + // Close the standalone (common) stream + None => { + // Send priming event if retry interval is specified + if let Some(interval) = retry_interval { + self.common.send_priming(interval).await?; + } + + // Close the stream by dropping the sender + let (tx, _rx) = tokio::sync::mpsc::channel(1); + self.common.tx = tx; + + // Also close all shadow streams + self.shadow_txs.clear(); + + tracing::debug!("closed standalone SSE stream for server-initiated disconnection"); + Ok(()) + } + } + } +} + +#[derive(Debug)] +#[non_exhaustive] +pub enum SessionEvent { + ClientMessage { + message: ClientJsonRpcMessage, + http_request_id: Option, + }, + EstablishRequestWiseChannel { + responder: oneshot::Sender>, + }, + CloseRequestWiseChannel { + id: HttpRequestId, + responder: oneshot::Sender>, + }, + Resume { + last_event_id: EventId, + responder: oneshot::Sender>, + }, + InitializeRequest { + request: ClientJsonRpcMessage, + responder: oneshot::Sender>, + }, + Close, + CloseSseStream { + /// The HTTP request ID to close. If `None`, closes the standalone (common) stream. + http_request_id: Option, + /// Optional retry interval. If provided, a priming event is sent before closing. + retry_interval: Option, + responder: oneshot::Sender>, + }, + EstablishCommonChannel { + responder: oneshot::Sender>, + }, +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum SessionQuitReason { + ServiceTerminated, + ClientTerminated, + ExpectInitializeRequest, + ExpectInitializeResponse, + Cancelled, +} + +#[derive(Debug, Clone)] +pub struct LocalSessionHandle { + id: SessionId, + // after all event_tx drop, inner task will be terminated + event_tx: Sender, +} + +impl LocalSessionHandle { + /// Get the session id + pub fn id(&self) -> &SessionId { + &self.id + } + + /// Close the session + pub async fn close(&self) -> Result<(), SessionError> { + self.event_tx + .send(SessionEvent::Close) + .await + .map_err(|_| SessionError::SessionServiceTerminated)?; + Ok(()) + } + + /// Send a message to the session + pub async fn push_message( + &self, + message: ClientJsonRpcMessage, + http_request_id: Option, + ) -> Result<(), SessionError> { + self.event_tx + .send(SessionEvent::ClientMessage { + message, + http_request_id, + }) + .await + .map_err(|_| SessionError::SessionServiceTerminated)?; + Ok(()) + } + + /// establish a channel for a http-request, the corresponded message from server will be + /// sent through this channel. The channel will be closed when the request is completed, + /// or you can close it manually by calling [`LocalSessionHandle::close_request_wise_channel`]. + pub async fn establish_request_wise_channel( + &self, + ) -> Result { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.event_tx + .send(SessionEvent::EstablishRequestWiseChannel { responder: tx }) + .await + .map_err(|_| SessionError::SessionServiceTerminated)?; + rx.await + .map_err(|_| SessionError::SessionServiceTerminated)? + } + + /// close the http-request wise channel. + pub async fn close_request_wise_channel( + &self, + request_id: HttpRequestId, + ) -> Result<(), SessionError> { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.event_tx + .send(SessionEvent::CloseRequestWiseChannel { + id: request_id, + responder: tx, + }) + .await + .map_err(|_| SessionError::SessionServiceTerminated)?; + rx.await + .map_err(|_| SessionError::SessionServiceTerminated)? + } + + /// Establish a common channel for general purpose messages. + pub async fn establish_common_channel( + &self, + ) -> Result { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.event_tx + .send(SessionEvent::EstablishCommonChannel { responder: tx }) + .await + .map_err(|_| SessionError::SessionServiceTerminated)?; + rx.await + .map_err(|_| SessionError::SessionServiceTerminated)? + } + + /// Resume streaming response by the last event id. This is suitable for both request wise and common channel. + pub async fn resume( + &self, + last_event_id: EventId, + ) -> Result { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.event_tx + .send(SessionEvent::Resume { + last_event_id, + responder: tx, + }) + .await + .map_err(|_| SessionError::SessionServiceTerminated)?; + rx.await + .map_err(|_| SessionError::SessionServiceTerminated)? + } + + /// Send an initialize request to the session. And wait for the initialized response. + /// + /// This is used to establish a session with the server. + pub async fn initialize( + &self, + request: ClientJsonRpcMessage, + ) -> Result { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.event_tx + .send(SessionEvent::InitializeRequest { + request, + responder: tx, + }) + .await + .map_err(|_| SessionError::SessionServiceTerminated)?; + rx.await + .map_err(|_| SessionError::SessionServiceTerminated)? + } + + /// Close an SSE stream for a specific request. + /// + /// This closes the SSE connection for a POST request stream, but keeps the session + /// and message cache active. Clients can reconnect using the `Last-Event-ID` header + /// via a GET request to resume receiving messages. + /// + /// # Arguments + /// + /// * `http_request_id` - The HTTP request ID of the stream to close + /// * `retry_interval` - Optional retry interval. If provided, a priming event is sent + pub async fn close_sse_stream( + &self, + http_request_id: HttpRequestId, + retry_interval: Option, + ) -> Result<(), SessionError> { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.event_tx + .send(SessionEvent::CloseSseStream { + http_request_id: Some(http_request_id), + retry_interval, + responder: tx, + }) + .await + .map_err(|_| SessionError::SessionServiceTerminated)?; + rx.await + .map_err(|_| SessionError::SessionServiceTerminated)? + } + + /// Close the standalone SSE stream. + /// + /// This closes the standalone SSE connection (established via GET request), + /// but keeps the session and message cache active. Clients can reconnect using + /// the `Last-Event-ID` header via a GET request to resume receiving messages. + /// + /// # Arguments + /// + /// * `retry_interval` - Optional retry interval. If provided, a priming event is sent + pub async fn close_standalone_sse_stream( + &self, + retry_interval: Option, + ) -> Result<(), SessionError> { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.event_tx + .send(SessionEvent::CloseSseStream { + http_request_id: None, + retry_interval, + responder: tx, + }) + .await + .map_err(|_| SessionError::SessionServiceTerminated)?; + rx.await + .map_err(|_| SessionError::SessionServiceTerminated)? + } +} + +pub type SessionTransport = WorkerTransport; + +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum LocalSessionWorkerError { + #[error("transport terminated")] + TransportTerminated, + #[error("unexpected message: {0:?}")] + UnexpectedEvent(SessionEvent), + #[error("fail to send initialize request {0}")] + FailToSendInitializeRequest(SessionError), + #[error("fail to handle message: {0}")] + FailToHandleMessage(SessionError), + #[error("init timeout after {}ms", _0.as_millis())] + InitTimeout(Duration), + #[error("Transport closed")] + TransportClosed, + #[error("Tokio join error {0}")] + TokioJoinError(#[from] tokio::task::JoinError), +} +impl Worker for LocalSessionWorker { + type Error = LocalSessionWorkerError; + type Role = RoleServer; + fn err_closed() -> Self::Error { + LocalSessionWorkerError::TransportClosed + } + fn err_join(e: tokio::task::JoinError) -> Self::Error { + LocalSessionWorkerError::TokioJoinError(e) + } + fn config(&self) -> crate::transport::worker::WorkerConfig { + crate::transport::worker::WorkerConfig { + name: Some(format!("streamable-http-session-{}", self.id)), + channel_buffer_capacity: self.session_config.channel_capacity, + } + } + #[instrument(name = "streamable_http_session", skip_all, fields(id = self.id.as_ref()))] + async fn run( + mut self, + mut context: WorkerContext, + ) -> Result<(), WorkerQuitReason> { + enum InnerEvent { + FromHttpService(SessionEvent), + FromHandler(WorkerSendRequest), + } + let init_timeout = self.session_config.init_timeout.unwrap_or(Duration::MAX); + let evt = tokio::select! { + evt = self.event_rx.recv() => evt.ok_or_else(|| { + WorkerQuitReason::fatal( + LocalSessionWorkerError::TransportTerminated, + "get initialize request", + ) + })?, + _ = context.cancellation_token.cancelled() => { + return Err(WorkerQuitReason::Cancelled); + } + _ = tokio::time::sleep(init_timeout) => { + return Err(WorkerQuitReason::fatal( + LocalSessionWorkerError::InitTimeout(init_timeout), + "waiting for initialize request", + )); + } + }; + let SessionEvent::InitializeRequest { request, responder } = evt else { + return Err(WorkerQuitReason::fatal( + LocalSessionWorkerError::UnexpectedEvent(evt), + "get initialize request", + )); + }; + context.send_to_handler(request).await?; + let send_initialize_response = context.recv_from_handler().await?; + responder + .send(Ok(send_initialize_response.message)) + .map_err(|_| { + WorkerQuitReason::fatal( + LocalSessionWorkerError::FailToSendInitializeRequest( + SessionError::SessionServiceTerminated, + ), + "send initialize response", + ) + })?; + send_initialize_response + .responder + .send(Ok(())) + .map_err(|_| WorkerQuitReason::HandlerTerminated)?; + let ct = context.cancellation_token.clone(); + let keep_alive = self.session_config.keep_alive.unwrap_or(Duration::MAX); + loop { + self.evict_expired_channels(); + let keep_alive_timeout = tokio::time::sleep(keep_alive); + let event = tokio::select! { + event = self.event_rx.recv() => { + if let Some(event) = event { + InnerEvent::FromHttpService(event) + } else { + return Err(WorkerQuitReason::fatal(LocalSessionWorkerError::TransportTerminated, "waiting next session event")) + } + }, + from_handler = context.recv_from_handler() => { + InnerEvent::FromHandler(from_handler?) + } + _ = ct.cancelled() => { + return Err(WorkerQuitReason::Cancelled) + } + _ = keep_alive_timeout => { + return Err(WorkerQuitReason::IdleTimeout(keep_alive)) + } + }; + match event { + InnerEvent::FromHandler(WorkerSendRequest { message, responder }) => { + // catch response + let to_unregister = match &message { + crate::model::JsonRpcMessage::Response(json_rpc_response) => { + let request_id = json_rpc_response.id.clone(); + Some(ResourceKey::McpRequestId(request_id)) + } + crate::model::JsonRpcMessage::Error(json_rpc_error) => { + json_rpc_error.id.clone().map(ResourceKey::McpRequestId) + } + _ => { + None + // no need to unregister resource + } + }; + let handle_result = self + .handle_server_message(message) + .await + .map_err(LocalSessionWorkerError::FailToHandleMessage); + let _ = responder.send(handle_result).inspect_err(|error| { + tracing::warn!(?error, "failed to send message to http service handler"); + }); + if let Some(to_unregister) = to_unregister { + self.unregister_resource(&to_unregister); + } + } + InnerEvent::FromHttpService(SessionEvent::ClientMessage { + message: json_rpc_message, + http_request_id, + }) => { + match &json_rpc_message { + crate::model::JsonRpcMessage::Request(request) => { + if let Some(http_request_id) = http_request_id { + self.register_request(request, http_request_id) + } + } + crate::model::JsonRpcMessage::Notification(notification) => { + self.catch_cancellation_notification(notification) + } + _ => {} + } + context.send_to_handler(json_rpc_message).await?; + } + InnerEvent::FromHttpService(SessionEvent::EstablishRequestWiseChannel { + responder, + }) => { + let handle_result = self.establish_request_wise_channel().await; + let _ = responder.send(handle_result); + } + InnerEvent::FromHttpService(SessionEvent::EstablishCommonChannel { responder }) => { + let handle_result = self.establish_common_channel().await; + let _ = responder.send(handle_result); + } + InnerEvent::FromHttpService(SessionEvent::CloseRequestWiseChannel { + id, + responder, + }) => { + let _handle_result = self.tx_router.remove(&id); + let _ = responder.send(Ok(())); + } + InnerEvent::FromHttpService(SessionEvent::Resume { + last_event_id, + responder, + }) => { + let handle_result = self.resume(last_event_id).await; + let _ = responder.send(handle_result); + } + InnerEvent::FromHttpService(SessionEvent::Close) => { + return Err(WorkerQuitReason::TransportClosed); + } + InnerEvent::FromHttpService(SessionEvent::CloseSseStream { + http_request_id, + retry_interval, + responder, + }) => { + let handle_result = + self.close_sse_stream(http_request_id, retry_interval).await; + let _ = responder.send(handle_result); + } + _ => { + // ignore + } + } + } + } +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct SessionConfig { + /// the capacity of the channel for the session. Default is 16. + pub channel_capacity: usize, + /// The session will be closed after this duration of inactivity. + /// + /// This serves as a safety net for cleaning up sessions whose HTTP + /// connections have silently dropped (e.g., due to an HTTP/2 + /// `RST_STREAM`). Without a timeout, such sessions become zombies: + /// the session worker keeps running indefinitely because the session + /// handle's sender is still held in the session manager, preventing + /// the worker's event channel from closing. + /// + /// Defaults to 5 minutes. Set to `None` to disable (not recommended + /// for long-running servers behind proxies). + pub keep_alive: Option, + /// SSE retry interval for priming events on request-wise streams. + /// When set, the session layer prepends a priming event with the correct + /// stream-identifying event ID to each request-wise SSE stream. + /// Default is 3 seconds, matching `StreamableHttpServerConfig::default()`. + pub sse_retry: Option, + /// How long to retain completed request-wise channel caches for late + /// resume requests. After this duration, completed entries are evicted + /// and resume will return an error. Default is 60 seconds. + pub completed_cache_ttl: Duration, + /// Maximum duration to wait for the `initialize` request after session + /// creation. If not received within this window, the session is + /// terminated. Default is 60 seconds. Set to `None` to disable. + pub init_timeout: Option, +} + +impl SessionConfig { + pub const DEFAULT_CHANNEL_CAPACITY: usize = 16; + pub const DEFAULT_KEEP_ALIVE: Duration = Duration::from_secs(300); + pub const DEFAULT_SSE_RETRY: Duration = Duration::from_secs(3); + pub const DEFAULT_COMPLETED_CACHE_TTL: Duration = Duration::from_secs(60); + pub const DEFAULT_INIT_TIMEOUT: Duration = Duration::from_secs(60); +} + +impl Default for SessionConfig { + fn default() -> Self { + Self { + channel_capacity: Self::DEFAULT_CHANNEL_CAPACITY, + keep_alive: Some(Self::DEFAULT_KEEP_ALIVE), + sse_retry: Some(Self::DEFAULT_SSE_RETRY), + completed_cache_ttl: Self::DEFAULT_COMPLETED_CACHE_TTL, + init_timeout: Some(Self::DEFAULT_INIT_TIMEOUT), + } + } +} + +/// Create a new session with the given id and configuration. +/// +/// This function will return a pair of [`LocalSessionHandle`] and [`LocalSessionWorker`]. +/// +/// You can run the [`LocalSessionWorker`] as a transport for mcp server. And use the [`LocalSessionHandle`] operate the session. +pub fn create_local_session( + id: impl Into, + config: SessionConfig, +) -> (LocalSessionHandle, LocalSessionWorker) { + create_local_session_with_event_store(id, config, None) +} + +fn create_local_session_with_event_store( + id: impl Into, + config: SessionConfig, + event_store: Option>, +) -> (LocalSessionHandle, LocalSessionWorker) { + let id = id.into(); + let (event_tx, event_rx) = tokio::sync::mpsc::channel(config.channel_capacity); + let (common_tx, _) = tokio::sync::mpsc::channel(config.channel_capacity); + let common = CachedTx::new_common(common_tx, &id, event_store.clone()); + tracing::info!(session_id = ?id, "create new session"); + let handle = LocalSessionHandle { + event_tx, + id: id.clone(), + }; + let session_worker = LocalSessionWorker { + next_http_request_id: 0, + id, + tx_router: HashMap::new(), + resource_router: HashMap::new(), + common, + shadow_txs: Vec::new(), + event_rx, + session_config: config.clone(), + event_store, + }; + (handle, session_worker) +} + +#[cfg(test)] +mod sep2260_routing_tests { + use super::*; + use crate::service::OriginatingRequestId; + + fn roots_request(originating: Option) -> ServerJsonRpcMessage { + #[expect( + deprecated, + reason = "roots is SEP-2577-deprecated; any restricted request works here" + )] + let mut request = crate::model::ListRootsRequest { + method: Default::default(), + extensions: Default::default(), + }; + if let Some(id) = originating { + request.extensions.insert(OriginatingRequestId(id)); + } + ServerJsonRpcMessage::request( + crate::model::ServerRequest::ListRootsRequest(request), + RequestId::Number(1000), + ) + } + + #[tokio::test] + async fn associated_server_request_routes_to_originating_stream() { + let (_handle, mut worker) = create_local_session("test-session", SessionConfig::default()); + let receiver = worker.establish_request_wise_channel().await.unwrap(); + let http_request_id = receiver.http_request_id.unwrap(); + let originating_id = RequestId::Number(7); + worker.register_resource( + ResourceKey::McpRequestId(originating_id.clone()), + http_request_id, + ); + + let channel = worker.resolve_outbound_channel(&roots_request(Some(originating_id))); + assert!( + matches!(channel, OutboundChannel::RequestWise { id, close: false } if id == http_request_id) + ); + } + + #[tokio::test] + async fn unassociated_server_request_routes_to_common_stream() { + let (_handle, mut worker) = create_local_session("test-session", SessionConfig::default()); + let _receiver = worker.establish_request_wise_channel().await.unwrap(); + let channel = worker.resolve_outbound_channel(&roots_request(None)); + assert!(matches!(channel, OutboundChannel::Common)); + } + + #[tokio::test] + async fn associated_request_for_completed_request_falls_back_to_common() { + // Originating request already unregistered (completion race / re-scoped + // task op): must fall back to Common, never a closed request-wise channel. + let (_handle, mut worker) = create_local_session("test-session", SessionConfig::default()); + let receiver = worker.establish_request_wise_channel().await.unwrap(); + let http_request_id = receiver.http_request_id.unwrap(); + let originating_id = RequestId::Number(7); + worker.register_resource( + ResourceKey::McpRequestId(originating_id.clone()), + http_request_id, + ); + worker.unregister_resource(&ResourceKey::McpRequestId(originating_id.clone())); + + let channel = worker.resolve_outbound_channel(&roots_request(Some(originating_id))); + assert!(matches!(channel, OutboundChannel::Common)); + } +} diff --git a/vendor/rmcp/src/transport/streamable_http_server/session/never.rs b/vendor/rmcp/src/transport/streamable_http_server/session/never.rs new file mode 100644 index 000000000..d83031c37 --- /dev/null +++ b/vendor/rmcp/src/transport/streamable_http_server/session/never.rs @@ -0,0 +1,126 @@ +use std::sync::Arc; + +use futures::Stream; +use thiserror::Error; + +use super::{EventStore, ServerSseMessage, SessionId, SessionManager}; +use crate::{ + RoleServer, + model::{ClientJsonRpcMessage, ServerJsonRpcMessage}, + transport::Transport, +}; + +#[derive(Debug, Clone, Error)] +#[error("Session management is not supported")] +#[non_exhaustive] +pub struct ErrorSessionManagementNotSupported; +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub struct NeverSessionManager { + event_store: Option>, +} + +impl NeverSessionManager { + /// Configure resumable SSE storage without enabling sessions. + pub fn with_event_store(mut self, event_store: Arc) -> Self { + self.event_store = Some(event_store); + self + } +} +#[non_exhaustive] +pub enum NeverTransport {} +impl Transport for NeverTransport { + type Error = ErrorSessionManagementNotSupported; + + fn send( + &mut self, + _item: ServerJsonRpcMessage, + ) -> impl Future> + Send + 'static { + futures::future::ready(Err(ErrorSessionManagementNotSupported)) + } + + fn receive(&mut self) -> impl Future> { + futures::future::ready(None) + } + + async fn close(&mut self) -> Result<(), Self::Error> { + Err(ErrorSessionManagementNotSupported) + } +} + +impl SessionManager for NeverSessionManager { + type Error = ErrorSessionManagementNotSupported; + type Transport = NeverTransport; + + fn create_session( + &self, + ) -> impl Future> + Send { + futures::future::ready(Err(ErrorSessionManagementNotSupported)) + } + + fn initialize_session( + &self, + _id: &SessionId, + _message: ClientJsonRpcMessage, + ) -> impl Future> + Send { + futures::future::ready(Err(ErrorSessionManagementNotSupported)) + } + + fn has_session( + &self, + _id: &SessionId, + ) -> impl Future> + Send { + futures::future::ready(Err(ErrorSessionManagementNotSupported)) + } + + fn close_session( + &self, + _id: &SessionId, + ) -> impl Future> + Send { + futures::future::ready(Err(ErrorSessionManagementNotSupported)) + } + + fn create_stream( + &self, + _id: &SessionId, + _message: ClientJsonRpcMessage, + ) -> impl Future< + Output = Result + Send + 'static, Self::Error>, + > + Send { + futures::future::ready(Result::, _>::Err( + ErrorSessionManagementNotSupported, + )) + } + fn create_standalone_stream( + &self, + _id: &SessionId, + ) -> impl Future< + Output = Result + Send + 'static, Self::Error>, + > + Send { + futures::future::ready(Result::, _>::Err( + ErrorSessionManagementNotSupported, + )) + } + fn resume( + &self, + _id: &SessionId, + _last_event_id: String, + ) -> impl Future< + Output = Result + Send + 'static, Self::Error>, + > + Send { + futures::future::ready(Result::, _>::Err( + ErrorSessionManagementNotSupported, + )) + } + fn accept_message( + &self, + _id: &SessionId, + _message: ClientJsonRpcMessage, + ) -> impl Future> + Send { + futures::future::ready(Err(ErrorSessionManagementNotSupported)) + } + + fn event_store(&self) -> Option> { + self.event_store.clone() + } +} diff --git a/vendor/rmcp/src/transport/streamable_http_server/session/store.rs b/vendor/rmcp/src/transport/streamable_http_server/session/store.rs new file mode 100644 index 000000000..8b656201c --- /dev/null +++ b/vendor/rmcp/src/transport/streamable_http_server/session/store.rs @@ -0,0 +1,126 @@ +use std::pin::Pin; + +use futures::Stream; + +use crate::{ + model::InitializeRequestParams, transport::common::server_side_http::ServerSseMessage, +}; + +/// An opaque identifier for a persisted SSE event. +pub type EventId = String; + +/// An opaque identifier for an SSE stream. +pub type StreamId = String; + +/// A stream of persisted SSE events in delivery order. +pub type EventStream = Pin + Send + Sync + 'static>>; + +/// Type alias for boxed event store errors. +pub type EventStoreError = Box; + +/// Persistent storage for resumable Streamable HTTP events. +/// +/// Implementations typically use a database or distributed log shared by all +/// server instances. Event IDs must be globally unique across all streams, and +/// events must be committed before [`EventStore::store_event`] returns so the +/// returned ID is safe to send to a client. +#[async_trait::async_trait] +pub trait EventStore: Send + Sync + 'static { + /// Persist an event and return the opaque ID clients should receive. + /// + /// The store assigns a globally unique ID and must retain its association + /// with `stream_id` so a later replay only returns events from that stream. + async fn store_event( + &self, + stream_id: &str, + event: &ServerSseMessage, + ) -> Result; + + /// Return events strictly after `last_event_id` in delivery order. + /// + /// Implementations must locate the stream from the globally unique event + /// ID and yield only later events from that stream, with their originally + /// assigned event IDs. + /// + /// A finite stream enables reconnect-and-poll behavior. Implementations + /// backed by a distributed log may keep the stream open to deliver new + /// events as they are appended by any server instance. + async fn replay_events_after( + &self, + last_event_id: &str, + ) -> Result; +} + +impl std::fmt::Debug for dyn EventStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("") + } +} + +/// State persisted to an external store for cross-instance session recovery. +/// +/// When a client reconnects to a different server instance, the new instance +/// loads this state to transparently replay the `initialize` handshake without +/// the client needing to re-initialize. +#[non_exhaustive] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SessionState { + /// Parameters from the client's original `initialize` request. + pub initialize_params: InitializeRequestParams, +} + +impl SessionState { + pub fn new(initialize_params: InitializeRequestParams) -> Self { + Self { initialize_params } + } +} + +/// Type alias for boxed session store errors. +pub type SessionStoreError = Box; + +/// Pluggable external session store for cross-instance recovery. +/// +/// Implement this trait to back sessions with Redis, a database, or any +/// key-value store. The simplest usage is to set +/// `StreamableHttpServerConfig::session_store` to an `Arc`. +/// +/// # Example (in-memory, for testing) +/// +/// ```rust,ignore +/// use std::{collections::HashMap, sync::Arc}; +/// use tokio::sync::RwLock; +/// use rmcp::transport::streamable_http_server::session::store::{ +/// SessionState, SessionStore, SessionStoreError, +/// }; +/// +/// #[derive(Default)] +/// struct InMemoryStore(Arc>>); +/// +/// #[async_trait::async_trait] +/// impl SessionStore for InMemoryStore { +/// async fn load(&self, id: &str) -> Result, SessionStoreError> { +/// Ok(self.0.read().await.get(id).cloned()) +/// } +/// async fn store(&self, id: &str, state: &SessionState) -> Result<(), SessionStoreError> { +/// self.0.write().await.insert(id.to_owned(), state.clone()); +/// Ok(()) +/// } +/// async fn delete(&self, id: &str) -> Result<(), SessionStoreError> { +/// self.0.write().await.remove(id); +/// Ok(()) +/// } +/// } +/// ``` +#[async_trait::async_trait] +pub trait SessionStore: Send + Sync + 'static { + /// Load session state for the given `session_id`. + /// + /// Returns `Ok(None)` when no entry exists (i.e. session is unknown to the store). + async fn load(&self, session_id: &str) -> Result, SessionStoreError>; + + /// Persist session state for the given `session_id`. + async fn store(&self, session_id: &str, state: &SessionState) -> Result<(), SessionStoreError>; + + /// Remove session state for the given `session_id`. + async fn delete(&self, session_id: &str) -> Result<(), SessionStoreError>; +} diff --git a/vendor/rmcp/src/transport/streamable_http_server/tower.rs b/vendor/rmcp/src/transport/streamable_http_server/tower.rs new file mode 100644 index 000000000..f1fef585f --- /dev/null +++ b/vendor/rmcp/src/transport/streamable_http_server/tower.rs @@ -0,0 +1,2151 @@ +use std::{ + borrow::Cow, + collections::HashMap, + convert::Infallible, + fmt::Display, + pin::Pin, + sync::Arc, + task::{Context, Poll}, + time::Duration, +}; + +use bytes::Bytes; +use futures::{Stream, StreamExt, future::BoxFuture}; +use http::{HeaderMap, Method, Request, Response, header::ALLOW}; +use http_body::Body; +use http_body_util::{BodyExt, Full, combinators::BoxBody}; +use pin_project_lite::pin_project; +use tokio_stream::wrappers::ReceiverStream; +use tokio_util::sync::CancellationToken; + +use super::session::{ + EventStore, EventStoreError, RestoreOutcome, SessionId, SessionManager, SessionRestoreMarker, + SessionState, SessionStore, +}; +use crate::{ + RoleServer, + model::{ + ClientCapabilities, ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorCode, + ErrorData, GetExtensions, GetMeta, Implementation, InitializeRequest, + InitializeRequestParams, InitializedNotification, JsonObject, JsonRpcError, + ProtocolVersion, RequestId, ServerInfo, ServerJsonRpcMessage, ServerResult, + }, + serve_server, + service::{ + NotificationContext, RequestContext, Service, negotiate_protocol_version, + serve_directly_with_ct, uses_legacy_lifecycle, + }, + transport::{ + OneshotTransport, TransportAdapterIdentity, + common::{ + http_header::{ + EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_MCP_PROTOCOL_VERSION, + HEADER_SESSION_ID, JSON_MIME_TYPE, + }, + mcp_headers, + server_side_http::{ + BoxResponse, ServerSseMessage, accepted_response, expect_json, + internal_error_response, sse_stream_response, unexpected_message_response, + }, + }, + }, +}; + +/// Default maximum POST request body size (4 MiB). +pub(crate) const DEFAULT_MAX_REQUEST_BODY_BYTES: usize = 4 * 1024 * 1024; +const STATELESS_STREAM_CHANNEL_CAPACITY: usize = 16; + +#[non_exhaustive] +#[derive(Debug, Clone)] +pub struct StreamableHttpServerConfig { + /// The ping message duration for SSE connections. + pub sse_keep_alive: Option, + /// The retry interval for SSE priming events. + pub sse_retry: Option, + /// If true, the server will create a session for each request and keep it alive. + /// When enabled, SSE priming events are sent to enable client reconnection. + /// + /// Only applies to legacy protocol versions (`< 2026-07-28`). Per SEP-2567, + /// sessions are removed from the `2026-07-28` version, so requests + /// negotiating that version are always served statelessly regardless of + /// this setting. + pub legacy_session_mode: bool, + /// When true and `legacy_session_mode` is false, the server prefers + /// `Content-Type: application/json` for simple request-response tools. + /// If the handler emits a notification or request before the final response, + /// the server falls back to `text/event-stream` so no message is lost. + pub json_response: bool, + /// Cancellation token for the Streamable HTTP server. + /// + /// When this token is cancelled, all active sessions are terminated and + /// the server stops accepting new requests. + pub cancellation_token: CancellationToken, + /// Allowed hostnames or `host:port` authorities for inbound `Host` validation. + /// + /// By default, Streamable HTTP servers only accept loopback hosts to + /// prevent DNS rebinding attacks against locally running servers. Public + /// deployments should override this list with their own hostnames. + /// examples: + /// allowed_hosts = ["localhost", "127.0.0.1", "0.0.0.0"] + /// or with ports: + /// allowed_hosts = ["example.com", "example.com:8080"] + pub allowed_hosts: Vec, + /// Allowed browser origins for inbound `Origin` validation. + /// + /// Defaults to an empty list, which disables Origin validation. When + /// non-empty, requests carrying an `Origin` header must match per RFC 6454 + /// `(scheme, host, port)`; missing-`Origin` requests still pass. Entries + /// must include a scheme; `"null"` matches the browser's `Origin: null`. + /// examples: + /// allowed_origins = ["https://app.example.com", "http://localhost:8080"] + pub allowed_origins: Vec, + /// Optional external session store for cross-instance recovery. + /// + /// When set, [`SessionState`] (the client's `initialize` parameters) is + /// persisted after a successful handshake and deleted when the session + /// closes. On any subsequent request that arrives at an instance with no + /// in-memory session, the store is consulted: if an entry is found the + /// session is transparently restored so the client does not need to + /// re-initialize. + /// + /// # Example + /// ```rust,ignore + /// use std::sync::Arc; + /// use rmcp::transport::streamable_http_server::{ + /// StreamableHttpServerConfig, session::SessionStore, + /// }; + /// + /// let config = StreamableHttpServerConfig { + /// session_store: Some(Arc::new(MyRedisStore::new())), + /// ..Default::default() + /// }; + /// ``` + pub session_store: Option>, + /// Maximum POST request body size in bytes. + /// + /// Enforced while streaming the body, independent of `Content-Length`, + /// chunked transfer encoding, or HTTP version. Oversized payloads receive + /// a `413 Payload Too Large` response. + pub max_request_body_bytes: usize, + /// Require stateless JSON-RPC request POSTs to carry per-request protocol + /// signals before handler dispatch. + /// + /// Non-initialize requests must carry `MCP-Protocol-Version`; ordinary + /// non-discovery requests must also carry + /// `_meta.io.modelcontextprotocol/protocolVersion`. `server/discover` + /// retains its existing request-metadata validation. For `2026-07-28` + /// requests, the server handler continues to require the remaining + /// per-request metadata, including `clientCapabilities`. Initialize, + /// notifications, and other message kinds retain their existing rules. + /// + /// This option applies to requests routed statelessly. Set + /// `legacy_session_mode` to `false` to ensure every request uses that path. + /// Legacy session routing and its error precedence remain unchanged. + /// + /// The validator checks metadata presence rather than applying a version + /// allowlist. However, rmcp clients negotiated below `2026-07-28` do not + /// attach per-request protocol metadata, so enabling this option rejects + /// their ordinary requests. Servers using this option should normally + /// override + /// [`ServerHandler::supported_protocol_versions`](crate::ServerHandler::supported_protocol_versions) + /// to advertise only `2026-07-28` and later. + /// + /// Default is `false`, preserving today's legacy behavior where an absent + /// header is treated as protocol version `2025-03-26`. + pub stateless_protocol_metadata_required: bool, +} + +impl std::fmt::Debug for dyn SessionStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("") + } +} + +impl Default for StreamableHttpServerConfig { + fn default() -> Self { + Self { + sse_keep_alive: Some(Duration::from_secs(15)), + sse_retry: Some(Duration::from_secs(3)), + legacy_session_mode: true, + json_response: false, + cancellation_token: CancellationToken::new(), + allowed_hosts: vec!["localhost".into(), "127.0.0.1".into(), "::1".into()], + allowed_origins: vec![], + session_store: None, + max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES, + stateless_protocol_metadata_required: false, + } + } +} + +impl StreamableHttpServerConfig { + pub fn with_allowed_hosts( + mut self, + allowed_hosts: impl IntoIterator>, + ) -> Self { + self.allowed_hosts = allowed_hosts.into_iter().map(Into::into).collect(); + self + } + /// Disable allowed hosts. This will allow requests with any `Host` header, which is NOT recommended for public deployments. + pub fn disable_allowed_hosts(mut self) -> Self { + self.allowed_hosts.clear(); + self + } + pub fn with_allowed_origins( + mut self, + allowed_origins: impl IntoIterator>, + ) -> Self { + self.allowed_origins = allowed_origins.into_iter().map(Into::into).collect(); + self + } + /// Disable Origin validation, reverting to the default ignore-Origin behavior. + pub fn disable_allowed_origins(mut self) -> Self { + self.allowed_origins.clear(); + self + } + pub fn with_sse_keep_alive(mut self, duration: Option) -> Self { + self.sse_keep_alive = duration; + self + } + + pub fn with_sse_retry(mut self, duration: Option) -> Self { + self.sse_retry = duration; + self + } + + pub fn with_legacy_session_mode(mut self, legacy_session_mode: bool) -> Self { + self.legacy_session_mode = legacy_session_mode; + self + } + + pub fn with_json_response(mut self, json_response: bool) -> Self { + self.json_response = json_response; + self + } + + pub fn with_cancellation_token(mut self, token: CancellationToken) -> Self { + self.cancellation_token = token; + self + } + + /// Set the maximum POST request body size in bytes. + pub fn with_max_request_body_bytes(mut self, bytes: usize) -> Self { + self.max_request_body_bytes = bytes; + self + } + + /// Require per-request protocol signals on stateless JSON-RPC request + /// POSTs. + /// + /// See [`StreamableHttpServerConfig::stateless_protocol_metadata_required`]. + pub fn with_stateless_protocol_metadata_required( + mut self, + stateless_protocol_metadata_required: bool, + ) -> Self { + self.stateless_protocol_metadata_required = stateless_protocol_metadata_required; + self + } +} + +#[expect( + clippy::result_large_err, + reason = "BoxResponse is intentionally large; matches other handlers in this file" +)] +/// Validates the `MCP-Protocol-Version` header on incoming HTTP requests. +/// +/// Per the MCP 2025-06-18 spec: +/// - If the header is present but contains an unsupported version, return 400 Bad Request. +/// - If the header is absent, assume `2025-03-26` for backwards compatibility (no error). +fn validate_protocol_version_header( + headers: &http::HeaderMap, + allow_unknown: bool, +) -> Result<(), BoxResponse> { + if let Some(value) = headers.get(HEADER_MCP_PROTOCOL_VERSION) { + let version_str = value.to_str().map_err(|_| { + Response::builder() + .status(http::StatusCode::BAD_REQUEST) + .body( + Full::new(Bytes::from( + "Bad Request: Invalid MCP-Protocol-Version header encoding", + )) + .boxed(), + ) + .expect("valid response") + })?; + let is_known = ProtocolVersion::KNOWN_VERSIONS + .iter() + .any(|v| v.as_str() == version_str); + if !allow_unknown && !is_known { + return Err(Response::builder() + .status(http::StatusCode::BAD_REQUEST) + .body( + Full::new(Bytes::from(format!( + "Bad Request: Unsupported MCP-Protocol-Version: {version_str}" + ))) + .boxed(), + ) + .expect("valid response")); + } + } + Ok(()) +} + +fn message_has_per_request_protocol_version(message: &ClientJsonRpcMessage) -> bool { + match message { + ClientJsonRpcMessage::Request(request) => { + request.request.get_meta().protocol_version().is_some() + } + _ => false, + } +} + +struct NegotiatingStatelessHttpService(S); + +impl> Service for NegotiatingStatelessHttpService { + async fn handle_request( + &self, + request: ClientRequest, + context: RequestContext, + ) -> Result { + let requested_protocol_version = + if let ClientRequest::InitializeRequest(initialize) = &request { + Some(initialize.params.protocol_version.clone()) + } else { + None + }; + let peer = context.peer.clone(); + let mut response = self.0.handle_request(request, context).await?; + if let (Some(requested), ServerResult::InitializeResult(result)) = + (requested_protocol_version, &mut response) + { + result.protocol_version = negotiate_protocol_version( + &requested, + result.protocol_version.clone(), + &self.0.supported_protocol_versions(), + ); + if let Some(peer_info) = peer.peer_info() { + let mut peer_info = (*peer_info).clone(); + peer_info.protocol_version = result.protocol_version.clone(); + peer.set_peer_info(peer_info); + } + } + Ok(response) + } + + async fn handle_notification( + &self, + notification: ClientNotification, + context: NotificationContext, + ) -> Result<(), ErrorData> { + self.0.handle_notification(notification, context).await + } + + fn get_info(&self) -> ServerInfo { + self.0.get_info() + } + + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + self.0.supported_protocol_versions() + } +} + +#[expect( + clippy::result_large_err, + reason = "BoxResponse is intentionally large; matches other handlers in this file" +)] +// SEP-2567: sessions are removed from the discover lifecycle. Validate +// protocol-version consistency, then classify the request with the shared +// lifecycle helper. +fn is_legacy_request( + message: Option<&ClientJsonRpcMessage>, + headers: &HeaderMap, +) -> Result { + let has_per_request_version = message.is_some_and(message_has_per_request_protocol_version); + validate_protocol_version_header(headers, has_per_request_version)?; + if let Some(message) = message { + if let ClientJsonRpcMessage::Request(req) = message { + if let ClientRequest::InitializeRequest(init) = &req.request { + validate_header_matches_init_body( + headers, + init.params.protocol_version.as_str(), + Some(req.id.clone()), + )?; + } + } + validate_request_protocol_version_meta(headers, message)?; + } + + let uses_discover_lifecycle = matches!( + message, + Some(ClientJsonRpcMessage::Request(req)) + if !matches!(&req.request, ClientRequest::InitializeRequest(_)) + && req + .request + .get_meta() + .missing_required_keys(&ProtocolVersion::V_2026_07_28) + .is_empty() + ); + + let from_body = match message { + Some(ClientJsonRpcMessage::Request(req)) => match &req.request { + ClientRequest::InitializeRequest(init) => Some(init.params.protocol_version.clone()), + _ => req.request.get_meta().protocol_version(), + }, + _ => None, + }; + let version = from_body + .or_else(|| { + headers + .get(HEADER_MCP_PROTOCOL_VERSION) + .and_then(|value| value.to_str().ok()) + .and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_owned())).ok()) + }) + .unwrap_or(ProtocolVersion::V_2025_03_26); + Ok(uses_legacy_lifecycle( + Some(&version), + uses_discover_lifecycle, + )) +} + +fn method_not_allowed_response() -> BoxResponse { + Response::builder() + .status(http::StatusCode::METHOD_NOT_ALLOWED) + .header(ALLOW, "POST") + .body(Full::new(Bytes::from("Method Not Allowed")).boxed()) + .expect("valid response") +} + +async fn persist_and_forward_event( + event_store: &dyn EventStore, + stream_id: &str, + mut event: ServerSseMessage, + output: &mut Option>, +) -> Result<(), EventStoreError> { + event.event_id = Some(event_store.store_event(stream_id, &event).await?); + if let Some(sender) = output { + if sender.send(event).await.is_err() { + *output = None; + } + } + Ok(()) +} + +fn invalid_request_jsonrpc_response( + id: Option, + message: impl Into>, +) -> BoxResponse { + let err = JsonRpcError::new(id, ErrorData::invalid_request(message, None)); + let body = serde_json::to_vec(&err).expect("serialize JsonRpcError"); + Response::builder() + .status(http::StatusCode::BAD_REQUEST) + .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) + .body(Full::new(Bytes::from(body)).boxed()) + .expect("valid response") +} + +fn invalid_params_jsonrpc_response( + id: Option, + message: impl Into>, +) -> BoxResponse { + let err = JsonRpcError::new(id, ErrorData::invalid_params(message, None)); + let body = serde_json::to_vec(&err).expect("serialize JsonRpcError"); + Response::builder() + .status(http::StatusCode::BAD_REQUEST) + .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) + .body(Full::new(Bytes::from(body)).boxed()) + .expect("valid response") +} + +#[expect( + clippy::result_large_err, + reason = "BoxResponse is intentionally large; matches other handlers in this file" +)] +/// Absent header is allowed; the first initialize round-trip may legitimately omit it. +fn validate_header_matches_init_body( + headers: &http::HeaderMap, + body_version: &str, + request_id: Option, +) -> Result<(), BoxResponse> { + let Some(header_value) = headers.get(HEADER_MCP_PROTOCOL_VERSION) else { + return Ok(()); + }; + let header_str = header_value.to_str().map_err(|_| { + invalid_request_jsonrpc_response( + request_id.clone(), + "Invalid Request: MCP-Protocol-Version header is not valid UTF-8", + ) + })?; + if header_str != body_version { + tracing::warn!( + header = header_str, + body = body_version, + "rejecting initialize: MCP-Protocol-Version header does not match params.protocolVersion" + ); + return Err(invalid_request_jsonrpc_response( + request_id, + format!( + "Invalid Request: MCP-Protocol-Version header ({header_str}) does not match initialize params.protocolVersion ({body_version})" + ), + )); + } + Ok(()) +} + +#[expect( + clippy::result_large_err, + reason = "BoxResponse is intentionally large; matches other handlers in this file" +)] +fn validate_request_protocol_version_meta( + headers: &HeaderMap, + message: &ClientJsonRpcMessage, +) -> Result<(), BoxResponse> { + let ClientJsonRpcMessage::Request(request) = message else { + return Ok(()); + }; + if matches!(&request.request, ClientRequest::InitializeRequest(_)) { + return Ok(()); + } + let is_discover = matches!(&request.request, ClientRequest::DiscoverRequest(_)); + let meta = request.request.get_meta(); + let header_version = headers + .get(HEADER_MCP_PROTOCOL_VERSION) + .and_then(|value| value.to_str().ok()); + let Some(meta_version) = meta.protocol_version() else { + let requires_request_metadata = is_discover + || header_version + .is_some_and(|version| version >= ProtocolVersion::V_2026_07_28.as_str()); + if requires_request_metadata { + let missing = meta.missing_required_keys(&ProtocolVersion::V_2026_07_28); + return Err(invalid_params_jsonrpc_response( + Some(request.id.clone()), + format!( + "Invalid params: request _meta is missing or has malformed required fields: {}", + missing.join(", ") + ), + )); + } + return Ok(()); + }; + let Some(header_version) = header_version else { + return Err(header_mismatch_jsonrpc_response( + Some(request.id.clone()), + "request _meta protocolVersion requires MCP-Protocol-Version header", + )); + }; + if header_version != meta_version.as_str() { + return Err(header_mismatch_jsonrpc_response( + Some(request.id.clone()), + format!( + "MCP-Protocol-Version header ({header_version}) does not match request _meta protocolVersion ({meta_version})" + ), + )); + } + Ok(()) +} + +/// When `stateless_protocol_metadata_required` is enabled in stateless mode, +/// every non-initialize Streamable HTTP JSON-RPC request POST must carry the +/// `MCP-Protocol-Version` HTTP header. A missing header is rejected with +/// HTTP 400 / JSON-RPC `-32020` before handler dispatch. `server/discover` +/// is included so the seam aligns with the per-POST header contract; its +/// body-metadata rule is preserved unchanged. +#[expect( + clippy::result_large_err, + reason = "BoxResponse is intentionally large; matches other handlers in this file" +)] +fn validate_required_protocol_header( + config: &StreamableHttpServerConfig, + headers: &HeaderMap, + message: &ClientJsonRpcMessage, +) -> Result<(), BoxResponse> { + if !config.stateless_protocol_metadata_required { + return Ok(()); + } + let ClientJsonRpcMessage::Request(request) = message else { + // Notifications, response messages, and error messages are exempt. + return Ok(()); + }; + if matches!(&request.request, ClientRequest::InitializeRequest(_)) { + // Initialize keeps its own header-matching rule. + return Ok(()); + } + if headers.contains_key(HEADER_MCP_PROTOCOL_VERSION) { + return Ok(()); + } + Err(header_mismatch_jsonrpc_response( + Some(request.id.clone()), + "Missing MCP-Protocol-Version header for request requiring per-request protocol metadata", + )) +} + +/// When `stateless_protocol_metadata_required` is enabled in stateless mode, +/// every non-initialize, non-discover Streamable HTTP JSON-RPC request must +/// carry `io.modelcontextprotocol/protocolVersion` in `_meta`. A missing entry +/// is rejected with HTTP 400 / JSON-RPC `-32602` (invalid_params). `initialize`, +/// `server/discover` (whose body-metadata rule is already enforced by +/// `validate_request_protocol_version_meta`), notifications, and other message +/// kinds are exempt. +#[expect( + clippy::result_large_err, + reason = "BoxResponse is intentionally large; matches other handlers in this file" +)] +fn validate_required_protocol_meta( + config: &StreamableHttpServerConfig, + message: &ClientJsonRpcMessage, +) -> Result<(), BoxResponse> { + if !config.stateless_protocol_metadata_required { + return Ok(()); + } + let ClientJsonRpcMessage::Request(request) = message else { + return Ok(()); + }; + if matches!( + &request.request, + ClientRequest::InitializeRequest(_) | ClientRequest::DiscoverRequest(_) + ) { + return Ok(()); + } + if request.request.get_meta().protocol_version().is_some() { + return Ok(()); + } + Err(invalid_params_jsonrpc_response( + Some(request.id.clone()), + "Invalid params: request requires protocolVersion in request _meta", + )) +} + +fn jsonrpc_http_status(message: &ServerJsonRpcMessage) -> http::StatusCode { + let ServerJsonRpcMessage::Error(error) = message else { + return http::StatusCode::OK; + }; + // Modern per-request HTTP treats invalid params as a malformed request. + // Legacy requests bypass this mapper and retain HTTP 200 JSON-RPC errors. + match error.error.code { + ErrorCode::UNSUPPORTED_PROTOCOL_VERSION + | ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY + | ErrorCode::INVALID_PARAMS => http::StatusCode::BAD_REQUEST, + ErrorCode::METHOD_NOT_FOUND => http::StatusCode::NOT_FOUND, + _ => http::StatusCode::OK, + } +} + +fn jsonrpc_message_response( + message: ServerJsonRpcMessage, + map_protocol_status: bool, +) -> Result { + let status = if map_protocol_status { + jsonrpc_http_status(&message) + } else { + http::StatusCode::OK + }; + let body = + serde_json::to_vec(&message).map_err(internal_error_response("serialize json response"))?; + Ok(Response::builder() + .status(status) + .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) + .body(Full::new(Bytes::from(body)).boxed()) + .expect("valid response")) +} + +fn header_mismatch_jsonrpc_response( + id: Option, + message: impl Into>, +) -> BoxResponse { + let err = JsonRpcError::new(id, ErrorData::header_mismatch(message, None)); + let body = serde_json::to_vec(&err).expect("serialize JsonRpcError"); + Response::builder() + .status(http::StatusCode::BAD_REQUEST) + .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) + .body(Full::new(Bytes::from(body)).boxed()) + .expect("valid response") +} + +/// Validates SEP-2243 `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*` headers against the body. +/// +/// Only enforced when the request declares a protocol version `>= STANDARD_HEADERS`. +/// The `initialize` handshake is exempt: clients emit these headers only after the +/// version has been negotiated. `tool_schema` supplies the called tool's input schema +/// so annotated `Mcp-Param-*` headers can be checked (no schema => those are skipped). +#[expect( + clippy::result_large_err, + reason = "BoxResponse is intentionally large; matches other handlers in this file" +)] +fn validate_standard_headers( + headers: &HeaderMap, + message: &ClientJsonRpcMessage, + tool_schema: impl Fn(&str) -> Option>, +) -> Result<(), BoxResponse> { + let version_requires_headers = headers + .get(HEADER_MCP_PROTOCOL_VERSION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|version| version >= ProtocolVersion::STANDARD_HEADERS.as_str()); + if !version_requires_headers { + return Ok(()); + } + + let request_id = match message { + ClientJsonRpcMessage::Request(req) => { + if matches!(&req.request, ClientRequest::InitializeRequest(_)) { + return Ok(()); + } + Some(req.id.clone()) + } + ClientJsonRpcMessage::Notification(_) => None, + _ => return Ok(()), + }; + + let Ok(value) = serde_json::to_value(message) else { + return Ok(()); + }; + // For tools/call, look up the tool schema so Mcp-Param-* headers are validated. + let schema = value + .get("method") + .and_then(|method| method.as_str()) + .filter(|method| *method == "tools/call") + .and_then(|_| value.get("params")) + .and_then(|params| params.get("name")) + .and_then(|name| name.as_str()) + .and_then(tool_schema); + if let Err(reason) = mcp_headers::validate_request_headers(headers, &value, schema.as_deref()) { + return Err(header_mismatch_jsonrpc_response(request_id, reason)); + } + Ok(()) +} + +fn forbidden_response(message: impl Into) -> BoxResponse { + Response::builder() + .status(http::StatusCode::FORBIDDEN) + .body(Full::new(Bytes::from(message.into())).boxed()) + .expect("valid response") +} + +fn normalize_host(host: &str) -> String { + host.trim_matches('[') + .trim_matches(']') + .to_ascii_lowercase() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NormalizedAuthority { + host: String, + port: Option, +} + +fn normalize_authority(host: &str, port: Option) -> NormalizedAuthority { + NormalizedAuthority { + host: normalize_host(host), + port, + } +} + +fn parse_allowed_authority(allowed: &str) -> Option { + let allowed = allowed.trim(); + if allowed.is_empty() { + return None; + } + + if let Ok(authority) = http::uri::Authority::try_from(allowed) { + return Some(normalize_authority(authority.host(), authority.port_u16())); + } + + Some(normalize_authority(allowed, None)) +} + +fn host_is_allowed(host: &NormalizedAuthority, allowed_hosts: &[String]) -> bool { + if allowed_hosts.is_empty() { + // If the allowed hosts list is empty, allow all hosts (not recommended). + return true; + } + allowed_hosts + .iter() + .filter_map(|allowed| parse_allowed_authority(allowed)) + .any(|allowed| { + allowed.host == host.host + && match allowed.port { + Some(port) => host.port == Some(port), + None => true, + } + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum NormalizedOrigin { + Null, + Tuple { + scheme: String, + host: String, + port: Option, + }, +} + +fn parse_origin_value(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + if value.eq_ignore_ascii_case("null") { + return Some(NormalizedOrigin::Null); + } + let uri = http::Uri::try_from(value).ok()?; + let scheme = uri.scheme_str()?.to_ascii_lowercase(); + let authority = uri.authority()?; + Some(NormalizedOrigin::Tuple { + scheme, + host: normalize_host(authority.host()), + port: authority.port_u16(), + }) +} + +fn origin_is_allowed(origin: &NormalizedOrigin, allowed_origins: &[String]) -> bool { + if allowed_origins.is_empty() { + return true; + } + allowed_origins + .iter() + .filter_map(|raw| parse_origin_value(raw)) + .any(|allowed| match (&allowed, origin) { + (NormalizedOrigin::Null, NormalizedOrigin::Null) => true, + ( + NormalizedOrigin::Tuple { + scheme: a_scheme, + host: a_host, + port: a_port, + }, + NormalizedOrigin::Tuple { + scheme: o_scheme, + host: o_host, + port: o_port, + }, + ) => a_scheme == o_scheme && a_host == o_host && (a_port.is_none() || a_port == o_port), + _ => false, + }) +} + +fn bad_request_response(message: &str) -> BoxResponse { + let body = Full::from(message.to_string()).boxed(); + + http::Response::builder() + .status(http::StatusCode::BAD_REQUEST) + .header(http::header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(body) + .expect("failed to build bad request response") +} + +fn parse_host_header( + uri: &http::Uri, + headers: &HeaderMap, +) -> Result { + if let Some(host) = headers.get(http::header::HOST) { + let host_str = host + .to_str() + .inspect_err(|_| { + tracing::warn!(host = ?host, "rejected request with non-UTF-8 Host header"); + }) + .map_err(|_| bad_request_response("Bad Request: Invalid Host header encoding"))?; + let authority = http::uri::Authority::try_from(host_str) + .inspect_err(|_| { + tracing::warn!( + host = host_str, + "rejected request with malformed Host header" + ); + }) + .map_err(|_| bad_request_response("Bad Request: Invalid Host header"))?; + return Ok(normalize_authority(authority.host(), authority.port_u16())); + } + // HTTP/2 carries the host in `:authority`; middleware such as + // `axum::Router::nest` can drop the `Host` header hyper synthesizes from it. + let authority = uri.authority().ok_or_else(|| { + tracing::warn!("rejected request with missing Host header and no :authority"); + bad_request_response("Bad Request: missing Host header") + })?; + Ok(normalize_authority(authority.host(), authority.port_u16())) +} + +fn validate_dns_rebinding_headers( + uri: &http::Uri, + headers: &HeaderMap, + config: &StreamableHttpServerConfig, +) -> Result<(), BoxResponse> { + let host = parse_host_header(uri, headers)?; + if !host_is_allowed(&host, &config.allowed_hosts) { + tracing::warn!( + host = ?host, + "rejected request with disallowed Host header (possible DNS rebinding attempt)", + ); + return Err(forbidden_response("Forbidden: Host header is not allowed")); + } + validate_origin_header(headers, &config.allowed_origins)?; + Ok(()) +} + +fn validate_origin_header( + headers: &HeaderMap, + allowed_origins: &[String], +) -> Result<(), BoxResponse> { + if allowed_origins.is_empty() { + return Ok(()); + } + let Some(origin_header) = headers.get(http::header::ORIGIN) else { + return Ok(()); + }; + let origin_str = origin_header + .to_str() + .inspect_err(|_| { + tracing::warn!(origin = ?origin_header, "rejected request with non-UTF-8 Origin header"); + }) + .map_err(|_| bad_request_response("Bad Request: Invalid Origin header encoding"))?; + let origin = parse_origin_value(origin_str).ok_or_else(|| { + tracing::warn!( + origin = origin_str, + "rejected request with malformed Origin header", + ); + bad_request_response("Bad Request: Invalid Origin header") + })?; + if !origin_is_allowed(&origin, allowed_origins) { + tracing::warn!( + origin = ?origin, + "rejected request with disallowed Origin header (possible cross-origin attack)", + ); + return Err(forbidden_response( + "Forbidden: Origin header is not allowed", + )); + } + Ok(()) +} + +/// # Streamable HTTP server +/// +/// An HTTP service that implements the +/// [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http) +/// for MCP servers. +/// +/// ## Session management +/// +/// When [`StreamableHttpServerConfig::legacy_session_mode`] is `true` (the default), +/// the server creates a session for each client that sends an `initialize` +/// request. The session ID is returned in the `Mcp-Session-Id` response header +/// and the client must include it on all subsequent requests. +/// +/// Two tool calls carrying the same `Mcp-Session-Id` come from the same logical +/// session (typically one conversation in an LLM client). Different session IDs +/// mean different sessions. +/// +/// The [`SessionManager`] trait controls how sessions are stored and routed: +/// +/// * [`LocalSessionManager`](super::session::local::LocalSessionManager) — +/// in-memory session store (default). +/// * [`NeverSessionManager`](super::session::never::NeverSessionManager) — +/// disables sessions entirely (stateless mode). +/// +/// ## Accessing HTTP request data from tool handlers +/// +/// The service consumes the request body but injects the remaining +/// [`http::request::Parts`] into [`crate::model::Extensions`], which is +/// accessible through [`crate::service::RequestContext`]. +/// +/// ### Reading the raw HTTP parts +/// +/// ```rust +/// use rmcp::handler::server::tool::Extension; +/// use http::request::Parts; +/// async fn my_tool(Extension(parts): Extension) { +/// tracing::info!("http parts:{parts:?}") +/// } +/// ``` +/// +/// ### Reading the session ID inside a tool handler +/// +/// ```rust,ignore +/// use rmcp::handler::server::tool::Extension; +/// use rmcp::service::RequestContext; +/// use rmcp::model::RoleServer; +/// +/// #[tool(description = "session-aware tool")] +/// async fn my_tool( +/// &self, +/// Extension(parts): Extension, +/// ) -> Result { +/// if let Some(session_id) = parts.headers.get("mcp-session-id") { +/// tracing::info!(?session_id, "called from session"); +/// } +/// // ... +/// # todo!() +/// } +/// ``` +/// +/// ### Accessing custom axum/tower extension state +/// +/// State added via axum's `Extension` layer is available inside +/// `Parts.extensions`: +/// +/// ```rust,ignore +/// use rmcp::service::RequestContext; +/// use rmcp::model::RoleServer; +/// +/// #[derive(Clone)] +/// struct AppState { /* ... */ } +/// +/// #[tool(description = "example")] +/// async fn my_tool( +/// &self, +/// ctx: RequestContext, +/// ) -> Result { +/// let parts = ctx.extensions.get::().unwrap(); +/// let state = parts.extensions.get::().unwrap(); +/// // use state... +/// # todo!() +/// } +/// ``` +pub struct StreamableHttpService { + pub config: StreamableHttpServerConfig, + session_manager: Arc, + service_factory: Arc Result + Send + Sync>, + /// Tracks in-progress session restores so that concurrent requests for the + /// same unknown session ID wait for the first restore to complete rather + /// than racing to replay the initialize handshake. `None` when no external + /// session store is configured (avoids allocating the map). + pending_restores: Option< + Arc>>>>, + >, + /// Caches tool input schemas by name for SEP-2243 `Mcp-Param-*` validation. + /// Populated lazily via `get_tool` so the service factory runs at most once + /// per tool name. `None` value means the tool exposes no schema. + tool_schemas: Arc>>>>, +} + +impl Clone for StreamableHttpService { + fn clone(&self) -> Self { + Self { + config: self.config.clone(), + session_manager: self.session_manager.clone(), + service_factory: self.service_factory.clone(), + pending_restores: self.pending_restores.clone(), + tool_schemas: self.tool_schemas.clone(), + } + } +} + +impl tower_service::Service> for StreamableHttpService +where + RequestBody: Body + Send + 'static, + S: crate::ServerHandler + Send + 'static, + M: SessionManager, + RequestBody::Error: Display, + RequestBody::Data: Send + 'static, +{ + type Response = BoxResponse; + type Error = Infallible; + type Future = BoxFuture<'static, Result>; + fn call(&mut self, req: http::Request) -> Self::Future { + let service = self.clone(); + Box::pin(async move { + let response = service.handle(req).await; + Ok(response) + }) + } + fn poll_ready( + &mut self, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } +} + +/// Guard used inside [`StreamableHttpService::try_restore_from_store`]. +/// +/// Ensures the `pending_restores` map entry is always cleaned up — even when +/// the future is cancelled mid-await. +/// +/// `result` defaults to `false` (failure / cancellation). Only the success path +/// needs to set it to `true` before returning. +struct PendingRestoreGuard { + pending_restores: + Arc>>>>, + session_id: SessionId, + watch_tx: tokio::sync::watch::Sender>, + /// The value that will be broadcast to waiting tasks on drop. + result: bool, +} + +impl Drop for PendingRestoreGuard { + fn drop(&mut self) { + // `send` is synchronous — unblocks waiters immediately, no lock needed. + let _ = self.watch_tx.send(Some(self.result)); + // Remove the map entry asynchronously (requires the async write lock). + let pending_restores = self.pending_restores.clone(); + let session_id = self.session_id.clone(); + tokio::spawn(async move { + pending_restores.write().await.remove(&session_id); + }); + } +} + +impl StreamableHttpService +where + S: crate::ServerHandler + Send + 'static, + M: SessionManager, +{ + pub fn new( + service_factory: impl Fn() -> Result + Send + Sync + 'static, + session_manager: Arc, + config: StreamableHttpServerConfig, + ) -> Self { + let pending_restores = config.session_store.is_some().then(|| { + Arc::new(tokio::sync::RwLock::new(HashMap::< + SessionId, + tokio::sync::watch::Sender>, + >::new())) + }); + Self { + config, + session_manager, + service_factory: Arc::new(service_factory), + pending_restores, + tool_schemas: Arc::new(std::sync::RwLock::new(HashMap::new())), + } + } + fn get_service(&self) -> Result { + (self.service_factory)() + } + + fn persisted_stateless_stream( + &self, + first: Option, + mut receiver: tokio::sync::mpsc::Receiver, + request_ct: CancellationToken, + event_store: Arc, + ) -> ReceiverStream { + let (sender, output) = tokio::sync::mpsc::channel(STATELESS_STREAM_CHANNEL_CAPACITY); + let stream_id = uuid::Uuid::new_v4().to_string(); + let retry = self.config.sse_retry; + let server_ct = self.config.cancellation_token.child_token(); + + tokio::spawn(async move { + let mut sender = Some(sender); + if let Some(retry) = retry { + if let Err(error) = persist_and_forward_event( + event_store.as_ref(), + &stream_id, + ServerSseMessage::retry(retry), + &mut sender, + ) + .await + { + tracing::error!(%stream_id, %error, "failed to persist SSE priming event"); + request_ct.cancel(); + return; + } + } + + let mut first = first; + loop { + let message = if let Some(message) = first.take() { + Some(message) + } else { + tokio::select! { + message = receiver.recv() => message, + _ = server_ct.cancelled() => { + request_ct.cancel(); + None + } + } + }; + let Some(message) = message else { + break; + }; + tracing::trace!(?message); + if let Err(error) = persist_and_forward_event( + event_store.as_ref(), + &stream_id, + ServerSseMessage::from_message(message), + &mut sender, + ) + .await + { + tracing::error!(%stream_id, %error, "failed to persist SSE event"); + request_ct.cancel(); + break; + } + } + }); + + ReceiverStream::new(output) + } + + fn stateless_sse_response( + &self, + first: Option, + receiver: tokio::sync::mpsc::Receiver, + request_ct: CancellationToken, + ) -> BoxResponse { + if let Some(event_store) = self.session_manager.event_store() { + let stream = self.persisted_stateless_stream(first, receiver, request_ct, event_store); + sse_stream_response( + stream, + self.config.sse_keep_alive, + self.config.cancellation_token.child_token(), + ) + } else { + let stream = futures::stream::iter(first) + .chain(ReceiverStream::new(receiver)) + .map(|message| { + tracing::trace!(?message); + ServerSseMessage::from_message(message) + }); + sse_stream_response( + CancelOnDisconnect::new(stream, request_ct), + self.config.sse_keep_alive, + self.config.cancellation_token.child_token(), + ) + } + } + + // The HTTP status must be known before opening an SSE stream. + async fn serve_negotiated_request_directly( + &self, + service: S, + mut request: crate::model::JsonRpcRequest, + parts: http::request::Parts, + ) -> Result { + let peer_info = Self::peer_info_for_stateless_request(&request, &parts.headers); + request.request.extensions_mut().insert(parts); + let (transport, mut receiver) = + OneshotTransport::::new(ClientJsonRpcMessage::Request(request)); + // Give this stateless request its own cancellation token so a client + // disconnect can cancel the in-flight handler (#857), as in the + // non-negotiated stateless path below. + let request_ct = CancellationToken::new(); + let service = serve_directly_with_ct( + NegotiatingStatelessHttpService(service), + transport, + peer_info, + request_ct.clone(), + ); + tokio::spawn(async move { + let _ = service.waiting().await; + }); + + let cancel = self.config.cancellation_token.child_token(); + // Cancel the handler if the client disconnects while it is still + // producing its first message (this future is dropped before + // `receiver.recv()` completes). Disarmed once the handler emits + // anything, so a normal response is never cancelled. + let mut disconnect_guard = Some(request_ct.clone().drop_guard()); + let first = tokio::select! { + message = receiver.recv() => { + if let Some(guard) = disconnect_guard.take() { + guard.disarm(); + } + message + } + _ = cancel.cancelled() => None, + } + .ok_or_else(|| { + internal_error_response("empty response")(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "no response message received from handler", + )) + })?; + + let terminal = matches!( + &first, + ServerJsonRpcMessage::Response(_) | ServerJsonRpcMessage::Error(_) + ); + if terminal + && (self.config.json_response || jsonrpc_http_status(&first) != http::StatusCode::OK) + { + // This message is the whole reply, so `receiver` is dropped here and + // anything the handler emits afterwards is undeliverable. Cancel it so + // a still-running handler stops instead of running on unobserved: its + // terminal `send` would otherwise fail before adding the termination + // permit, leaving the serve loop parked forever. A no-op when the + // handler already completed. + request_ct.cancel(); + return jsonrpc_message_response(first, true); + } + + Ok(self.stateless_sse_response(Some(first), receiver, request_ct)) + } + + /// Returns the cached input schema for `name`, constructing a service once + /// per name to read its `ServerHandler::get_tool` definition. Used to + /// validate SEP-2243 `Mcp-Param-*` headers against the request body. + fn tool_schema(&self, name: &str) -> Option> { + if let Ok(cache) = self.tool_schemas.read() { + if let Some(schema) = cache.get(name) { + return schema.clone(); + } + } + let schema = self + .get_service() + .ok() + .and_then(|service| service.get_tool(name)) + .map(|tool| tool.input_schema); + if let Ok(mut cache) = self.tool_schemas.write() { + cache.insert(name.to_owned(), schema.clone()); + } + schema + } + + /// Spawn a task that runs `serve_server` for the given session, waits for + /// it to finish, and then calls `close_session`. + /// + /// `init_done_tx`: when `Some`, the sender is fired after `serve_server` + /// returns successfully, signalling to the caller that the MCP handshake + /// is complete. Used by `try_restore_from_store` to synchronise with the + /// restore `initialize` replay; `handle_post` passes `None`. + fn spawn_session_worker( + session_manager: Arc, + session_id: SessionId, + service: S, + transport: M::Transport, + init_done_tx: Option>, + ) where + S: crate::ServerHandler + Send + 'static, + M: SessionManager, + { + tokio::spawn(async move { + let svc = + serve_server::(service, transport) + .await; + match svc { + Ok(svc) => { + if let Some(tx) = init_done_tx { + let _ = tx.send(()); + } + let _ = svc.waiting().await; + } + Err(e) => { + tracing::error!("Failed to serve session: {e}"); + // Dropping init_done_tx (if Some) signals failure to the caller. + } + } + let _ = session_manager + .close_session(&session_id) + .await + .inspect_err(|e| { + tracing::error!("Failed to close session {session_id}: {e}"); + }); + }); + } + + /// Attempt to restore a session from the external store. + /// + /// Returns `true` when the session is available and ready to serve the + /// current request (either just restored or already in memory). Returns + /// `false` when no store is configured or the session ID is unknown. + /// + /// Concurrent requests for the same unknown session ID are serialized: the + /// first caller performs the full restore and handshake replay while others + /// subscribe to a `watch` channel and wait, avoiding duplicate handshakes. + async fn try_restore_from_store( + &self, + session_id: &SessionId, + parts: &http::request::Parts, + ) -> Result + where + S: crate::ServerHandler + Send + 'static, + M: SessionManager, + { + // Both fields are Some iff a session store is configured. + let (Some(pending_restores), Some(store)) = + (&self.pending_restores, &self.config.session_store) + else { + return Ok(false); + }; + + // Serialize concurrent restores for the same session ID. + // Write-lock once: if another task is already restoring, subscribe and wait; + // otherwise, register ourselves as the restoring task. + // Channel value: None = in progress, Some(true) = restored, Some(false) = not found/failed. + let (watch_tx, _watch_rx) = tokio::sync::watch::channel(None::); + { + let mut pending = pending_restores.write().await; + if let Some(tx) = pending.get(session_id) { + let mut rx = tx.subscribe(); + drop(pending); + // Wait for the restore to finish, then propagate the outcome. + let result = rx + .wait_for(|r| r.is_some()) + .await + .map(|r| r.unwrap_or(false)) + .unwrap_or(false); + return Ok(result); + } + pending.insert(session_id.clone(), watch_tx.clone()); + } + + // Guard: signals waiters and cleans up the map entry on drop + let mut guard = PendingRestoreGuard { + pending_restores: pending_restores.clone(), + session_id: session_id.clone(), + watch_tx: watch_tx.clone(), + result: false, + }; + + // --- Step 3: load from external store --- + let state = match store.load(session_id.as_ref()).await { + Ok(Some(s)) => s, + Ok(None) => { + return Ok(false); + } + Err(e) => { + tracing::error!( + session_id = session_id.as_ref(), + error = %e, + "session store load failed during restore" + ); + return Err(std::io::Error::other(e)); + } + }; + + // --- Step 4: ask the session manager to allocate an in-memory worker --- + let transport = match self + .session_manager + .restore_session(session_id.clone()) + .await + .map_err(|e| std::io::Error::other(e.to_string())) + { + Ok(RestoreOutcome::Restored(t)) => t, + Ok(RestoreOutcome::AlreadyPresent) => { + // Invariant violation: pending_restores ensures only one task can call + // restore_session per session ID, so AlreadyPresent is impossible here. + return Err(std::io::Error::other( + "restore_session returned AlreadyPresent unexpectedly; session manager might have modified the session store outside of the restore_session API", + )); + } + Ok(RestoreOutcome::NotSupported) => { + return Ok(false); + } + Err(e) => { + return Err(e); + } + }; + + // --- Step 5: replay the MCP initialize handshake --- + let service = match self.get_service() { + Ok(s) => s, + Err(e) => { + return Err(e); + } + }; + + // `serve_server` requires both the `initialize` request and the + // `notifications/initialized` notification before transitioning to + // the running state — we must send both before returning. + let mut restore_init = ClientJsonRpcMessage::request( + ClientRequest::InitializeRequest(InitializeRequest { + params: state.initialize_params, + ..Default::default() + }), + crate::model::NumberOrString::Number(0), + ); + restore_init.insert_extension(parts.clone()); + restore_init.insert_extension(SessionRestoreMarker { + id: session_id.clone(), + }); + let mut restore_initialized = ClientJsonRpcMessage::notification( + ClientNotification::InitializedNotification(InitializedNotification { + ..Default::default() + }), + ); + restore_initialized.insert_extension(parts.clone()); + restore_initialized.insert_extension(SessionRestoreMarker { + id: session_id.clone(), + }); + // Signal from the spawned task once serve_server finishes initialising. + let (init_done_tx, init_done_rx) = tokio::sync::oneshot::channel::<()>(); + + Self::spawn_session_worker( + self.session_manager.clone(), + session_id.clone(), + service, + transport, + Some(init_done_tx), + ); + + if let Err(e) = self + .session_manager + .initialize_session(session_id, restore_init) + .await + .map_err(|e| std::io::Error::other(e.to_string())) + { + return Err(e); + } + + if let Err(e) = self + .session_manager + .accept_message(session_id, restore_initialized) + .await + .map_err(|e| std::io::Error::other(e.to_string())) + { + return Err(e); + } + + if init_done_rx.await.is_err() { + return Err(std::io::Error::other( + "serve_server initialization failed during restore", + )); + } + + // Restore complete — wake any waiting concurrent requests. + guard.result = true; + + tracing::debug!( + session_id = session_id.as_ref(), + "session restored from external store" + ); + Ok(true) + } + pub async fn handle(&self, request: Request) -> Response> + where + B: Body + Send + 'static, + B::Error: Display, + { + if let Err(response) = + validate_dns_rebinding_headers(request.uri(), request.headers(), &self.config) + { + return response; + } + let method = request.method().clone(); + let supports_stateless_replay = self.session_manager.event_store().is_some(); + let allowed_methods = match (self.config.legacy_session_mode, supports_stateless_replay) { + (true, _) => "GET, POST, DELETE", + (false, true) => "GET, POST", + (false, false) => "POST", + }; + let result = match method { + Method::POST => self.handle_post(request).await, + Method::GET if self.config.legacy_session_mode || supports_stateless_replay => { + self.handle_get(request).await + } + Method::DELETE if self.config.legacy_session_mode => self.handle_delete(request).await, + _ => { + // Handle other methods or return an error + let response = Response::builder() + .status(http::StatusCode::METHOD_NOT_ALLOWED) + .header(ALLOW, allowed_methods) + .body(Full::new(Bytes::from("Method Not Allowed")).boxed()) + .expect("valid response"); + return response; + } + }; + match result { + Ok(response) => response, + Err(response) => response, + } + } + async fn handle_get(&self, request: Request) -> Result + where + B: Body + Send + 'static, + B::Error: Display, + { + // check accept header + if !request + .headers() + .get(http::header::ACCEPT) + .and_then(|header| header.to_str().ok()) + .is_some_and(|header| header.contains(EVENT_STREAM_MIME_TYPE)) + { + return Ok(Response::builder() + .status(http::StatusCode::NOT_ACCEPTABLE) + .body( + Full::new(Bytes::from( + "Not Acceptable: Client must accept text/event-stream", + )) + .boxed(), + ) + .expect("valid response")); + } + let request_uses_legacy_protocol = is_legacy_request(None, request.headers())?; + let legacy_request = self.config.legacy_session_mode && request_uses_legacy_protocol; + if !legacy_request { + let Some(last_event_id) = request + .headers() + .get(HEADER_LAST_EVENT_ID) + .and_then(|value| value.to_str().ok()) + else { + return Ok(method_not_allowed_response()); + }; + let Some(event_store) = self.session_manager.event_store() else { + return Ok(method_not_allowed_response()); + }; + let stream = match event_store.replay_events_after(last_event_id).await { + Ok(stream) => stream, + Err(error) => { + tracing::warn!(%error, "stateless SSE resume failed, returning empty stream"); + Box::pin(futures::stream::empty()) + } + }; + return Ok(sse_stream_response( + stream, + self.config.sse_keep_alive, + self.config.cancellation_token.child_token(), + )); + } + // check session id + let session_id = request + .headers() + .get(HEADER_SESSION_ID) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_owned().into()); + let Some(session_id) = session_id else { + // MCP spec: servers that require a session ID SHOULD respond with 400 Bad Request + return Ok(Response::builder() + .status(http::StatusCode::BAD_REQUEST) + .body(Full::new(Bytes::from("Bad Request: Session ID is required")).boxed()) + .expect("valid response")); + }; + // check if session exists + let has_session = self + .session_manager + .has_session(&session_id) + .await + .map_err(internal_error_response("check session"))?; + let (parts, _) = request.into_parts(); + if !has_session { + // Attempt transparent cross-instance restore from external store. + let restored = self + .try_restore_from_store(&session_id, &parts) + .await + .map_err(internal_error_response("restore session"))?; + if !restored { + // MCP spec: server MUST respond with 404 Not Found for terminated/unknown sessions + return Ok(Response::builder() + .status(http::StatusCode::NOT_FOUND) + .body(Full::new(Bytes::from("Not Found: Session not found")).boxed()) + .expect("valid response")); + } + } + // Validate MCP-Protocol-Version header (per 2025-06-18 spec) + validate_protocol_version_header(&parts.headers, false)?; + // check if last event id is provided + let last_event_id = parts + .headers + .get(HEADER_LAST_EVENT_ID) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_owned()); + if let Some(last_event_id) = last_event_id { + match self + .session_manager + .resume(&session_id, last_event_id) + .await + { + Ok(stream) => { + return Ok(sse_stream_response( + stream, + self.config.sse_keep_alive, + self.config.cancellation_token.child_token(), + )); + } + Err(e) => { + // Return 200 with an immediately-closed empty stream. + // Returning an HTTP error would cause EventSource to retry + // with the same Last-Event-ID in an infinite loop. An empty + // 200 cleanly terminates the EventSource without delivering + // events from a different stream. + tracing::warn!("Resume failed ({e}), returning empty stream"); + return Ok(sse_stream_response( + futures::stream::empty(), + None, + self.config.cancellation_token.child_token(), + )); + } + } + } + // No Last-Event-ID — create standalone stream + let stream = self + .session_manager + .create_standalone_stream(&session_id) + .await + .map_err(internal_error_response("create standalone stream"))?; + let stream = if let Some(retry) = self.config.sse_retry { + let priming = if self.session_manager.event_store().is_some() { + ServerSseMessage::retry(retry) + } else { + ServerSseMessage::priming("0", retry) + }; + futures::stream::once(async move { priming }) + .chain(stream) + .left_stream() + } else { + stream.right_stream() + }; + Ok(sse_stream_response( + stream, + self.config.sse_keep_alive, + self.config.cancellation_token.child_token(), + )) + } + + async fn handle_post(&self, request: Request) -> Result + where + B: Body + Send + 'static, + B::Error: Display, + { + // check accept header + if !request + .headers() + .get(http::header::ACCEPT) + .and_then(|header| header.to_str().ok()) + .is_some_and(|header| { + header.contains(JSON_MIME_TYPE) && header.contains(EVENT_STREAM_MIME_TYPE) + }) + { + return Ok(Response::builder() + .status(http::StatusCode::NOT_ACCEPTABLE) + .body(Full::new(Bytes::from("Not Acceptable: Client must accept both application/json and text/event-stream")).boxed()) + .expect("valid response")); + } + + // check content type + if !request + .headers() + .get(http::header::CONTENT_TYPE) + .and_then(|header| header.to_str().ok()) + .is_some_and(|header| header.starts_with(JSON_MIME_TYPE)) + { + return Ok(Response::builder() + .status(http::StatusCode::UNSUPPORTED_MEDIA_TYPE) + .body( + Full::new(Bytes::from( + "Unsupported Media Type: Content-Type must be application/json", + )) + .boxed(), + ) + .expect("valid response")); + } + + // json deserialize request body + let (part, body) = request.into_parts(); + let mut message = match expect_json(body, self.config.max_request_body_bytes).await { + Ok(message) => message, + Err(response) => return Ok(response), + }; + + let use_session = + self.config.legacy_session_mode && is_legacy_request(Some(&message), &part.headers)?; + + if use_session { + // do we have a session id? + let session_id = part + .headers + .get(HEADER_SESSION_ID) + .and_then(|v| v.to_str().ok()); + if let Some(session_id) = session_id { + let session_id = session_id.to_owned().into(); + let has_session = self + .session_manager + .has_session(&session_id) + .await + .map_err(internal_error_response("check session"))?; + if !has_session { + // Attempt transparent cross-instance restore from external store. + let restored = self + .try_restore_from_store(&session_id, &part) + .await + .map_err(internal_error_response("restore session"))?; + if !restored { + // MCP spec: server MUST respond with 404 Not Found for terminated/unknown sessions + return Ok(Response::builder() + .status(http::StatusCode::NOT_FOUND) + .body(Full::new(Bytes::from("Not Found: Session not found")).boxed()) + .expect("valid response")); + } + } + + // Validate MCP-Protocol-Version header (per 2025-06-18 spec) + let has_per_request_version = message_has_per_request_protocol_version(&message); + validate_protocol_version_header(&part.headers, has_per_request_version)?; + validate_request_protocol_version_meta(&part.headers, &message)?; + // Validate SEP-2243 standard headers against the body + validate_standard_headers(&part.headers, &message, |name| self.tool_schema(name))?; + + // inject request part to extensions + match &mut message { + ClientJsonRpcMessage::Request(req) => { + req.request.extensions_mut().insert(part); + } + ClientJsonRpcMessage::Notification(not) => { + not.notification.extensions_mut().insert(part); + } + _ => { + // skip + } + } + + match message { + ClientJsonRpcMessage::Request(_) => { + // Priming for request-wise streams is handled by the + // session layer (SessionManager::create_stream) which + // has access to the http_request_id for correct event IDs. + let stream = self + .session_manager + .create_stream(&session_id, message) + .await + .map_err(internal_error_response("get session"))?; + Ok(sse_stream_response( + stream, + self.config.sse_keep_alive, + self.config.cancellation_token.child_token(), + )) + } + ClientJsonRpcMessage::Notification(_) + | ClientJsonRpcMessage::Response(_) + | ClientJsonRpcMessage::Error(_) => { + // handle notification + self.session_manager + .accept_message(&session_id, message) + .await + .map_err(internal_error_response("accept message"))?; + Ok(accepted_response()) + } + } + } else { + if matches!( + &message, + ClientJsonRpcMessage::Request(request) + if matches!(&request.request, ClientRequest::DiscoverRequest(_)) + ) { + validate_protocol_version_header( + &part.headers, + message_has_per_request_protocol_version(&message), + )?; + validate_standard_headers(&part.headers, &message, |name| { + self.tool_schema(name) + })?; + validate_request_protocol_version_meta(&part.headers, &message)?; + let ClientJsonRpcMessage::Request(request) = message else { + unreachable!("guarded as a request above"); + }; + let service = self + .get_service() + .map_err(internal_error_response("get service"))?; + return self + .serve_negotiated_request_directly(service, request, part) + .await; + } + // Capture init params for external store persistence before + // extensions are injected (which would require Clone). + let stored_init_params = match &mut message { + ClientJsonRpcMessage::Request(req) => { + let ClientRequest::InitializeRequest(init_req) = &req.request else { + return Err(unexpected_message_response("initialize request")); + }; + // Reject mismatched MCP-Protocol-Version header before binding the session to anything. + validate_header_matches_init_body( + &part.headers, + init_req.params.protocol_version.as_str(), + Some(req.id.clone()), + )?; + let stored_init_params = self + .config + .session_store + .as_ref() + .map(|_| init_req.params.clone()); + // inject request part to extensions + req.request.extensions_mut().insert(part); + stored_init_params + } + _ => { + return Err(unexpected_message_response("initialize request")); + } + }; + let service = self + .get_service() + .map_err(internal_error_response("get service"))?; + let (session_id, transport) = self + .session_manager + .create_session() + .await + .map_err(internal_error_response("create session"))?; + // spawn a task to serve the session + Self::spawn_session_worker( + self.session_manager.clone(), + session_id.clone(), + service, + transport, + None, + ); + // get initialize response + let response = self + .session_manager + .initialize_session(&session_id, message) + .await + .map_err(internal_error_response("create stream"))?; + // Persist session state to external store after a successful handshake. + if let (Some(store), Some(params)) = + (&self.config.session_store, stored_init_params) + { + let state = SessionState { + initialize_params: params, + }; + let _ = store + .store(session_id.as_ref(), &state) + .await + .inspect_err(|e| { + tracing::warn!( + "Failed to persist session {} to store: {e}", + session_id + ); + }); + } + let stream = + futures::stream::once(async move { ServerSseMessage::from_message(response) }); + // Prepend priming event if sse_retry configured + let stream = if let Some(retry) = self.config.sse_retry { + let priming = ServerSseMessage::priming("0", retry); + futures::stream::once(async move { priming }) + .chain(stream) + .left_stream() + } else { + stream.right_stream() + }; + let mut response = sse_stream_response( + stream, + self.config.sse_keep_alive, + self.config.cancellation_token.child_token(), + ); + + response.headers_mut().insert( + HEADER_SESSION_ID, + session_id + .parse() + .map_err(internal_error_response("create session id header"))?, + ); + Ok(response) + } + } else { + // Stateless mode: + // - on initialize: the header (if present) must match `params.protocolVersion` + // - on every other request: the header must name a known version. + // + // The opt-in seam applies only here so legacy session routing and + // its error precedence remain unchanged. + validate_required_protocol_header(&self.config, &part.headers, &message)?; + let has_per_request_version = message_has_per_request_protocol_version(&message); + match &message { + ClientJsonRpcMessage::Request(req) => { + if let ClientRequest::InitializeRequest(init_req) = &req.request { + validate_header_matches_init_body( + &part.headers, + init_req.params.protocol_version.as_str(), + Some(req.id.clone()), + )?; + } else { + validate_protocol_version_header(&part.headers, has_per_request_version)?; + } + } + _ => { + validate_protocol_version_header(&part.headers, has_per_request_version)?; + } + } + // Validate SEP-2243 standard headers against the body + validate_standard_headers(&part.headers, &message, |name| self.tool_schema(name))?; + validate_request_protocol_version_meta(&part.headers, &message)?; + validate_required_protocol_meta(&self.config, &message)?; + let service = self + .get_service() + .map_err(internal_error_response("get service"))?; + match message { + ClientJsonRpcMessage::Request(mut request) => { + let negotiates_per_request = has_per_request_version + || matches!(&request.request, ClientRequest::DiscoverRequest(_)); + if negotiates_per_request { + return self + .serve_negotiated_request_directly(service, request, part) + .await; + } + // Build a peer_info so context.protocol_version() works inside handlers. + // serve_directly skips the handshake and receives None by default, making + // protocol_version() always return None in stateless mode. We reconstruct it: + // - initialize requests: version comes from the request body params + // - all other requests: version comes from the MCP-Protocol-Version header + // (already validated above; absent header defaults to 2025-03-26) + let peer_info = Self::peer_info_for_stateless_request(&request, &part.headers); + request.request.extensions_mut().insert(part); + let (transport, mut receiver) = + OneshotTransport::::new(ClientJsonRpcMessage::Request(request)); + // Give this stateless request its own cancellation token so an + // unpersisted response can cancel the in-flight handler on + // disconnect (#857). + let request_ct = CancellationToken::new(); + let service = serve_directly_with_ct( + NegotiatingStatelessHttpService(service), + transport, + peer_info, + request_ct.clone(), + ); + tokio::spawn(async move { + // on service created + let _ = service.waiting().await; + }); + if self.config.json_response { + // Prefer JSON for a terminal first message. If the handler + // emits an intermediate notification or request, preserve + // the complete message sequence by falling back to SSE. + let cancel = self.config.cancellation_token.child_token(); + // Cancel the handler if the client disconnects while it is + // still producing its first message (this future is dropped + // before `receiver.recv()` completes). Disarmed once the + // handler emits anything, so a normal response is never + // cancelled. + let mut disconnect_guard = Some(request_ct.clone().drop_guard()); + let Some(message) = (tokio::select! { + res = receiver.recv() => { + if let Some(guard) = disconnect_guard.take() { + guard.disarm(); + } + res + } + _ = cancel.cancelled() => None, + }) else { + return Err(internal_error_response("empty response")( + std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "no response message received from handler", + ), + )); + }; + tracing::trace!(?message); + if matches!( + message, + ServerJsonRpcMessage::Response(_) | ServerJsonRpcMessage::Error(_) + ) { + let body = serde_json::to_vec(&message).map_err(|e| { + internal_error_response("serialize json response")(e) + })?; + Ok(Response::builder() + .status(http::StatusCode::OK) + .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) + .body(Full::new(Bytes::from(body)).boxed()) + .expect("valid response")) + } else { + Ok(self.stateless_sse_response(Some(message), receiver, request_ct)) + } + } else { + Ok(self.stateless_sse_response(None, receiver, request_ct)) + } + } + ClientJsonRpcMessage::Notification(_notification) => { + // ignore + Ok(accepted_response()) + } + ClientJsonRpcMessage::Response(_json_rpc_response) => Ok(accepted_response()), + ClientJsonRpcMessage::Error(_json_rpc_error) => Ok(accepted_response()), + } + } + } + + async fn handle_delete(&self, request: Request) -> Result + where + B: Body + Send + 'static, + B::Error: Display, + { + if !is_legacy_request(None, request.headers())? { + return Ok(method_not_allowed_response()); + } + // check session id + let session_id = request + .headers() + .get(HEADER_SESSION_ID) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_owned().into()); + let Some(session_id) = session_id else { + // MCP spec: servers that require a session ID SHOULD respond with 400 Bad Request + return Ok(Response::builder() + .status(http::StatusCode::BAD_REQUEST) + .body(Full::new(Bytes::from("Bad Request: Session ID is required")).boxed()) + .expect("valid response")); + }; + // Validate MCP-Protocol-Version header (per 2025-06-18 spec) + validate_protocol_version_header(request.headers(), false)?; + // close session + self.session_manager + .close_session(&session_id) + .await + .map_err(internal_error_response("close session"))?; + // Remove from external store: a DELETE means the client intentionally + // ends the session, so the store entry is no longer needed. + if let Some(store) = &self.config.session_store { + let _ = store.delete(session_id.as_ref()).await.inspect_err(|e| { + tracing::warn!("Failed to delete session {} from store: {e}", session_id); + }); + } + Ok(accepted_response()) + } + + /// Build a `ClientInfo` (peer_info) for a stateless request so that + /// `context.protocol_version()` returns the correct value inside handlers. + /// + /// `serve_directly` skips the MCP handshake and accepts `peer_info = None`, + /// which means `context.protocol_version()` is always `None` in stateless mode. + /// We reconstruct the protocol version from the available signal per request type: + /// - initialize: version is in the request body params (authoritative) + /// - all other requests: version is in the MCP-Protocol-Version header + /// (validated before this point; absent header defaults to 2025-03-26) + fn peer_info_for_stateless_request( + request: &crate::model::JsonRpcRequest, + headers: &HeaderMap, + ) -> Option { + let version = if let ClientRequest::InitializeRequest(ref init) = request.request { + init.params.protocol_version.clone() + } else { + headers + .get(HEADER_MCP_PROTOCOL_VERSION) + .and_then(|v| v.to_str().ok()) + .and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_owned())).ok()) + .unwrap_or(ProtocolVersion::V_2025_03_26) + }; + Some(InitializeRequestParams { + meta: None, + protocol_version: version, + capabilities: ClientCapabilities::default(), + client_info: Implementation::default(), + }) + } +} + +pin_project! { + /// Cancels an unpersisted stateless request when its response is dropped. + /// + /// Persisted requests keep running so another connection can resume them. + /// Without an event store, dropping the stream fires the request's + /// cancellation token. Natural completion disarms the guard. + struct CancelOnDisconnect { + #[pin] + inner: S, + ct: Option, + } + impl PinnedDrop for CancelOnDisconnect { + fn drop(this: Pin<&mut Self>) { + let this = this.project(); + if let Some(ct) = this.ct.take() { + ct.cancel(); + } + } + } +} + +impl CancelOnDisconnect { + fn new(inner: S, ct: CancellationToken) -> Self { + Self { + inner, + ct: Some(ct), + } + } +} + +impl Stream for CancelOnDisconnect { + type Item = S::Item; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.project(); + let polled = this.inner.poll_next(cx); + if let Poll::Ready(None) = &polled { + // Ended naturally: the request completed, so don't cancel on drop. + *this.ct = None; + } + polled + } +} diff --git a/vendor/rmcp/src/transport/worker.rs b/vendor/rmcp/src/transport/worker.rs new file mode 100644 index 000000000..5294640e5 --- /dev/null +++ b/vendor/rmcp/src/transport/worker.rs @@ -0,0 +1,217 @@ +use std::{borrow::Cow, time::Duration}; + +use tokio_util::sync::CancellationToken; +use tracing::{Instrument, Level}; + +use super::{IntoTransport, Transport}; +use crate::service::{RxJsonRpcMessage, ServiceRole, TxJsonRpcMessage}; + +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum WorkerQuitReason { + #[error("Join error {0}")] + Join(#[from] tokio::task::JoinError), + #[error("Transport fatal {error}, when {context}")] + Fatal { + error: E, + context: Cow<'static, str>, + }, + #[error("Transport cancelled")] + Cancelled, + #[error("Transport closed")] + TransportClosed, + #[error("Handler terminated")] + HandlerTerminated, + #[error("Worker idle timeout after {}ms", _0.as_millis())] + IdleTimeout(Duration), +} + +impl WorkerQuitReason { + pub fn fatal(error: E, context: impl Into>) -> Self { + Self::Fatal { + error, + context: context.into(), + } + } + pub fn fatal_context(context: impl Into>) -> impl FnOnce(E) -> Self { + |e| Self::Fatal { + error: e, + context: context.into(), + } + } +} + +pub trait Worker: Sized + Send + 'static { + type Error: std::error::Error + Send + Sync + 'static; + type Role: ServiceRole; + fn err_closed() -> Self::Error; + fn err_join(e: tokio::task::JoinError) -> Self::Error; + fn run( + self, + context: WorkerContext, + ) -> impl Future>> + Send; + fn config(&self) -> WorkerConfig { + WorkerConfig::default() + } +} + +#[non_exhaustive] +pub struct WorkerSendRequest { + pub message: TxJsonRpcMessage, + pub responder: tokio::sync::oneshot::Sender>, +} + +pub struct WorkerTransport { + rx: tokio::sync::mpsc::Receiver>, + send_service: tokio::sync::mpsc::Sender>, + join_handle: Option>>>, + _drop_guard: tokio_util::sync::DropGuard, + ct: CancellationToken, +} + +#[non_exhaustive] +pub struct WorkerConfig { + pub name: Option, + pub channel_buffer_capacity: usize, +} + +impl Default for WorkerConfig { + fn default() -> Self { + Self { + name: None, + channel_buffer_capacity: 16, + } + } +} +#[non_exhaustive] +pub enum WorkerAdapter {} + +impl IntoTransport for W { + fn into_transport(self) -> impl Transport + 'static { + WorkerTransport::spawn(self) + } +} + +impl WorkerTransport { + pub fn cancel_token(&self) -> CancellationToken { + self.ct.clone() + } + pub fn spawn(worker: W) -> Self { + Self::spawn_with_ct(worker, CancellationToken::new()) + } + pub fn spawn_with_ct(worker: W, transport_task_ct: CancellationToken) -> Self { + let config = worker.config(); + let worker_name = config.name; + let (to_transport_tx, from_handler_rx) = + tokio::sync::mpsc::channel::>(config.channel_buffer_capacity); + let (to_handler_tx, from_transport_rx) = + tokio::sync::mpsc::channel::>(config.channel_buffer_capacity); + let context = WorkerContext { + to_handler_tx, + from_handler_rx, + cancellation_token: transport_task_ct.clone(), + }; + + let join_handle = tokio::spawn(async move { + worker + .run(context) + .instrument(tracing::span!( + Level::TRACE, + "transport_worker", + name = worker_name, + )) + .await + .inspect_err(|e| match e { + WorkerQuitReason::Cancelled + | WorkerQuitReason::TransportClosed + | WorkerQuitReason::HandlerTerminated + | WorkerQuitReason::IdleTimeout(_) => { + tracing::debug!("worker quit with reason: {:?}", e); + } + WorkerQuitReason::Join(e) => { + tracing::error!("worker quit with join error: {:?}", e); + } + WorkerQuitReason::Fatal { error, context } => { + tracing::error!("worker quit with fatal: {error}, when {context}"); + } + }) + .inspect(|_| { + tracing::debug!("worker quit"); + }) + }); + Self { + rx: from_transport_rx, + send_service: to_transport_tx, + join_handle: Some(join_handle), + ct: transport_task_ct.clone(), + _drop_guard: transport_task_ct.drop_guard(), + } + } +} + +#[non_exhaustive] +pub struct SendRequest { + pub message: TxJsonRpcMessage, + pub responder: tokio::sync::oneshot::Sender>, +} + +#[non_exhaustive] +pub struct WorkerContext { + pub to_handler_tx: tokio::sync::mpsc::Sender>, + pub from_handler_rx: tokio::sync::mpsc::Receiver>, + pub cancellation_token: CancellationToken, +} + +impl WorkerContext { + pub async fn send_to_handler( + &mut self, + item: RxJsonRpcMessage, + ) -> Result<(), WorkerQuitReason> { + self.to_handler_tx + .send(item) + .await + .map_err(|_| WorkerQuitReason::HandlerTerminated) + } + + pub async fn recv_from_handler( + &mut self, + ) -> Result, WorkerQuitReason> { + self.from_handler_rx + .recv() + .await + .ok_or(WorkerQuitReason::HandlerTerminated) + } +} + +impl Transport for WorkerTransport { + type Error = W::Error; + + fn send( + &mut self, + item: TxJsonRpcMessage, + ) -> impl Future> + Send + 'static { + let tx = self.send_service.clone(); + let (responder, receiver) = tokio::sync::oneshot::channel(); + let request = WorkerSendRequest { + message: item, + responder, + }; + async move { + tx.send(request).await.map_err(|_| W::err_closed())?; + receiver.await.map_err(|_| W::err_closed())??; + Ok(()) + } + } + async fn receive(&mut self) -> Option> { + self.rx.recv().await + } + async fn close(&mut self) -> Result<(), Self::Error> { + if let Some(handle) = self.join_handle.take() { + self.ct.cancel(); + let _quit_reason = handle.await.map_err(W::err_join)?; + Ok(()) + } else { + Ok(()) + } + } +} diff --git a/vendor/rmcp/src/transport/ws.rs b/vendor/rmcp/src/transport/ws.rs new file mode 100644 index 000000000..5ec9cbac9 --- /dev/null +++ b/vendor/rmcp/src/transport/ws.rs @@ -0,0 +1 @@ +// Maybe we don't really need a ws implementation? diff --git a/vendor/rmcp/tests/common/calculator.rs b/vendor/rmcp/tests/common/calculator.rs new file mode 100644 index 000000000..22c6d38ef --- /dev/null +++ b/vendor/rmcp/tests/common/calculator.rs @@ -0,0 +1,59 @@ +#![allow(dead_code)] +use rmcp::{ + ServerHandler, + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{ServerCapabilities, ServerInfo}, + schemars, tool, tool_router, +}; +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct SumRequest { + #[schemars(description = "the left hand side number")] + pub a: i32, + pub b: i32, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct SubRequest { + #[schemars(description = "the left hand side number")] + pub a: i32, + #[schemars(description = "the right hand side number")] + pub b: i32, +} +#[derive(Debug, Clone)] +pub struct Calculator { + tool_router: ToolRouter, +} + +impl Calculator { + pub fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } +} + +impl Default for Calculator { + fn default() -> Self { + Self::new() + } +} + +#[tool_router] +impl Calculator { + #[tool(description = "Calculate the sum of two numbers")] + fn sum(&self, Parameters(SumRequest { a, b }): Parameters) -> String { + (a + b).to_string() + } + + #[tool(description = "Calculate the sub of two numbers")] + fn sub(&self, Parameters(SubRequest { a, b }): Parameters) -> String { + (a - b).to_string() + } +} + +impl ServerHandler for Calculator { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_instructions("A simple calculator") + } +} diff --git a/vendor/rmcp/tests/common/handlers.rs b/vendor/rmcp/tests/common/handlers.rs new file mode 100644 index 000000000..7928fa6eb --- /dev/null +++ b/vendor/rmcp/tests/common/handlers.rs @@ -0,0 +1,190 @@ +// Sampling/Roots/Logging are SEP-2577-deprecated; this test handler exercises them. +#![expect(deprecated)] +use std::{ + future::Future, + sync::{Arc, Mutex}, +}; + +#[cfg(feature = "client")] +use rmcp::service::NotificationContext; +#[cfg(feature = "client")] +use rmcp::{ClientHandler, RoleClient}; +use rmcp::{ + ErrorData as McpError, RoleServer, ServerHandler, + model::*, + service::{MaybeSendFuture, RequestContext}, +}; +#[cfg(feature = "client")] +use serde_json::json; +use tokio::sync::Notify; + +#[derive(Clone)] +#[allow(dead_code)] +pub struct TestClientHandler { + pub honor_this_server: bool, + pub honor_all_servers: bool, + pub receive_signal: Arc, + pub received_messages: Arc>>, +} + +impl TestClientHandler { + #[allow(dead_code)] + pub fn new(honor_this_server: bool, honor_all_servers: bool) -> Self { + Self { + honor_this_server, + honor_all_servers, + receive_signal: Arc::new(Notify::new()), + received_messages: Arc::new(Mutex::new(Vec::new())), + } + } + + #[allow(dead_code)] + pub fn with_notification( + honor_this_server: bool, + honor_all_servers: bool, + receive_signal: Arc, + received_messages: Arc>>, + ) -> Self { + Self { + honor_this_server, + honor_all_servers, + receive_signal, + received_messages, + } + } +} + +#[cfg(feature = "client")] +impl ClientHandler for TestClientHandler { + async fn create_message( + &self, + params: CreateMessageRequestParams, + _context: RequestContext, + ) -> Result { + // First validate that there's at least one User message + if !params.messages.iter().any(|msg| msg.role == Role::User) { + return Err(McpError::invalid_request( + "Message sequence must contain at least one user message", + Some(json!({"messages": params.messages})), + )); + } + + // Create response based on context inclusion + let response = match params.include_context { + Some(ContextInclusion::ThisServer) if self.honor_this_server => { + "Test response with context: test context" + } + Some(ContextInclusion::AllServers) if self.honor_all_servers => { + "Test response with context: test context" + } + _ => "Test response without context", + }; + + Ok(CreateMessageResult::new( + SamplingMessage::assistant_text(response.to_string()), + "test-model".to_string(), + ) + .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN)) + } + + fn on_logging_message( + &self, + params: LoggingMessageNotificationParam, + _context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + let receive_signal = self.receive_signal.clone(); + let received_messages = self.received_messages.clone(); + + async move { + println!("Client: Received log message: {:?}", params); + let mut messages = received_messages.lock().unwrap(); + messages.push(params); + receive_signal.notify_one(); + } + } +} + +pub struct TestServer {} + +impl TestServer { + #[allow(dead_code)] + pub fn new() -> Self { + Self {} + } +} + +impl ServerHandler for TestServer { + #[allow(deprecated)] + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_logging().build()) + } + + #[allow(deprecated)] + fn set_level( + &self, + request: SetLevelRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let peer = context.peer; + async move { + let (data, logger) = match request.level { + LoggingLevel::Error => ( + serde_json::json!({ + "message": "Failed to process request", + "error_code": "E1001", + "error_details": "Connection timeout", + "timestamp": chrono::Utc::now().to_rfc3339(), + }), + Some("error_handler".to_string()), + ), + LoggingLevel::Debug => ( + serde_json::json!({ + "message": "Processing request", + "function": "handle_request", + "line": 42, + "context": { + "request_id": "req-123", + "user_id": "user-456" + }, + "timestamp": chrono::Utc::now().to_rfc3339(), + }), + Some("debug_logger".to_string()), + ), + LoggingLevel::Info => ( + serde_json::json!({ + "message": "System status update", + "status": "healthy", + "metrics": { + "requests_per_second": 150, + "average_latency_ms": 45, + "error_rate": 0.01 + }, + "timestamp": chrono::Utc::now().to_rfc3339(), + }), + Some("monitoring".to_string()), + ), + _ => ( + serde_json::json!({ + "message": format!("Message at level {:?}", request.level), + "timestamp": chrono::Utc::now().to_rfc3339(), + }), + None, + ), + }; + + if let Err(e) = peer + .notify_logging_message({ + let mut param = LoggingMessageNotificationParam::new(request.level, data); + if let Some(l) = logger { + param = param.with_logger(l); + } + param + }) + .await + { + panic!("Failed to send notification: {}", e); + } + Ok(()) + } + } +} diff --git a/vendor/rmcp/tests/common/mod.rs b/vendor/rmcp/tests/common/mod.rs new file mode 100644 index 000000000..491960651 --- /dev/null +++ b/vendor/rmcp/tests/common/mod.rs @@ -0,0 +1,2 @@ +pub mod calculator; +pub mod handlers; diff --git a/vendor/rmcp/tests/test_auth_error_classification.rs b/vendor/rmcp/tests/test_auth_error_classification.rs new file mode 100644 index 000000000..99b050810 --- /dev/null +++ b/vendor/rmcp/tests/test_auth_error_classification.rs @@ -0,0 +1,85 @@ +use std::{any::TypeId, error::Error}; + +use rmcp::{ + service::ClientInitializeError, + transport::{ + AuthError, DynamicTransportError, + streamable_http_client::{AuthRequiredError, InsufficientScopeError, StreamableHttpError}, + }, +}; +use thiserror::Error; + +type TestHttpError = StreamableHttpError; + +#[derive(Debug, Error)] +#[error("outer transport wrapper")] +struct OuterError(#[source] TestHttpError); + +fn initialization_error(error: impl Error + Send + Sync + 'static) -> ClientInitializeError { + ClientInitializeError::TransportError { + error: DynamicTransportError::from_parts( + "test transport", + TypeId::of::<()>(), + Box::new(error), + ), + context: "initialize".into(), + } +} + +#[test] +fn classifies_local_authorization_required() { + let error = TestHttpError::Auth(AuthError::AuthorizationRequired); + + assert!(initialization_error(error).is_authorization_required()); +} + +#[test] +fn classifies_http_authorization_challenge() { + let error = + TestHttpError::AuthRequired(AuthRequiredError::new("Bearer realm=\"mcp\"".to_owned())); + + assert!(initialization_error(error).is_authorization_required()); +} + +#[test] +fn classifies_authorization_required_through_multiple_sources() { + let error = OuterError(TestHttpError::Auth(AuthError::AuthorizationRequired)); + + assert!(initialization_error(error).is_authorization_required()); +} + +#[test] +fn does_not_classify_unrelated_transport_errors() { + let closed = TestHttpError::TransportChannelClosed; + let refresh = TestHttpError::Auth(AuthError::TokenRefreshFailed("timeout".to_owned())); + let scope = TestHttpError::InsufficientScope(InsufficientScopeError::new( + "Bearer error=\"insufficient_scope\"".to_owned(), + Some("admin".to_owned()), + )); + + assert!(!initialization_error(closed).is_authorization_required()); + assert!(!initialization_error(refresh).is_authorization_required()); + assert!(!initialization_error(scope).is_authorization_required()); +} + +#[test] +fn does_not_classify_non_transport_initialization_errors() { + assert!(!ClientInitializeError::Cancelled.is_authorization_required()); + assert!( + !ClientInitializeError::ConnectionClosed("server closed the connection".to_owned()) + .is_authorization_required() + ); +} + +#[test] +fn http_challenge_remains_available_as_an_error_source() { + let error = + TestHttpError::AuthRequired(AuthRequiredError::new("Bearer realm=\"mcp\"".to_owned())); + + let source = error.source().expect("auth challenge should be a source"); + let challenge = source + .downcast_ref::() + .expect("source should retain the challenge type"); + + assert_eq!(challenge.www_authenticate_header, "Bearer realm=\"mcp\""); +} diff --git a/vendor/rmcp/tests/test_cache_hints.rs b/vendor/rmcp/tests/test_cache_hints.rs new file mode 100644 index 000000000..2b6aecd8c --- /dev/null +++ b/vendor/rmcp/tests/test_cache_hints.rs @@ -0,0 +1,79 @@ +use rmcp::model::{CacheScope, ListToolsResult, ReadResourceResult, ResourceContents}; +use serde_json::json; + +#[test] +fn paginated_results_serialize_cache_hints_as_top_level_fields() { + let result = ListToolsResult::with_all_items(Vec::new()) + .with_ttl_ms(5_000) + .with_cache_scope(CacheScope::Private); + + let actual = serde_json::to_value(result).expect("serialize list tools result"); + + assert_eq!( + actual, + json!({ + "ttlMs": 5000, + "cacheScope": "private", + "tools": [], + "resultType": "complete" + }) + ); + assert!(actual.get("_meta").is_none()); +} + +#[test] +fn read_resource_results_serialize_cache_hints_as_top_level_fields() { + let result = + ReadResourceResult::new(vec![ResourceContents::text("hello", "file:///example.txt")]) + .with_ttl_ms(10_000) + .with_cache_scope(CacheScope::Public); + + let actual = serde_json::to_value(result).expect("serialize read resource result"); + + assert_eq!(actual["ttlMs"], 10000); + assert_eq!(actual["cacheScope"], "public"); + assert!(actual["contents"][0].get("_meta").is_none()); +} + +#[test] +fn cache_hints_are_omitted_when_absent() { + let result = ListToolsResult::with_all_items(Vec::new()); + let actual = serde_json::to_value(result).expect("serialize list tools result"); + + assert_eq!(actual, json!({ "tools": [], "resultType": "complete" })); +} + +#[test] +fn cache_hints_default_to_none_and_negative_ttl_is_normalized_to_zero() { + let absent: ListToolsResult = serde_json::from_value(json!({ + "tools": [] + })) + .expect("deserialize result without ttlMs"); + assert_eq!(absent.ttl_ms, None); + assert_eq!(absent.cache_scope, None); + + let negative: ReadResourceResult = serde_json::from_value(json!({ + "ttlMs": -42, + "cacheScope": "private", + "contents": [] + })) + .expect("deserialize result with negative ttlMs"); + assert_eq!(negative.ttl_ms, Some(0)); + assert_eq!(negative.cache_scope, Some(CacheScope::Private)); +} + +#[test] +fn cache_scope_round_trips() { + assert_eq!( + serde_json::to_value(CacheScope::Public).unwrap(), + json!("public") + ); + assert_eq!( + serde_json::to_value(CacheScope::Private).unwrap(), + json!("private") + ); + assert_eq!( + serde_json::from_value::(json!("private")).unwrap(), + CacheScope::Private + ); +} diff --git a/vendor/rmcp/tests/test_cancelled_response.rs b/vendor/rmcp/tests/test_cancelled_response.rs new file mode 100644 index 000000000..5961a7b5d --- /dev/null +++ b/vendor/rmcp/tests/test_cancelled_response.rs @@ -0,0 +1,212 @@ +//! A receiver SHOULD NOT send a response for a request it has already been told +//! to cancel. This drives a real stdio server with raw JSON-RPC: the tool blocks +//! until the request is cancelled, so its result is only produced *after* the +//! cancellation — the service loop must drop it rather than write it to the wire. + +use std::{collections::BTreeSet, process::Stdio, time::Duration}; + +use rmcp::{ + ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities, + ServerInfo, + }, + service::RequestContext, +}; +use serde_json::{Value, json}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}, + process::{Child, Command}, +}; + +const HELPER_ENV: &str = "RMCP_CANCELLED_RESPONSE_HELPER"; +const READ_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn cancelled_request_receives_no_response() -> anyhow::Result<()> { + let mut child = spawn_helper(); + let mut writer = child.stdin.take().expect("helper stdin"); + let stdout = child.stdout.take().expect("helper stdout"); + let mut reader = BufReader::new(stdout); + + send_json( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "raw-test-client", "version": "0.0.0" } + } + }), + ) + .await?; + collect_ids_until(&mut reader, 1, READ_TIMEOUT).await?; + send_json( + &mut writer, + &json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), + ) + .await?; + + // Start a request that blocks until cancelled, then cancel it. Its response is + // produced only after the cancellation arrives, so it must be suppressed. + send_json( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { "name": "wait-for-cancel", "arguments": {} } + }), + ) + .await?; + send_json( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": { "requestId": 2 } + }), + ) + .await?; + // A ping proves the server is alive past the cancellation, so the absence of + // an id=2 response is genuine suppression rather than a dead connection. + send_json( + &mut writer, + &json!({ "jsonrpc": "2.0", "id": 3, "method": "ping" }), + ) + .await?; + + let seen = collect_ids_until(&mut reader, 3, READ_TIMEOUT).await?; + assert!(seen.contains(&3)); + assert!(!seen.contains(&2)); + + drop(writer); + wait_for_child(&mut child).await; + Ok(()) +} + +struct WaitForCancelServer; + +impl ServerHandler for WaitForCancelServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } + + async fn call_tool( + &self, + _request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + context.ct.cancelled().await; + Ok(CallToolResult::success(vec![ContentBlock::text("late response")]).into()) + } +} + +#[tokio::test] +async fn cancelled_response_helper() -> anyhow::Result<()> { + if std::env::var(HELPER_ENV).as_deref() != Ok("1") { + return Ok(()); + } + run_helper_server().await?; + Ok(()) +} + +#[cfg(feature = "local")] +async fn run_helper_server() -> anyhow::Result<()> { + tokio::task::LocalSet::new() + .run_until(serve_helper_stdio()) + .await +} + +#[cfg(not(feature = "local"))] +async fn run_helper_server() -> anyhow::Result<()> { + serve_helper_stdio().await +} + +async fn serve_helper_stdio() -> anyhow::Result<()> { + let server = WaitForCancelServer.serve(rmcp::transport::stdio()).await?; + server.waiting().await?; + Ok(()) +} + +fn spawn_helper() -> Child { + let exe = std::env::current_exe().expect("current test exe"); + Command::new(exe) + .arg("--exact") + .arg("cancelled_response_helper") + .arg("--quiet") + .arg("--nocapture") + .arg("--test-threads") + .arg("1") + .env(HELPER_ENV, "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .expect("spawn helper") +} + +async fn wait_for_child(child: &mut Child) { + let _ = tokio::time::timeout(Duration::from_secs(2), child.wait()).await; + if child.id().is_some() { + let _ = child.kill().await; + } +} + +async fn send_json(writer: &mut W, message: &Value) -> anyhow::Result<()> +where + W: AsyncWrite + Unpin, +{ + let serialized = serde_json::to_string(message)?; + writer.write_all(serialized.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.flush().await?; + Ok(()) +} + +/// Read response lines, collecting every message id seen, until `stop_id` is seen +/// (then a short grace read to catch any straggler) or the timeout elapses. +async fn collect_ids_until( + reader: &mut BufReader, + stop_id: u64, + timeout: Duration, +) -> anyhow::Result> +where + R: tokio::io::AsyncRead + Unpin, +{ + let mut seen = BTreeSet::new(); + let mut deadline = tokio::time::Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + let mut line = String::new(); + let Ok(read_result) = tokio::time::timeout(remaining, reader.read_line(&mut line)).await + else { + break; + }; + if read_result? == 0 { + break; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Ok(value) = serde_json::from_str::(trimmed) else { + continue; + }; + if let Some(id) = value.get("id").and_then(Value::as_u64) { + seen.insert(id); + if id == stop_id { + // Give any late (incorrectly-sent) response a brief window to arrive. + deadline = tokio::time::Instant::now() + Duration::from_millis(300); + } + } + } + Ok(seen) +} diff --git a/vendor/rmcp/tests/test_client_credentials.rs b/vendor/rmcp/tests/test_client_credentials.rs new file mode 100644 index 000000000..f21c85814 --- /dev/null +++ b/vendor/rmcp/tests/test_client_credentials.rs @@ -0,0 +1,258 @@ +use std::{convert::Infallible, net::SocketAddr}; + +use axum::{ + Router, + body::Body, + http::{Request, Response, StatusCode}, + routing::{get, post}, +}; +use rmcp::transport::auth::{ClientCredentialsConfig, OAuthState}; + +fn json_response(body: serde_json::Value) -> Response { + Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap() +} + +fn json_error(status: StatusCode, body: serde_json::Value) -> Response { + Response::builder() + .status(status) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap() +} + +async fn resource_metadata_handler(req: Request) -> Result, Infallible> { + let host = req.headers().get("host").unwrap().to_str().unwrap(); + let base_url = format!("http://{}", host); + Ok(json_response(serde_json::json!({ + "resource": base_url, + "authorization_servers": [base_url], + "scopes_supported": ["read", "write"] + }))) +} + +async fn auth_server_metadata_handler(req: Request) -> Result, Infallible> { + let host = req.headers().get("host").unwrap().to_str().unwrap(); + let base_url = format!("http://{}", host); + Ok(auth_server_metadata_response(&base_url, &base_url)) +} + +async fn path_inserted_auth_server_metadata_handler( + req: Request, +) -> Result, Infallible> { + let host = req.headers().get("host").unwrap().to_str().unwrap(); + let base_url = format!("http://{}", host); + Ok(auth_server_metadata_response( + &format!("{}/mcp", base_url), + &base_url, + )) +} + +fn auth_server_metadata_response(issuer: &str, endpoint_base_url: &str) -> Response { + json_response(serde_json::json!({ + "issuer": issuer, + "authorization_endpoint": format!("{}/authorize", endpoint_base_url), + "token_endpoint": format!("{}/token", endpoint_base_url), + "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + "grant_types_supported": ["client_credentials"], + "scopes_supported": ["read", "write"] + })) +} + +async fn token_handler(req: Request) -> Result, Infallible> { + let body_bytes = axum::body::to_bytes(req.into_body(), 1024 * 64) + .await + .unwrap(); + let body_str = String::from_utf8(body_bytes.to_vec()).unwrap(); + + // Parse form-urlencoded body + let params: Vec<(String, String)> = url::form_urlencoded::parse(body_str.as_bytes()) + .into_owned() + .collect(); + + let get_param = |key: &str| -> Option { + params + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.clone()) + }; + + let grant_type = get_param("grant_type").unwrap_or_default(); + if grant_type != "client_credentials" { + return Ok(json_error( + StatusCode::BAD_REQUEST, + serde_json::json!({ + "error": "unsupported_grant_type", + "error_description": "Only client_credentials grant type is supported" + }), + )); + } + + let client_id = get_param("client_id").unwrap_or_default(); + if client_id != "test-m2m-client" { + return Ok(json_error( + StatusCode::UNAUTHORIZED, + serde_json::json!({ + "error": "invalid_client", + "error_description": "Unknown client_id" + }), + )); + } + + let client_secret = get_param("client_secret").unwrap_or_default(); + if client_secret != "test-m2m-secret" { + return Ok(json_error( + StatusCode::UNAUTHORIZED, + serde_json::json!({ + "error": "invalid_client", + "error_description": "Invalid client_secret" + }), + )); + } + + let scope = get_param("scope").unwrap_or_default(); + + let mut response = serde_json::json!({ + "access_token": "m2m-access-token-12345", + "token_type": "Bearer", + "expires_in": 3600 + }); + + if !scope.is_empty() { + response["scope"] = serde_json::Value::String(scope); + } + + Ok(json_response(response)) +} + +async fn start_mock_server() -> (String, SocketAddr) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let base_url = format!("http://{}", addr); + + let app = Router::new() + .route( + "/.well-known/oauth-protected-resource", + get(resource_metadata_handler), + ) + .route( + "/.well-known/oauth-authorization-server", + get(auth_server_metadata_handler), + ) + .route("/token", post(token_handler)); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + (base_url, addr) +} + +async fn start_path_insert_metadata_server() -> (String, SocketAddr) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let base_url = format!("http://{}", addr); + + let app = Router::new() + .route( + "/.well-known/oauth-authorization-server/mcp", + get(path_inserted_auth_server_metadata_handler), + ) + .route("/token", post(token_handler)); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + (base_url, addr) +} + +#[tokio::test] +async fn test_client_credentials_flow_client_secret() { + let (base_url, _addr) = start_mock_server().await; + + let mut oauth_state = OAuthState::new(&base_url, None).await.unwrap(); + + let config = ClientCredentialsConfig::ClientSecret { + client_id: "test-m2m-client".to_string(), + client_secret: "test-m2m-secret".to_string(), + scopes: vec!["read".to_string(), "write".to_string()], + resource: Some(base_url.clone()), + }; + + oauth_state + .authenticate_client_credentials(config) + .await + .unwrap(); + + let manager = oauth_state + .into_authorization_manager() + .expect("Should be in Authorized state"); + + let token = manager.get_access_token().await.unwrap(); + assert_eq!(token, "m2m-access-token-12345"); +} + +#[tokio::test] +async fn test_client_credentials_discovers_path_inserted_oauth_metadata() { + let (base_url, _addr) = start_path_insert_metadata_server().await; + let resource_url = format!("{base_url}/mcp"); + + let mut oauth_state = OAuthState::new(&resource_url, None).await.unwrap(); + + let config = ClientCredentialsConfig::ClientSecret { + client_id: "test-m2m-client".to_string(), + client_secret: "test-m2m-secret".to_string(), + scopes: vec!["read".to_string()], + resource: Some(resource_url), + }; + + oauth_state + .authenticate_client_credentials(config) + .await + .unwrap(); + + let manager = oauth_state + .into_authorization_manager() + .expect("Should be in Authorized state"); + + let token = manager.get_access_token().await.unwrap(); + assert_eq!(token, "m2m-access-token-12345"); +} + +#[tokio::test] +async fn test_client_credentials_invalid_secret() { + let (base_url, _addr) = start_mock_server().await; + + let mut oauth_state = OAuthState::new(&base_url, None).await.unwrap(); + + let config = ClientCredentialsConfig::ClientSecret { + client_id: "test-m2m-client".to_string(), + client_secret: "wrong-secret".to_string(), + scopes: vec![], + resource: Some(base_url.clone()), + }; + + let result = oauth_state.authenticate_client_credentials(config).await; + assert!(result.is_err(), "Should fail with invalid credentials"); +} + +#[tokio::test] +async fn test_client_credentials_invalid_client_id() { + let (base_url, _addr) = start_mock_server().await; + + let mut oauth_state = OAuthState::new(&base_url, None).await.unwrap(); + + let config = ClientCredentialsConfig::ClientSecret { + client_id: "unknown-client".to_string(), + client_secret: "test-m2m-secret".to_string(), + scopes: vec![], + resource: Some(base_url.clone()), + }; + + let result = oauth_state.authenticate_client_credentials(config).await; + assert!(result.is_err(), "Should fail with unknown client_id"); +} diff --git a/vendor/rmcp/tests/test_client_initialization.rs b/vendor/rmcp/tests/test_client_initialization.rs new file mode 100644 index 000000000..960e1cf53 --- /dev/null +++ b/vendor/rmcp/tests/test_client_initialization.rs @@ -0,0 +1,152 @@ +// cargo test --features "server client" --package rmcp test_client_initialization +#![cfg(all(feature = "client", not(feature = "local")))] + +mod common; + +use std::borrow::Cow; + +use common::handlers::TestClientHandler; +use rmcp::{ + ServiceExt, + model::{ + ClientJsonRpcMessage, ErrorCode, ErrorData, InitializeResult, JsonRpcError, + JsonRpcVersion2_0, RequestId, ServerCapabilities, ServerJsonRpcMessage, ServerResult, + }, + transport::{IntoTransport, Transport}, +}; + +fn stringify_numeric_id(id: RequestId) -> RequestId { + let RequestId::Number(id) = id else { + panic!("expected a numeric request ID"); + }; + RequestId::String(id.to_string().into()) +} + +#[tokio::test] +async fn client_initialization_accepts_stringified_numeric_response_id() { + let (server_transport, client_transport) = tokio::io::duplex(1024); + let mut server = IntoTransport::::into_transport(server_transport); + let server_task = tokio::spawn(async move { + let ClientJsonRpcMessage::Request(request) = + server.receive().await.expect("expected initialize request") + else { + panic!("expected initialize request"); + }; + server + .send(ServerJsonRpcMessage::response( + ServerResult::InitializeResult( + InitializeResult::new(ServerCapabilities::default()), + ), + stringify_numeric_id(request.id), + )) + .await + .expect("send initialize response"); + assert!(matches!( + server.receive().await, + Some(ClientJsonRpcMessage::Notification(_)) + )); + }); + + let client = TestClientHandler::new(true, true) + .serve(client_transport) + .await + .expect("client should accept stringified initialize response ID"); + assert!( + client + .peer_info() + .expect("peer info should be retained") + .server_info + .is_some(), + "initialize always provides a server implementation identity" + ); + client.cancel().await.expect("cancel client"); + server_task.await.expect("server task"); +} + +#[tokio::test] +async fn client_correlates_stringified_numeric_response_id() { + let (server_transport, client_transport) = tokio::io::duplex(1024); + let mut server = IntoTransport::::into_transport(server_transport); + let server_task = tokio::spawn(async move { + let ClientJsonRpcMessage::Request(initialize) = + server.receive().await.expect("expected initialize request") + else { + panic!("expected initialize request"); + }; + server + .send(ServerJsonRpcMessage::response( + ServerResult::InitializeResult( + InitializeResult::new(ServerCapabilities::default()), + ), + initialize.id, + )) + .await + .expect("send initialize response"); + assert!(matches!( + server.receive().await, + Some(ClientJsonRpcMessage::Notification(_)) + )); + + let ClientJsonRpcMessage::Request(request) = + server.receive().await.expect("expected tools/list request") + else { + panic!("expected tools/list request"); + }; + server + .send(ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(Default::default()), + stringify_numeric_id(request.id), + )) + .await + .expect("send tools/list response"); + }); + + let client = TestClientHandler::new(true, true) + .serve(client_transport) + .await + .expect("initialize client"); + client + .list_tools(None) + .await + .expect("client should correlate stringified response ID"); + client.cancel().await.expect("cancel client"); + server_task.await.expect("server task"); +} + +#[tokio::test] +async fn test_client_init_handles_jsonrpc_error() { + let (server_transport, client_transport) = tokio::io::duplex(1024); + let mut server = IntoTransport::::into_transport(server_transport); + + let client_handle = tokio::spawn(async move { + TestClientHandler::new(true, true) + .serve(client_transport) + .await + }); + + tokio::spawn(async move { + let _init_request = server.receive().await; + + let error_msg = ServerJsonRpcMessage::Error(JsonRpcError { + jsonrpc: JsonRpcVersion2_0, + id: Some(RequestId::Number(1)), + error: ErrorData { + code: ErrorCode(-32600), + message: Cow::Borrowed("Invalid Request"), + data: None, + }, + }); + let _: Result<(), _> = server.send(error_msg).await; + }); + + let result = client_handle.await.unwrap(); + + assert!(result.is_err()); + match result { + Err(rmcp::service::ClientInitializeError::JsonRpcError(error_data)) => { + assert_eq!(error_data.code, ErrorCode(-32600)); + assert_eq!(error_data.message, "Invalid Request"); + } + _ => panic!("Expected ClientInitializeError::JsonRpcError"), + } +} diff --git a/vendor/rmcp/tests/test_client_lifecycle_modes.rs b/vendor/rmcp/tests/test_client_lifecycle_modes.rs new file mode 100644 index 000000000..4b75c3550 --- /dev/null +++ b/vendor/rmcp/tests/test_client_lifecycle_modes.rs @@ -0,0 +1,463 @@ +#![cfg(all(feature = "client", feature = "server", not(feature = "local")))] + +use rmcp::{ + ClientHandler, ClientLifecycleMode, ClientServiceExt, ServerHandler, ServiceExt, + model::{ + ClientJsonRpcMessage, ClientRequest, DiscoverResult, ErrorCode, ErrorData, GetMeta, + Implementation, InitializeResult, ProtocolVersion, RequestId, ServerCapabilities, + ServerJsonRpcMessage, ServerResult, + }, + service::PeerRequestOptions, + transport::{IntoTransport, Transport}, +}; + +#[derive(Clone, Default)] +struct DiscoverClient; + +impl ClientHandler for DiscoverClient {} + +#[derive(Clone, Default)] +struct StatelessServer; + +impl ServerHandler for StatelessServer {} + +#[tokio::test] +async fn discover_startup_accepts_stringified_numeric_response_id() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server = IntoTransport::::into_transport(server_transport); + let server_task = tokio::spawn(async move { + let ClientJsonRpcMessage::Request(request) = + server.receive().await.expect("expected discover request") + else { + panic!("expected discover request"); + }; + let RequestId::Number(response_id) = request.id else { + panic!("expected a numeric request ID"); + }; + server + .send(ServerJsonRpcMessage::response( + ServerResult::DiscoverResult( + DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + ServerCapabilities::default(), + ) + .with_server_info(Implementation::new("discover-server", "1.0.0")), + ), + RequestId::String(response_id.to_string().into()), + )) + .await + .expect("send discover response"); + }); + + let client = DiscoverClient + .serve_with_lifecycle( + client_transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await + .expect("client should accept stringified discover response ID"); + client.cancel().await.expect("cancel client"); + server_task.await.expect("server task"); +} + +#[tokio::test] +#[allow(deprecated)] +async fn discover_startup_accepts_missing_optional_server_info() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server = IntoTransport::::into_transport(server_transport); + let (rejection_observed_tx, rejection_observed_rx) = tokio::sync::oneshot::channel(); + let server_task = tokio::spawn(async move { + let ClientJsonRpcMessage::Request(discover_request) = + server.receive().await.expect("expected discover request") + else { + panic!("expected discover request"); + }; + let mut result = DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + ServerCapabilities::builder().enable_tools().build(), + ); + result.instructions = Some("discovery instructions".into()); + result.meta = Some(rmcp::model::MetaObject::new()); + result + .meta + .as_mut() + .expect("metadata") + .0 + .insert("example.test/key".into(), serde_json::json!(7)); + server + .send(ServerJsonRpcMessage::response( + ServerResult::DiscoverResult(result), + discover_request.id, + )) + .await + .expect("send discover response"); + + server + .send(ServerJsonRpcMessage::request( + rmcp::model::ServerRequest::CreateMessageRequest( + rmcp::model::CreateMessageRequest::new( + rmcp::model::CreateMessageRequestParams::new( + vec![rmcp::model::SamplingMessage::user_text("unsolicited")], + 16, + ), + ), + ), + RequestId::Number(99), + )) + .await + .expect("send unsolicited server request"); + let Some(ClientJsonRpcMessage::Error(error)) = server.receive().await else { + panic!("expected unsolicited server request to be rejected"); + }; + assert_eq!(error.error.code, ErrorCode::INVALID_PARAMS); + rejection_observed_tx + .send(()) + .expect("signal observed rejection"); + + let ClientJsonRpcMessage::Request(request) = + server.receive().await.expect("expected normal request") + else { + panic!("expected normal request"); + }; + assert_eq!( + request.request.get_meta().protocol_version(), + Some(ProtocolVersion::V_2026_07_28) + ); + server + .send(ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(Default::default()), + request.id, + )) + .await + .expect("send list tools response"); + }); + + let client = DiscoverClient + .serve_with_lifecycle( + client_transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await + .expect("missing optional server info should not fail discovery"); + let peer_info = client.peer_info().expect("peer info should be retained"); + assert_eq!(peer_info.protocol_version, ProtocolVersion::V_2026_07_28); + assert!(peer_info.capabilities.tools.is_some()); + assert_eq!(peer_info.server_info, None); + assert_eq!( + peer_info.instructions.as_deref(), + Some("discovery instructions") + ); + assert_eq!( + peer_info + .meta + .as_ref() + .and_then(|meta| meta.0.get("example.test/key")), + Some(&serde_json::json!(7)) + ); + rejection_observed_rx + .await + .expect("server should observe association rejection"); + client.list_tools(None).await.expect("list tools"); + client.cancel().await.expect("cancel client"); + server_task.await.expect("server task"); +} + +#[tokio::test] +async fn high_level_server_accepts_discover_startup_without_initialize() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server_task = tokio::spawn(async move { + StatelessServer + .serve(server_transport) + .await + .expect("server should accept discover") + }); + + let client = DiscoverClient + .serve_with_lifecycle( + client_transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await + .expect("discover client should start"); + client.list_tools(None).await.expect("list tools"); + client.cancel().await.expect("cancel client"); + let server = server_task.await.expect("server task"); + server.cancel().await.expect("cancel server"); +} + +#[tokio::test] +async fn discover_startup_omits_initialize() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server = IntoTransport::::into_transport(server_transport); + let server_task = tokio::spawn(async move { + let ClientJsonRpcMessage::Request(request) = + server.receive().await.expect("expected discover request") + else { + panic!("expected request"); + }; + assert!(matches!(request.request, ClientRequest::DiscoverRequest(_))); + let meta = request.request.get_meta(); + assert_eq!(meta.protocol_version(), Some(ProtocolVersion::V_2026_07_28)); + assert!(meta.client_info().is_some()); + assert!(meta.client_capabilities().is_some()); + + server + .send(ServerJsonRpcMessage::response( + ServerResult::DiscoverResult( + DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + ServerCapabilities::default(), + ) + .with_server_info(Implementation::new("discover-server", "1.0.0")), + ), + request.id, + )) + .await + .expect("send discover response"); + + let ClientJsonRpcMessage::Request(request) = + server.receive().await.expect("expected normal request") + else { + panic!("expected request"); + }; + assert!(!matches!( + request.request, + ClientRequest::InitializeRequest(_) + )); + let meta = request.request.get_meta(); + assert_eq!(meta.protocol_version(), Some(ProtocolVersion::V_2025_11_25)); + assert!(meta.client_info().is_some()); + assert!(meta.client_capabilities().is_some()); + assert_eq!( + meta.get("example.test/extension"), + Some(&serde_json::json!(7)) + ); + server + .send(ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(Default::default()), + request.id, + )) + .await + .expect("send tools response"); + }); + + let client = DiscoverClient + .serve_with_lifecycle( + client_transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await + .expect("discover client should start"); + let mut caller_meta = rmcp::model::RequestMetaObject::new(); + caller_meta.insert("example.test/extension".into(), serde_json::json!(7)); + caller_meta.set_protocol_version(ProtocolVersion::V_2025_11_25); + client + .send_request_with_option( + ClientRequest::ListToolsRequest(rmcp::model::ListToolsRequest { + method: Default::default(), + params: None, + extensions: Default::default(), + }), + PeerRequestOptions::default().with_meta(caller_meta), + ) + .await + .expect("send list tools") + .await_response() + .await + .expect("list tools response"); + client.cancel().await.expect("cancel client"); + server_task.await.expect("server task"); +} + +#[tokio::test] +async fn auto_startup_falls_back_after_discover_method_not_found() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server = IntoTransport::::into_transport(server_transport); + let server_task = tokio::spawn(async move { + let ClientJsonRpcMessage::Request(discover) = + server.receive().await.expect("expected discover request") + else { + panic!("expected request"); + }; + assert!(matches!( + discover.request, + ClientRequest::DiscoverRequest(_) + )); + server + .send(ServerJsonRpcMessage::error( + ErrorData::new(ErrorCode::METHOD_NOT_FOUND, "Method not found", None), + Some(discover.id), + )) + .await + .expect("send method-not-found"); + + let ClientJsonRpcMessage::Request(initialize) = + server.receive().await.expect("expected initialize request") + else { + panic!("expected request"); + }; + assert!(matches!( + initialize.request, + ClientRequest::InitializeRequest(_) + )); + server + .send(ServerJsonRpcMessage::response( + ServerResult::InitializeResult( + InitializeResult::new(ServerCapabilities::default()), + ), + initialize.id, + )) + .await + .expect("send initialize response"); + assert!(matches!( + server.receive().await, + Some(ClientJsonRpcMessage::Notification(_)) + )); + }); + + let client = DiscoverClient + .serve_with_lifecycle( + client_transport, + ClientLifecycleMode::Auto { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + legacy_version: Some(ProtocolVersion::V_2025_11_25), + }, + ) + .await + .expect("auto client should fall back"); + client.cancel().await.expect("cancel client"); + server_task.await.expect("server task"); +} + +#[tokio::test] +async fn discover_startup_retries_a_mutually_supported_version() { + let unsupported: ProtocolVersion = + serde_json::from_value(serde_json::json!("2099-01-01")).unwrap(); + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server = IntoTransport::::into_transport(server_transport); + let server_task = tokio::spawn(async move { + let ClientJsonRpcMessage::Request(first) = + server.receive().await.expect("expected first discover") + else { + panic!("expected request"); + }; + assert_eq!( + first.request.get_meta().protocol_version(), + Some(unsupported.clone()) + ); + server + .send(ServerJsonRpcMessage::error( + ErrorData::unsupported_protocol_version( + unsupported, + &[ProtocolVersion::V_2026_07_28], + ), + Some(first.id), + )) + .await + .expect("send unsupported error"); + + let ClientJsonRpcMessage::Request(second) = + server.receive().await.expect("expected retry discover") + else { + panic!("expected request"); + }; + assert_eq!( + second.request.get_meta().protocol_version(), + Some(ProtocolVersion::V_2026_07_28) + ); + server + .send(ServerJsonRpcMessage::response( + ServerResult::DiscoverResult( + DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + ServerCapabilities::default(), + ) + .with_server_info(Implementation::new("discover-server", "1.0.0")), + ), + second.id, + )) + .await + .expect("send discover response"); + }); + + let client = DiscoverClient + .serve_with_lifecycle( + client_transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ + serde_json::from_value(serde_json::json!("2099-01-01")).unwrap(), + ProtocolVersion::V_2026_07_28, + ], + }, + ) + .await + .expect("discover client should retry"); + client.cancel().await.expect("cancel client"); + server_task.await.expect("server task"); +} + +#[tokio::test] +async fn discover_startup_retries_current_version_once_when_server_reports_it_supported() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server = IntoTransport::::into_transport(server_transport); + let server_task = tokio::spawn(async move { + let ClientJsonRpcMessage::Request(first) = + server.receive().await.expect("expected first discover") + else { + panic!("expected request"); + }; + server + .send(ServerJsonRpcMessage::error( + ErrorData::unsupported_protocol_version( + ProtocolVersion::V_2026_07_28, + &[ProtocolVersion::V_2026_07_28], + ), + Some(first.id), + )) + .await + .expect("send unsupported error"); + + let ClientJsonRpcMessage::Request(second) = + server.receive().await.expect("expected retry discover") + else { + panic!("expected request"); + }; + assert_eq!( + second.request.get_meta().protocol_version(), + Some(ProtocolVersion::V_2026_07_28) + ); + server + .send(ServerJsonRpcMessage::response( + ServerResult::DiscoverResult( + DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + ServerCapabilities::default(), + ) + .with_server_info(Implementation::new("discover-server", "1.0.0")), + ), + second.id, + )) + .await + .expect("send discover response"); + }); + + let client = DiscoverClient + .serve_with_lifecycle( + client_transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await + .expect("discover client should retry once"); + client.cancel().await.expect("cancel client"); + server_task.await.expect("server task"); +} diff --git a/vendor/rmcp/tests/test_close_connection.rs b/vendor/rmcp/tests/test_close_connection.rs new file mode 100644 index 000000000..50479a237 --- /dev/null +++ b/vendor/rmcp/tests/test_close_connection.rs @@ -0,0 +1,128 @@ +#![cfg(not(feature = "local"))] +//cargo test --test test_close_connection --features "client server" + +mod common; +use std::time::Duration; + +use common::handlers::{TestClientHandler, TestServer}; +use rmcp::{ServiceExt, service::QuitReason}; + +/// Test that close() properly shuts down the connection +#[tokio::test] +async fn test_close_method() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + // Start server + let server_handle = tokio::spawn(async move { + let server = TestServer::new().serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + + // Start client + let handler = TestClientHandler::new(true, true); + let mut client = handler.serve(client_transport).await?; + + // Verify client is not closed + assert!(!client.is_closed()); + + // Call close() and verify it returns + let result = client.close().await?; + assert!(matches!(result, QuitReason::Cancelled)); + + // Verify client is now closed + assert!(client.is_closed()); + + // Calling close() again should return Closed immediately + let result = client.close().await?; + assert!(matches!(result, QuitReason::Closed)); + + // Wait for server to finish + server_handle.await??; + Ok(()) +} + +/// Test that close_with_timeout() respects the timeout +#[tokio::test] +async fn test_close_with_timeout() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + // Start server + let server_handle = tokio::spawn(async move { + let server = TestServer::new().serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + + // Start client + let handler = TestClientHandler::new(true, true); + let mut client = handler.serve(client_transport).await?; + + // Close with a reasonable timeout + let result = client.close_with_timeout(Duration::from_secs(5)).await?; + assert!(result.is_some()); + assert!(matches!(result.unwrap(), QuitReason::Cancelled)); + + // Verify client is now closed + assert!(client.is_closed()); + + // Wait for server to finish + server_handle.await??; + Ok(()) +} + +/// Test that cancel() still works and consumes self +#[tokio::test] +async fn test_cancel_method() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + // Start server + let server_handle = tokio::spawn(async move { + let server = TestServer::new().serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + + // Start client + let handler = TestClientHandler::new(true, true); + let client = handler.serve(client_transport).await?; + + // Cancel should work as before + let result = client.cancel().await?; + assert!(matches!(result, QuitReason::Cancelled)); + + // Wait for server to finish + server_handle.await??; + Ok(()) +} + +/// Test that dropping without close() logs a debug message (we can't easily test +/// the log output, but we can verify the drop doesn't panic) +#[tokio::test] +async fn test_drop_without_close() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + // Start server that will handle the drop + let server_handle = tokio::spawn(async move { + let server = TestServer::new().serve(server_transport).await?; + // The server should close when the client drops + let result = server.waiting().await?; + // Server should detect closure + assert!(matches!(result, QuitReason::Closed | QuitReason::Cancelled)); + anyhow::Ok(()) + }); + + // Create and immediately drop the client + { + let handler = TestClientHandler::new(true, true); + let _client = handler.serve(client_transport).await?; + // Client dropped here without calling close() + } + + // Give the async cleanup a moment to run + tokio::time::sleep(Duration::from_millis(100)).await; + + // Wait for server to finish (it should detect the closure) + server_handle.await??; + Ok(()) +} diff --git a/vendor/rmcp/tests/test_completion.rs b/vendor/rmcp/tests/test_completion.rs new file mode 100644 index 000000000..b155bd91b --- /dev/null +++ b/vendor/rmcp/tests/test_completion.rs @@ -0,0 +1,208 @@ +use std::collections::HashMap; + +use rmcp::model::*; +use serde_json::json; + +#[test] +fn test_completion_context_serialization() { + let mut args = HashMap::new(); + args.insert("key1".to_string(), "value1".to_string()); + args.insert("key2".to_string(), "value2".to_string()); + + let context = CompletionContext::with_arguments(args); + + // Test serialization + let json = serde_json::to_value(&context).unwrap(); + let expected = json!({ + "arguments": { + "key1": "value1", + "key2": "value2" + } + }); + assert_eq!(json, expected); + + // Test deserialization + let deserialized: CompletionContext = serde_json::from_value(expected).unwrap(); + assert_eq!(deserialized, context); +} + +#[test] +fn test_completion_context_methods() { + let mut args = HashMap::new(); + args.insert("city".to_string(), "San Francisco".to_string()); + args.insert("country".to_string(), "USA".to_string()); + + let context = CompletionContext::with_arguments(args); + + assert!(context.has_arguments()); + assert_eq!( + context.get_argument("city"), + Some(&"San Francisco".to_string()) + ); + assert_eq!(context.get_argument("missing"), None); + + let names: Vec<&str> = context.argument_names().collect(); + assert!(names.contains(&"city")); + assert!(names.contains(&"country")); + assert_eq!(names.len(), 2); +} + +#[test] +fn test_complete_request_param_serialization() { + let mut args = HashMap::new(); + args.insert("previous_input".to_string(), "test".to_string()); + + let request = CompleteRequestParams::new( + Reference::for_prompt("weather_prompt"), + ArgumentInfo::new("location", "San"), + ) + .with_context(CompletionContext::with_arguments(args)); + + let json = serde_json::to_value(&request).unwrap(); + assert!(json["ref"]["name"].as_str().unwrap() == "weather_prompt"); + assert!(json["argument"]["name"].as_str().unwrap() == "location"); + assert!(json["argument"]["value"].as_str().unwrap() == "San"); + assert!( + json["context"]["arguments"]["previous_input"] + .as_str() + .unwrap() + == "test" + ); +} + +#[test] +fn test_completion_info_validation() { + // Valid completion with less than max values + let values = vec!["option1".to_string(), "option2".to_string()]; + let completion = CompletionInfo::new(values.clone()).unwrap(); + assert_eq!(completion.values, values); + assert!(completion.validate().is_ok()); + + // Test max values limit + let many_values: Vec = (0..=CompletionInfo::MAX_VALUES) + .map(|i| format!("option_{}", i)) + .collect(); + let result = CompletionInfo::new(many_values); + assert!(result.is_err()); +} + +#[test] +fn test_completion_info_helper_methods() { + let values = vec!["test1".to_string(), "test2".to_string()]; + + // Test with_all_values + let completion = CompletionInfo::with_all_values(values.clone()).unwrap(); + assert_eq!(completion.values, values); + assert_eq!(completion.total, Some(2)); + assert_eq!(completion.has_more, Some(false)); + assert!(!completion.has_more_results()); + assert_eq!(completion.total_available(), Some(2)); + + // Test with_pagination + let paginated = CompletionInfo::with_pagination(values.clone(), Some(10), true).unwrap(); + assert_eq!(paginated.values, values); + assert_eq!(paginated.total, Some(10)); + assert_eq!(paginated.has_more, Some(true)); + assert!(paginated.has_more_results()); + assert_eq!(paginated.total_available(), Some(10)); +} + +#[test] +fn test_completion_info_bounds() { + // Test exactly at the limit + let max_values: Vec = (0..CompletionInfo::MAX_VALUES) + .map(|i| format!("value_{}", i)) + .collect(); + assert!(CompletionInfo::new(max_values).is_ok()); + + // Test over the limit + let over_limit: Vec = (0..=CompletionInfo::MAX_VALUES) + .map(|i| format!("value_{}", i)) + .collect(); + assert!(CompletionInfo::new(over_limit).is_err()); +} + +#[test] +fn test_reference_convenience_methods() { + let prompt_ref = Reference::for_prompt("test_prompt"); + assert_eq!(prompt_ref.reference_type(), "ref/prompt"); + assert_eq!(prompt_ref.as_prompt_name(), Some("test_prompt")); + assert_eq!(prompt_ref.as_resource_uri(), None); + + let resource_ref = Reference::for_resource("file://path/to/resource"); + assert_eq!(resource_ref.reference_type(), "ref/resource"); + assert_eq!( + resource_ref.as_resource_uri(), + Some("file://path/to/resource") + ); + assert_eq!(resource_ref.as_prompt_name(), None); +} + +#[test] +fn test_completion_serialization_format() { + // Test that completion follows MCP 2025-06-18 specification format + let completion = + CompletionInfo::with_all_values(vec!["value1".to_string(), "value2".to_string()]).unwrap(); + + let json = serde_json::to_value(&completion).unwrap(); + + // Verify JSON structure matches specification + assert!(json.is_object()); + assert!(json["values"].is_array()); + assert_eq!(json["values"].as_array().unwrap().len(), 2); + assert_eq!(json["total"].as_u64().unwrap(), 2); + assert!(!json["hasMore"].as_bool().unwrap()); +} + +#[test] +fn test_resource_reference() { + // ResourceTemplateReference가 `ref/resource` 와이어 태그로 직렬화/역직렬화되는지 확인 + let reference = Reference::for_resource("test://uri"); + + let json = serde_json::to_value(&reference).unwrap(); + assert_eq!(json["type"], "ref/resource"); + assert_eq!(json["uri"], "test://uri"); + + let back: Reference = serde_json::from_value(json).unwrap(); + match back { + Reference::Resource(r) => assert_eq!(r.uri, "test://uri"), + other => panic!("expected Reference::Resource, got {other:?}"), + } +} + +#[test] +fn test_complete_result_default() { + let result = CompleteResult::default(); + assert!(result.completion.values.is_empty()); + assert_eq!(result.completion.total, None); + assert_eq!(result.completion.has_more, None); +} + +#[test] +fn test_completion_context_empty() { + let context = CompletionContext::new(); + assert!(!context.has_arguments()); + assert_eq!(context.get_argument("any"), None); + assert!(context.argument_names().count() == 0); +} + +#[test] +fn test_mcp_schema_compliance() { + // Test that our types serialize correctly according to MCP specification + let request = CompleteRequestParams::new( + Reference::for_resource("file://{path}"), + ArgumentInfo::new("path", "src/"), + ); + + let json_str = serde_json::to_string(&request).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + + // Verify key structure matches MCP spec + assert!(parsed["ref"].is_object()); + assert!(parsed["argument"].is_object()); + assert!(parsed["argument"]["name"].is_string()); + assert!(parsed["argument"]["value"].is_string()); + + // Verify type tag is correct + assert_eq!(parsed["ref"]["type"].as_str().unwrap(), "ref/resource"); +} diff --git a/vendor/rmcp/tests/test_complex_schema.rs b/vendor/rmcp/tests/test_complex_schema.rs new file mode 100644 index 000000000..9372b0152 --- /dev/null +++ b/vendor/rmcp/tests/test_complex_schema.rs @@ -0,0 +1,105 @@ +#![allow(clippy::exhaustive_structs, clippy::exhaustive_enums)] + +use rmcp::{ + ErrorData as McpError, handler::server::wrapper::Parameters, model::*, schemars, tool, + tool_router, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] +pub enum ChatRole { + System, + User, + Assistant, + Tool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ChatMessage { + pub role: ChatRole, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ChatRequest { + pub system: Option, + pub messages: Vec, +} + +#[derive(Clone, Default)] +pub struct Demo; + +#[tool_router] +impl Demo { + pub fn new() -> Self { + Self + } + + #[tool(description = "LLM")] + async fn chat( + &self, + chat_request: Parameters, + ) -> Result { + let content = ContentBlock::json(chat_request.0)?; + Ok(CallToolResult::success(vec![content])) + } +} + +fn expected_schema() -> serde_json::Value { + serde_json::json!({ + "$defs": { + "ChatMessage": { + "properties": { + "content": { + "type": "string" + }, + "role": { + "$ref": "#/$defs/ChatRole" + } + }, + "required": [ + "role", + "content" + ], + "type": "object" + }, + "ChatRole": { + "enum": [ + "System", + "User", + "Assistant", + "Tool" + ], + "type": "string" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "messages": { + "items": { + "$ref": "#/$defs/ChatMessage" + }, + "type": "array" + }, + "system": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "messages" + ], + "type": "object" + }) +} + +#[test] +fn test_complex_schema() { + let attr = Demo::chat_tool_attr(); + let input_schema = attr.input_schema; + let expected = expected_schema(); + let produced = serde_json::Value::Object(input_schema.as_ref().clone()); + assert_eq!(produced, expected, "schema mismatch"); +} diff --git a/vendor/rmcp/tests/test_custom_headers.rs b/vendor/rmcp/tests/test_custom_headers.rs new file mode 100644 index 000000000..736dce18e --- /dev/null +++ b/vendor/rmcp/tests/test_custom_headers.rs @@ -0,0 +1,1231 @@ +#![cfg(not(feature = "local"))] +use std::collections::HashMap; + +use http::{HeaderName, HeaderValue}; + +#[test] +fn test_config_custom_headers_default_empty() { + use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; + + let config = StreamableHttpClientTransportConfig::with_uri("http://localhost:8080"); + assert!( + config.custom_headers.is_empty(), + "Default custom_headers should be empty" + ); +} + +#[test] +fn test_config_custom_headers_builder() { + use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; + + let mut headers = HashMap::new(); + headers.insert( + HeaderName::from_static("x-test-header"), + HeaderValue::from_static("test-value"), + ); + + let config = StreamableHttpClientTransportConfig::with_uri("http://localhost:8080") + .custom_headers(headers); + + assert_eq!(config.custom_headers.len(), 1); + assert_eq!( + config + .custom_headers + .get(&HeaderName::from_static("x-test-header")), + Some(&HeaderValue::from_static("test-value")) + ); +} + +#[test] +fn test_config_custom_headers_multiple_values() { + use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; + + let mut headers = HashMap::new(); + headers.insert( + HeaderName::from_static("x-header-1"), + HeaderValue::from_static("value-1"), + ); + headers.insert( + HeaderName::from_static("x-header-2"), + HeaderValue::from_static("value-2"), + ); + headers.insert( + HeaderName::from_static("authorization"), + HeaderValue::from_static("Bearer token123"), + ); + + let config = StreamableHttpClientTransportConfig::with_uri("http://localhost:8080") + .custom_headers(headers); + + assert_eq!(config.custom_headers.len(), 3); + assert_eq!( + config + .custom_headers + .get(&HeaderName::from_static("x-header-1")), + Some(&HeaderValue::from_static("value-1")) + ); + assert_eq!( + config + .custom_headers + .get(&HeaderName::from_static("x-header-2")), + Some(&HeaderValue::from_static("value-2")) + ); + assert_eq!( + config + .custom_headers + .get(&HeaderName::from_static("authorization")), + Some(&HeaderValue::from_static("Bearer token123")) + ); +} + +#[test] +fn test_config_auth_header_and_custom_headers_together() { + use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; + + let mut headers = HashMap::new(); + headers.insert( + HeaderName::from_static("x-custom-header"), + HeaderValue::from_static("custom-value"), + ); + + let config = StreamableHttpClientTransportConfig::with_uri("http://localhost:8080") + .auth_header("my-bearer-token") + .custom_headers(headers); + + assert_eq!(config.auth_header, Some("my-bearer-token".to_string())); + assert_eq!( + config + .custom_headers + .get(&HeaderName::from_static("x-custom-header")), + Some(&HeaderValue::from_static("custom-value")) + ); +} + +/// Unit test: post_message should reject reserved header "accept" +#[tokio::test] +#[cfg(feature = "transport-streamable-http-client-reqwest")] +async fn test_post_message_rejects_accept_header() { + use std::sync::Arc; + + use rmcp::{ + model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, + transport::streamable_http_client::{StreamableHttpClient, StreamableHttpError}, + }; + + let client = reqwest::Client::new(); + let mut custom_headers = HashMap::new(); + custom_headers.insert( + HeaderName::from_static("accept"), + HeaderValue::from_static("text/html"), + ); + + let message = ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ); + + let result = client + .post_message( + Arc::from("http://localhost:9999/mcp"), + message, + None, + None, + custom_headers, + ) + .await; + + assert!(result.is_err(), "Should reject 'accept' header"); + match result { + Err(StreamableHttpError::ReservedHeaderConflict(header_name)) => { + assert_eq!( + header_name, "accept", + "Error should indicate 'accept' header" + ); + } + other => panic!("Expected ReservedHeaderConflict error, got: {:?}", other), + } +} + +/// Unit test: post_message should reject reserved header "mcp-session-id" +#[tokio::test] +#[cfg(feature = "transport-streamable-http-client-reqwest")] +async fn test_post_message_rejects_mcp_session_id() { + use std::sync::Arc; + + use rmcp::{ + model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, + transport::streamable_http_client::{StreamableHttpClient, StreamableHttpError}, + }; + + let client = reqwest::Client::new(); + let mut custom_headers = HashMap::new(); + custom_headers.insert( + HeaderName::from_static("mcp-session-id"), + HeaderValue::from_static("my-session"), + ); + + let message = ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ); + + let result = client + .post_message( + Arc::from("http://localhost:9999/mcp"), + message, + None, + None, + custom_headers, + ) + .await; + + assert!(result.is_err(), "Should reject 'mcp-session-id' header"); + match result { + Err(StreamableHttpError::ReservedHeaderConflict(header_name)) => { + assert_eq!( + header_name, "mcp-session-id", + "Error should indicate 'mcp-session-id' header" + ); + } + other => panic!("Expected ReservedHeaderConflict error, got: {:?}", other), + } +} + +/// Unit test: post_message should allow the mcp-protocol-version header through +/// (it is injected by the worker after initialization, not a user-settable custom header) +#[tokio::test] +#[cfg(feature = "transport-streamable-http-client-reqwest")] +async fn test_post_message_allows_mcp_protocol_version() { + use std::sync::Arc; + + use rmcp::{ + model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, + transport::streamable_http_client::StreamableHttpClient, + }; + + let client = reqwest::Client::new(); + let mut custom_headers = HashMap::new(); + custom_headers.insert( + HeaderName::from_static("mcp-protocol-version"), + HeaderValue::from_static("2025-03-26"), + ); + + let message = ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ); + + let result = client + .post_message( + Arc::from("http://localhost:9999/mcp"), + message, + None, + None, + custom_headers, + ) + .await; + + // The header should be allowed through (not rejected as reserved). + // The error should be a connection error (no server at localhost:9999), + // not a ReservedHeaderConflict. + assert!(result.is_err(), "Should fail due to connection error"); + assert!( + !matches!( + &result, + Err(rmcp::transport::streamable_http_client::StreamableHttpError::ReservedHeaderConflict( + _ + )) + ), + "MCP-Protocol-Version should not be rejected as reserved, got: {:?}", + result + ); +} + +/// Unit test: post_message should reject reserved header "last-event-id" +#[tokio::test] +#[cfg(feature = "transport-streamable-http-client-reqwest")] +async fn test_post_message_rejects_last_event_id() { + use std::sync::Arc; + + use rmcp::{ + model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, + transport::streamable_http_client::{StreamableHttpClient, StreamableHttpError}, + }; + + let client = reqwest::Client::new(); + let mut custom_headers = HashMap::new(); + custom_headers.insert( + HeaderName::from_static("last-event-id"), + HeaderValue::from_static("event-123"), + ); + + let message = ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ); + + let result = client + .post_message( + Arc::from("http://localhost:9999/mcp"), + message, + None, + None, + custom_headers, + ) + .await; + + assert!(result.is_err(), "Should reject 'last-event-id' header"); + match result { + Err(StreamableHttpError::ReservedHeaderConflict(header_name)) => { + assert_eq!( + header_name, "last-event-id", + "Error should indicate 'last-event-id' header" + ); + } + other => panic!("Expected ReservedHeaderConflict error, got: {:?}", other), + } +} + +/// Unit test: post_message should do case-insensitive matching for reserved headers +#[tokio::test] +#[cfg(feature = "transport-streamable-http-client-reqwest")] +async fn test_post_message_case_insensitive_matching() { + use std::sync::Arc; + + use rmcp::{ + model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, + transport::streamable_http_client::{StreamableHttpClient, StreamableHttpError}, + }; + + let client = reqwest::Client::new(); + let message = ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ); + + // Test different casings + let test_cases = vec![ + ("Accept", "Should reject 'Accept' (capitalized)"), + ("ACCEPT", "Should reject 'ACCEPT' (uppercase)"), + ("Mcp-Session-Id", "Should reject 'Mcp-Session-Id'"), + ("MCP-SESSION-ID", "Should reject 'MCP-SESSION-ID'"), + ]; + + for (header_name, error_msg) in test_cases { + let mut custom_headers = HashMap::new(); + custom_headers.insert( + HeaderName::from_bytes(header_name.as_bytes()).unwrap(), + HeaderValue::from_static("value"), + ); + + let result = client + .post_message( + Arc::from("http://localhost:9999/mcp"), + message.clone(), + None, + None, + custom_headers, + ) + .await; + + assert!(result.is_err(), "{}", error_msg); + if let Err(StreamableHttpError::ReservedHeaderConflict(_)) = result { + // Success + } else { + panic!( + "{}: Expected ReservedHeaderConflict, got: {:?}", + error_msg, result + ); + } + } +} + +/// Integration test: Verify that custom headers are actually sent in MCP HTTP requests +#[tokio::test] +#[cfg(all( + feature = "transport-streamable-http-client", + feature = "transport-streamable-http-client-reqwest" +))] +async fn test_mcp_custom_headers_sent_to_server() -> anyhow::Result<()> { + use std::{net::SocketAddr, sync::Arc}; + + use axum::{ + Router, body::Bytes, extract::State, http::StatusCode, response::IntoResponse, + routing::post, + }; + use rmcp::{ + ServiceExt, + transport::{ + StreamableHttpClientTransport, + streamable_http_client::StreamableHttpClientTransportConfig, + }, + }; + use serde_json::json; + use tokio::sync::Mutex; + + // State to capture received headers + #[derive(Clone)] + struct ServerState { + received_headers: Arc>>, + initialize_called: Arc, + } + + // Handler that captures headers from MCP requests + async fn mcp_handler( + State(state): State, + headers: http::HeaderMap, + body: Bytes, + ) -> impl IntoResponse { + // Capture all custom headers (starting with x-) + let mut headers_map = HashMap::new(); + for (name, value) in headers.iter() { + let name_str = name.as_str(); + if name_str.starts_with("x-") { + if let Ok(v) = value.to_str() { + headers_map.insert(name_str.to_string(), v.to_string()); + } + } + } + + // Store captured headers + let mut stored = state.received_headers.lock().await; + stored.extend(headers_map); + + // Parse the MCP request + if let Ok(json_body) = serde_json::from_slice::(&body) { + if let Some(method) = json_body.get("method").and_then(|m| m.as_str()) { + if method == "initialize" { + state.initialize_called.notify_one(); + // Return a valid MCP initialize response with session header + let response = json!({ + "jsonrpc": "2.0", + "id": json_body.get("id"), + "result": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "serverInfo": { + "name": "test-server", + "version": "1.0.0" + } + } + }); + return ( + StatusCode::OK, + [ + (http::header::CONTENT_TYPE, "application/json"), + ( + http::HeaderName::from_static("mcp-session-id"), + "test-session-123", + ), + ], + response.to_string(), + ); + } else if method == "notifications/initialized" { + // For initialized notification, return 202 Accepted + return ( + StatusCode::ACCEPTED, + [ + (http::header::CONTENT_TYPE, "application/json"), + ( + http::HeaderName::from_static("mcp-session-id"), + "test-session-123", + ), + ], + String::new(), + ); + } + } + } + + // Default response for other requests + let response = json!({ + "jsonrpc": "2.0", + "id": 1, + "result": {} + }); + ( + StatusCode::OK, + [ + (http::header::CONTENT_TYPE, "application/json"), + ( + http::HeaderName::from_static("mcp-session-id"), + "test-session-123", + ), + ], + response.to_string(), + ) + } + + // Setup test server + let state = ServerState { + received_headers: Arc::new(Mutex::new(HashMap::new())), + initialize_called: Arc::new(tokio::sync::Notify::new()), + }; + + let app = Router::new() + .route("/mcp", post(mcp_handler)) + .with_state(state.clone()); + + let addr = SocketAddr::from(([127, 0, 0, 1], 0)); + let listener = tokio::net::TcpListener::bind(addr).await?; + let port = listener.local_addr()?.port(); + + let server_handle = tokio::spawn(async move { axum::serve(listener, app).await }); + + // Wait for server to be ready + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // Create MCP client with custom headers + let mut custom_headers = HashMap::new(); + custom_headers.insert( + HeaderName::from_static("x-test-header"), + HeaderValue::from_static("test-value-123"), + ); + custom_headers.insert( + HeaderName::from_static("x-another-header"), + HeaderValue::from_static("another-value-456"), + ); + custom_headers.insert( + HeaderName::from_static("x-client-id"), + HeaderValue::from_static("test-client"), + ); + + let config = + StreamableHttpClientTransportConfig::with_uri(format!("http://127.0.0.1:{}/mcp", port)) + .custom_headers(custom_headers); + + let transport = StreamableHttpClientTransport::from_config(config); + + // Start MCP client with empty handler (this will trigger initialize request) + let client = ().serve(transport).await.expect("Failed to start client"); + + // Wait for initialize to be called + tokio::time::timeout( + std::time::Duration::from_secs(5), + state.initialize_called.notified(), + ) + .await + .expect("Initialize request should be received"); + + // Verify that custom headers were received + let headers = state.received_headers.lock().await; + + assert_eq!( + headers.get("x-test-header"), + Some(&"test-value-123".to_string()), + "Custom header x-test-header should be sent to MCP server" + ); + assert_eq!( + headers.get("x-another-header"), + Some(&"another-value-456".to_string()), + "Custom header x-another-header should be sent to MCP server" + ); + assert_eq!( + headers.get("x-client-id"), + Some(&"test-client".to_string()), + "Custom header x-client-id should be sent to MCP server" + ); + + // Cleanup + drop(client); + server_handle.abort(); + + Ok(()) +} + +/// Integration test: Verify that MCP-Protocol-Version header is sent on post-init requests +#[tokio::test] +#[cfg(all( + feature = "transport-streamable-http-client", + feature = "transport-streamable-http-client-reqwest" +))] +async fn test_mcp_protocol_version_header_sent_after_init() -> anyhow::Result<()> { + use std::{net::SocketAddr, sync::Arc}; + + use axum::{ + Router, body::Bytes, extract::State, http::StatusCode, response::IntoResponse, + routing::post, + }; + use rmcp::{ + ServiceExt, + transport::{ + StreamableHttpClientTransport, + streamable_http_client::StreamableHttpClientTransportConfig, + }, + }; + use serde_json::json; + use tokio::sync::Mutex; + + type CapturedRequests = Vec<(String, Option)>; + + #[derive(Clone)] + struct ServerState { + /// Captures the MCP-Protocol-Version header value for each request method + protocol_version_by_method: Arc>, + initialized_called: Arc, + } + + async fn mcp_handler( + State(state): State, + headers: http::HeaderMap, + body: Bytes, + ) -> impl IntoResponse { + let protocol_version = headers + .get("mcp-protocol-version") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + if let Ok(json_body) = serde_json::from_slice::(&body) { + let method = json_body + .get("method") + .and_then(|m| m.as_str()) + .unwrap_or("unknown") + .to_string(); + + state + .protocol_version_by_method + .lock() + .await + .push((method.clone(), protocol_version)); + + if method == "initialize" { + let response = json!({ + "jsonrpc": "2.0", + "id": json_body.get("id"), + "result": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "serverInfo": { + "name": "test-server", + "version": "1.0.0" + } + } + }); + return ( + StatusCode::OK, + [ + (http::header::CONTENT_TYPE, "application/json"), + ( + http::HeaderName::from_static("mcp-session-id"), + "test-session-456", + ), + ], + response.to_string(), + ); + } else if method == "notifications/initialized" { + state.initialized_called.notify_one(); + return ( + StatusCode::ACCEPTED, + [ + (http::header::CONTENT_TYPE, "application/json"), + ( + http::HeaderName::from_static("mcp-session-id"), + "test-session-456", + ), + ], + String::new(), + ); + } + } + + let response = json!({ + "jsonrpc": "2.0", + "id": 1, + "result": {} + }); + ( + StatusCode::OK, + [ + (http::header::CONTENT_TYPE, "application/json"), + ( + http::HeaderName::from_static("mcp-session-id"), + "test-session-456", + ), + ], + response.to_string(), + ) + } + + let state = ServerState { + protocol_version_by_method: Arc::new(Mutex::new(Vec::new())), + initialized_called: Arc::new(tokio::sync::Notify::new()), + }; + + let app = Router::new() + .route("/mcp", post(mcp_handler)) + .with_state(state.clone()); + + let addr = SocketAddr::from(([127, 0, 0, 1], 0)); + let listener = tokio::net::TcpListener::bind(addr).await?; + let port = listener.local_addr()?.port(); + + let server_handle = tokio::spawn(async move { axum::serve(listener, app).await }); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let config = + StreamableHttpClientTransportConfig::with_uri(format!("http://127.0.0.1:{}/mcp", port)); + + let transport = StreamableHttpClientTransport::from_config(config); + let client = ().serve(transport).await.expect("Failed to start client"); + + tokio::time::timeout( + std::time::Duration::from_secs(5), + state.initialized_called.notified(), + ) + .await + .expect("Initialized notification should be received"); + + // Give time for the initialized notification to be fully processed + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let captured = state.protocol_version_by_method.lock().await; + + // The initialize request should NOT have MCP-Protocol-Version + // (the version isn't known yet) + let init_entry = captured + .iter() + .find(|(m, _)| m == "initialize") + .expect("Should have captured initialize request"); + assert_eq!( + init_entry.1, None, + "Initialize request should not have MCP-Protocol-Version header" + ); + + // The initialized notification should HAVE MCP-Protocol-Version + let initialized_entry = captured + .iter() + .find(|(m, _)| m == "notifications/initialized") + .expect("Should have captured initialized notification"); + assert_eq!( + initialized_entry.1, + Some("2025-03-26".to_string()), + "Initialized notification should include MCP-Protocol-Version: 2025-03-26" + ); + + drop(client); + server_handle.abort(); + + Ok(()) +} + +/// Integration test: Verify server rejects unsupported MCP-Protocol-Version with 400 +#[tokio::test] +#[cfg(all(feature = "transport-streamable-http-server", feature = "server",))] +async fn test_server_rejects_unsupported_protocol_version() { + use std::sync::Arc; + + use bytes::Bytes; + use http::{Method, Request, header::CONTENT_TYPE}; + use http_body_util::Full; + use rmcp::{ + handler::server::ServerHandler, + model::{ServerCapabilities, ServerInfo}, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, + }; + use serde_json::json; + + #[derive(Clone)] + struct TestHandler; + + impl ServerHandler for TestHandler { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().build()) + } + } + + let session_manager = Arc::new(LocalSessionManager::default()); + let service = StreamableHttpService::new( + || Ok(TestHandler), + session_manager, + StreamableHttpServerConfig::default(), + ); + + // First, send an initialize request to create a session + let init_body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { + "name": "test-client", + "version": "1.0.0" + } + } + }); + + let init_request = Request::builder() + .method(Method::POST) + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .header("Host", "localhost:8080") + .body(Full::new(Bytes::from(init_body.to_string()))) + .unwrap(); + + let response = service.handle(init_request).await; + assert_eq!(response.status(), http::StatusCode::OK); + + // Extract session id from response + let session_id = response + .headers() + .get("mcp-session-id") + .expect("Should have session id") + .to_str() + .unwrap() + .to_string(); + + // Send initialized notification to complete handshake + let initialized_body = json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized" + }); + let initialized_request = Request::builder() + .method(Method::POST) + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .header("Host", "localhost:8080") + .header("mcp-session-id", &session_id) + .header("mcp-protocol-version", "2025-03-26") + .body(Full::new(Bytes::from(initialized_body.to_string()))) + .unwrap(); + + let response = service.handle(initialized_request).await; + assert_eq!(response.status(), http::StatusCode::ACCEPTED); + + // Test 1: Valid protocol version should succeed + let valid_body = json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized" + }); + let valid_request = Request::builder() + .method(Method::POST) + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .header("Host", "localhost:8080") + .header("mcp-session-id", &session_id) + .header("mcp-protocol-version", "2025-03-26") + .body(Full::new(Bytes::from(valid_body.to_string()))) + .unwrap(); + + let response = service.handle(valid_request).await; + assert_eq!( + response.status(), + http::StatusCode::ACCEPTED, + "Valid MCP-Protocol-Version should be accepted" + ); + + // Test 2: Unsupported protocol version should return 400 + let invalid_body = json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized" + }); + let invalid_request = Request::builder() + .method(Method::POST) + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .header("Host", "localhost:8080") + .header("mcp-session-id", &session_id) + .header("mcp-protocol-version", "9999-01-01") + .body(Full::new(Bytes::from(invalid_body.to_string()))) + .unwrap(); + + let response = service.handle(invalid_request).await; + assert_eq!( + response.status(), + http::StatusCode::BAD_REQUEST, + "Unsupported MCP-Protocol-Version should return 400" + ); + + // Test 3: Missing protocol version should succeed (backwards compat) + let no_version_body = json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized" + }); + let no_version_request = Request::builder() + .method(Method::POST) + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .header("Host", "localhost:8080") + .header("mcp-session-id", &session_id) + .body(Full::new(Bytes::from(no_version_body.to_string()))) + .unwrap(); + + let response = service.handle(no_version_request).await; + assert_eq!( + response.status(), + http::StatusCode::ACCEPTED, + "Missing MCP-Protocol-Version should be accepted (backwards compat)" + ); +} + +/// Unit test: ProtocolVersion::as_str and KNOWN_VERSIONS +#[test] +fn test_protocol_version_utilities() { + use rmcp::model::ProtocolVersion; + + assert_eq!(ProtocolVersion::V_2026_07_28.as_str(), "2026-07-28"); + assert_eq!(ProtocolVersion::V_2025_11_25.as_str(), "2025-11-25"); + assert_eq!(ProtocolVersion::V_2025_06_18.as_str(), "2025-06-18"); + assert_eq!(ProtocolVersion::V_2025_03_26.as_str(), "2025-03-26"); + assert_eq!(ProtocolVersion::V_2024_11_05.as_str(), "2024-11-05"); + + assert_eq!(ProtocolVersion::KNOWN_VERSIONS.len(), 5); + assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2024_11_05)); + assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_03_26)); + assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_06_18)); + assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_11_25)); + assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2026_07_28)); +} + +/// Integration test: Verify server validates only the Host header for DNS rebinding protection +#[tokio::test] +#[cfg(all(feature = "transport-streamable-http-server", feature = "server",))] +async fn test_server_validates_host_header_for_dns_rebinding_protection() { + use std::sync::Arc; + + use bytes::Bytes; + use http::{Method, Request, header::CONTENT_TYPE}; + use http_body_util::Full; + use rmcp::{ + handler::server::ServerHandler, + model::{ServerCapabilities, ServerInfo}, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, + }; + use serde_json::json; + + #[derive(Clone)] + struct TestHandler; + + impl ServerHandler for TestHandler { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().build()) + } + } + + let service = StreamableHttpService::new( + || Ok(TestHandler), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + + let init_body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { + "name": "test-client", + "version": "1.0.0" + } + } + }); + + let allowed_request = Request::builder() + .method(Method::POST) + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .header("Host", "localhost:8080") + .header("Origin", "http://localhost:8080") + .body(Full::new(Bytes::from(init_body.to_string()))) + .unwrap(); + + let response = service.handle(allowed_request).await; + assert_eq!(response.status(), http::StatusCode::OK); + + let bad_host_request = Request::builder() + .method(Method::POST) + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .header("Host", "attacker.example") + .body(Full::new(Bytes::from(init_body.to_string()))) + .unwrap(); + + let response = service.handle(bad_host_request).await; + assert_eq!(response.status(), http::StatusCode::FORBIDDEN); + + let ignored_origin_request = Request::builder() + .method(Method::POST) + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .header("Host", "localhost:8080") + .header("Origin", "http://attacker.example") + .body(Full::new(Bytes::from(init_body.to_string()))) + .unwrap(); + + let response = service.handle(ignored_origin_request).await; + assert_eq!(response.status(), http::StatusCode::OK); +} + +/// Integration test: Verify server can enforce an allowed Host port when configured +#[tokio::test] +#[cfg(all(feature = "transport-streamable-http-server", feature = "server",))] +async fn test_server_validates_host_header_port_for_dns_rebinding_protection() { + use std::sync::Arc; + + use bytes::Bytes; + use http::{Method, Request, header::CONTENT_TYPE}; + use http_body_util::Full; + use rmcp::{ + handler::server::ServerHandler, + model::{ServerCapabilities, ServerInfo}, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, + }; + use serde_json::json; + + #[derive(Clone)] + struct TestHandler; + + impl ServerHandler for TestHandler { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().build()) + } + } + + let service = StreamableHttpService::new( + || Ok(TestHandler), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default().with_allowed_hosts(["localhost:8080"]), + ); + + let init_body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { + "name": "test-client", + "version": "1.0.0" + } + } + }); + + let allowed_request = Request::builder() + .method(Method::POST) + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .header("Host", "localhost:8080") + .body(Full::new(Bytes::from(init_body.to_string()))) + .unwrap(); + + let response = service.handle(allowed_request).await; + assert_eq!(response.status(), http::StatusCode::OK); + + let wrong_port_request = Request::builder() + .method(Method::POST) + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .header("Host", "localhost:3000") + .body(Full::new(Bytes::from(init_body.to_string()))) + .unwrap(); + + let response = service.handle(wrong_port_request).await; + assert_eq!(response.status(), http::StatusCode::FORBIDDEN); +} + +/// Integration test: Verify the validator falls back to the URI authority when +/// the Host header is absent (HTTP/2 :authority pseudo-header scenario). +#[tokio::test] +#[cfg(all(feature = "transport-streamable-http-server", feature = "server",))] +async fn test_server_falls_back_to_uri_authority_when_host_header_missing() { + use std::sync::Arc; + + use bytes::Bytes; + use http::{Method, Request, header::CONTENT_TYPE}; + use http_body_util::Full; + use rmcp::{ + handler::server::ServerHandler, + model::{ServerCapabilities, ServerInfo}, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, + }; + use serde_json::json; + + #[derive(Clone)] + struct TestHandler; + + impl ServerHandler for TestHandler { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().build()) + } + } + + let service = StreamableHttpService::new( + || Ok(TestHandler), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + + let init_body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { + "name": "test-client", + "version": "1.0.0" + } + } + }); + + // Allowed authority via URI only — no Host header. + let allowed_request = Request::builder() + .method(Method::POST) + .uri("http://localhost:8080/") + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(init_body.to_string()))) + .unwrap(); + assert!(allowed_request.headers().get("Host").is_none()); + + let response = service.handle(allowed_request).await; + assert_eq!(response.status(), http::StatusCode::OK); + + // Disallowed authority via URI only — no Host header. + let bad_request = Request::builder() + .method(Method::POST) + .uri("http://attacker.example/") + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(init_body.to_string()))) + .unwrap(); + assert!(bad_request.headers().get("Host").is_none()); + + let response = service.handle(bad_request).await; + assert_eq!(response.status(), http::StatusCode::FORBIDDEN); + + // Neither Host header nor URI authority — still a 400. + let missing_request = Request::builder() + .method(Method::POST) + .uri("/") + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(init_body.to_string()))) + .unwrap(); + assert!(missing_request.headers().get("Host").is_none()); + assert!(missing_request.uri().authority().is_none()); + + let response = service.handle(missing_request).await; + assert_eq!(response.status(), http::StatusCode::BAD_REQUEST); +} + +#[cfg(all(feature = "transport-streamable-http-server", feature = "server"))] +mod origin_validation { + use std::sync::Arc; + + use bytes::Bytes; + use http::{Method, Request, header::CONTENT_TYPE}; + use http_body_util::Full; + use rmcp::{ + handler::server::ServerHandler, + model::{ServerCapabilities, ServerInfo}, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, + }; + use serde_json::json; + + #[derive(Clone)] + struct TestHandler; + + impl ServerHandler for TestHandler { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().build()) + } + } + + fn service_with_allowed_origins( + origins: &[&str], + ) -> StreamableHttpService { + StreamableHttpService::new( + || Ok(TestHandler), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default().with_allowed_origins(origins.iter().copied()), + ) + } + + fn init_request(origin: Option<&str>) -> Request> { + let init_body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0.0"} + } + }); + let mut builder = Request::builder() + .method(Method::POST) + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .header("Host", "localhost:8080"); + if let Some(origin) = origin { + builder = builder.header("Origin", origin); + } + builder + .body(Full::new(Bytes::from(init_body.to_string()))) + .unwrap() + } + + #[tokio::test] + async fn allowlisted_origin_is_allowed() { + let service = service_with_allowed_origins(&["http://localhost:8080"]); + let response = service + .handle(init_request(Some("http://localhost:8080"))) + .await; + assert_eq!(response.status(), http::StatusCode::OK); + } + + #[tokio::test] + async fn non_allowlisted_origin_is_forbidden() { + let service = service_with_allowed_origins(&["http://localhost:8080"]); + let response = service + .handle(init_request(Some("http://attacker.example"))) + .await; + assert_eq!(response.status(), http::StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn missing_origin_passes_through() { + let service = service_with_allowed_origins(&["http://localhost:8080"]); + let response = service.handle(init_request(None)).await; + assert_eq!(response.status(), http::StatusCode::OK); + } + + #[tokio::test] + async fn scheme_mismatch_is_forbidden() { + let service = service_with_allowed_origins(&["http://localhost:8080"]); + let response = service + .handle(init_request(Some("https://localhost:8080"))) + .await; + assert_eq!(response.status(), http::StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn null_origin_is_allowed_when_allowlisted() { + let service = service_with_allowed_origins(&["null"]); + let response = service.handle(init_request(Some("null"))).await; + assert_eq!(response.status(), http::StatusCode::OK); + } + + #[tokio::test] + async fn null_origin_is_forbidden_when_not_allowlisted() { + let service = service_with_allowed_origins(&["http://localhost:8080"]); + let response = service.handle(init_request(Some("null"))).await; + assert_eq!(response.status(), http::StatusCode::FORBIDDEN); + } +} diff --git a/vendor/rmcp/tests/test_custom_request.rs b/vendor/rmcp/tests/test_custom_request.rs new file mode 100644 index 000000000..66ee1ff99 --- /dev/null +++ b/vendor/rmcp/tests/test_custom_request.rs @@ -0,0 +1,190 @@ +#![cfg(not(feature = "local"))] +use std::sync::Arc; + +use rmcp::{ + ClientHandler, ServerHandler, ServiceExt, + model::{ + ClientRequest, ClientResult, CustomRequest, CustomResult, ServerRequest, ServerResult, + }, +}; +use serde_json::json; +use tokio::sync::{Mutex, Notify}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +type CustomRequestPayload = (String, Option); + +struct CustomRequestServer { + receive_signal: Arc, + payload: Arc>>, +} + +impl ServerHandler for CustomRequestServer { + async fn on_custom_request( + &self, + request: CustomRequest, + _context: rmcp::service::RequestContext, + ) -> Result { + let CustomRequest { method, params, .. } = request; + *self.payload.lock().await = Some((method, params)); + self.receive_signal.notify_one(); + Ok(CustomResult::new(json!({ "status": "ok" }))) + } +} + +#[tokio::test] +async fn test_custom_client_request_reaches_server() -> anyhow::Result<()> { + let _ = tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "debug".to_string().into()), + ) + .with(tracing_subscriber::fmt::layer()) + .try_init(); + + let (server_transport, client_transport) = tokio::io::duplex(4096); + let receive_signal = Arc::new(Notify::new()); + let payload = Arc::new(Mutex::new(None)); + + { + let receive_signal = receive_signal.clone(); + let payload = payload.clone(); + tokio::spawn(async move { + let server = CustomRequestServer { + receive_signal, + payload, + } + .serve(server_transport) + .await?; + server.waiting().await?; + anyhow::Ok(()) + }); + } + + let client = ().serve(client_transport).await?; + + let response = client + .send_request(ClientRequest::CustomRequest(CustomRequest::new( + "requests/custom-test", + Some(json!({ "foo": "bar" })), + ))) + .await?; + + tokio::time::timeout(std::time::Duration::from_secs(5), receive_signal.notified()).await?; + + let (method, params) = payload.lock().await.take().expect("payload set"); + assert_eq!("requests/custom-test", method); + assert_eq!(Some(json!({ "foo": "bar" })), params); + + match response { + ServerResult::CustomResult(result) => { + assert_eq!(result.0, json!({ "status": "ok" })); + } + other => panic!("Expected custom result, got: {other:?}"), + } + + client.cancel().await?; + Ok(()) +} + +struct CustomRequestClient { + receive_signal: Arc, + payload: Arc>>, +} + +impl ClientHandler for CustomRequestClient { + async fn on_custom_request( + &self, + request: CustomRequest, + _context: rmcp::service::RequestContext, + ) -> Result { + let CustomRequest { method, params, .. } = request; + *self.payload.lock().await = Some((method, params)); + self.receive_signal.notify_one(); + Ok(CustomResult::new(json!({ "status": "ok" }))) + } +} + +struct CustomRequestServerNotifier { + receive_signal: Arc, + response: Arc>>>, +} + +impl ServerHandler for CustomRequestServerNotifier { + async fn on_initialized(&self, context: rmcp::service::NotificationContext) { + let peer = context.peer.clone(); + let receive_signal = self.receive_signal.clone(); + let response = self.response.clone(); + tokio::spawn(async move { + let result = peer + .send_request(ServerRequest::CustomRequest(CustomRequest::new( + "requests/custom-server", + Some(json!({ "ping": "pong" })), + ))) + .await; + let payload = match result { + Ok(ClientResult::CustomResult(result)) => Ok(result.0), + Ok(other) => Err(format!("Unexpected response: {other:?}")), + Err(err) => Err(format!("Failed to send request: {err:?}")), + }; + *response.lock().await = Some(payload); + receive_signal.notify_one(); + }); + } +} + +#[tokio::test] +async fn test_custom_server_request_reaches_client() -> anyhow::Result<()> { + let _ = tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "debug".to_string().into()), + ) + .with(tracing_subscriber::fmt::layer()) + .try_init(); + + let (server_transport, client_transport) = tokio::io::duplex(4096); + let response_signal = Arc::new(Notify::new()); + let response = Arc::new(Mutex::new(None)); + tokio::spawn({ + let response_signal = response_signal.clone(); + let response = response.clone(); + async move { + let server = CustomRequestServerNotifier { + receive_signal: response_signal, + response, + } + .serve(server_transport) + .await?; + server.waiting().await?; + anyhow::Ok(()) + } + }); + + let receive_signal = Arc::new(Notify::new()); + let payload = Arc::new(Mutex::new(None)); + + let client = CustomRequestClient { + receive_signal: receive_signal.clone(), + payload: payload.clone(), + } + .serve(client_transport) + .await?; + + tokio::time::timeout(std::time::Duration::from_secs(5), receive_signal.notified()).await?; + tokio::time::timeout( + std::time::Duration::from_secs(5), + response_signal.notified(), + ) + .await?; + + let (method, params) = payload.lock().await.take().expect("payload set"); + assert_eq!("requests/custom-server", method); + assert_eq!(Some(json!({ "ping": "pong" })), params); + + let response = response.lock().await.take().expect("response set"); + let response = response.expect("custom request response ok"); + assert_eq!(response, json!({ "status": "ok" })); + + client.cancel().await?; + Ok(()) +} diff --git a/vendor/rmcp/tests/test_deserialization.rs b/vendor/rmcp/tests/test_deserialization.rs new file mode 100644 index 000000000..857077015 --- /dev/null +++ b/vendor/rmcp/tests/test_deserialization.rs @@ -0,0 +1,238 @@ +use rmcp::model::{JsonRpcResponse, ServerJsonRpcMessage, ServerResult}; +#[test] +fn test_tool_list_result() { + let json = std::fs::read("tests/test_deserialization/tool_list_result.json").unwrap(); + let result: ServerJsonRpcMessage = serde_json::from_slice(&json).unwrap(); + println!("{result:#?}"); + + assert!(matches!( + result, + ServerJsonRpcMessage::Response(JsonRpcResponse { + result: ServerResult::ListToolsResult(_), + .. + }) + )); +} + +/// Regression tests for `#[serde(untagged)]` deserialization of `ServerResult`. +/// +/// `ServerResult` is an untagged enum, so serde tries each variant in declaration +/// order, with `CustomResult(Value)` acting as the catch-all. If variant ordering +/// changes, these tests will catch the regression. +mod untagged_server_result { + use rmcp::model::{CallToolResult, JsonRpcResponse, ServerJsonRpcMessage, ServerResult}; + use serde_json::json; + + /// Helper: wrap a result value in a JSON-RPC response envelope. + fn wrap_response(result: serde_json::Value) -> serde_json::Value { + json!({ + "jsonrpc": "2.0", + "id": 1, + "result": result + }) + } + + /// Parse a JSON-RPC response and return the inner `ServerResult`. + fn parse_result(json: serde_json::Value) -> ServerResult { + let msg: ServerJsonRpcMessage = serde_json::from_value(json).unwrap(); + match msg { + ServerJsonRpcMessage::Response(JsonRpcResponse { result, .. }) => result, + other => panic!("expected Response, got {other:?}"), + } + } + + #[test] + fn initialize_result_deserializes_to_correct_variant() { + let result = parse_result(wrap_response(json!({ + "protocolVersion": "2025-03-26", + "capabilities": {}, + "serverInfo": { + "name": "test-server", + "version": "1.0.0" + } + }))); + assert!( + matches!(result, ServerResult::InitializeResult(_)), + "expected InitializeResult, got {result:?}" + ); + } + + #[test] + fn call_tool_result_deserializes_to_correct_variant() { + let result = parse_result(wrap_response(json!({ + "content": [ + { "type": "text", "text": "hello" } + ] + }))); + assert!( + matches!(result, ServerResult::CallToolResult(_)), + "expected CallToolResult, got {result:?}" + ); + } + + #[test] + fn input_required_result_with_meta_deserializes_to_correct_variant() { + let result = parse_result(wrap_response(json!({ + "resultType": "input_required", + "inputRequests": { + "username": { + "method": "elicitation/create", + "params": { + "message": "Please provide your username", + "requestedSchema": { + "type": "object", + "properties": { + "username": { "type": "string" } + }, + "required": ["username"] + } + } + } + }, + "requestState": "opaque-state", + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "test-server", + "version": "1.0.0" + } + } + }))); + + let ServerResult::InputRequiredResult(result) = result else { + panic!("expected InputRequiredResult, got {result:?}"); + }; + assert!( + result.input_requests.is_some_and(|requests| { + requests.len() == 1 && requests.contains_key("username") + }) + ); + assert_eq!(result.request_state.as_deref(), Some("opaque-state")); + assert_eq!( + result + .meta + .as_ref() + .and_then(|meta| meta.get("io.modelcontextprotocol/serverInfo")), + Some(&json!({ + "name": "test-server", + "version": "1.0.0" + })) + ); + } + + #[test] + fn call_tool_result_rejects_input_required_discriminator() { + assert!( + serde_json::from_value::(json!({ + "resultType": "input_required", + "requestState": "opaque-state", + "_meta": {} + })) + .is_err() + ); + } + + #[test] + fn invalid_input_required_result_falls_through_to_custom_result() { + let payload = json!({ + "resultType": "input_required", + "_meta": {} + }); + let result = parse_result(wrap_response(payload.clone())); + + let ServerResult::CustomResult(result) = result else { + panic!("expected CustomResult, got {result:?}"); + }; + assert_eq!(result.0, payload); + } + + #[test] + fn empty_object_deserializes_to_empty_result() { + let result = parse_result(wrap_response(json!({}))); + assert!( + matches!(result, ServerResult::EmptyResult(_)), + "expected EmptyResult, got {result:?}" + ); + } + + #[test] + fn unknown_shape_falls_through_to_custom_result() { + // A value that doesn't match any known result type should land in + // CustomResult. + let result = parse_result(wrap_response(json!({ + "some_unknown_field": "some_value", + "number": 42 + }))); + assert!( + matches!(result, ServerResult::CustomResult(_)), + "expected CustomResult, got {result:?}" + ); + } + + #[test] + fn result_type_bearing_objects_do_not_match_task_ack() { + // TaskAckResult carries only `resultType` (+ optional `_meta`), so it + // must not greedily swallow arbitrary results that happen to include + // a `resultType` key inside the untagged ServerResult union. + let result = parse_result(wrap_response(json!({ + "resultType": "weird-custom", + "payload": { "a": 1 } + }))); + assert!( + matches!(result, ServerResult::CustomResult(_)), + "expected CustomResult, got {result:?}" + ); + + let result = parse_result(wrap_response(json!({ + "resultType": "complete", + "customField": 42 + }))); + assert!( + matches!(result, ServerResult::CustomResult(_)), + "expected CustomResult, got {result:?}" + ); + + // A bare complete ack (the actual tasks/update / tasks/cancel ack + // shape) still parses as TaskAckResult. + let result = parse_result(wrap_response(json!({ "resultType": "complete" }))); + assert!( + matches!(result, ServerResult::TaskAckResult(_)), + "expected TaskAckResult, got {result:?}" + ); + } + + #[test] + fn arbitrary_json_value_falls_through_to_custom_result() { + // Any bare JSON value must fall through to CustomResult. + for value in [json!(42), json!("hello"), json!(null), json!([1, 2, 3])] { + let result = parse_result(wrap_response(value.clone())); + assert!( + matches!(result, ServerResult::CustomResult(_)), + "value {value} should deserialize as CustomResult, got {result:?}" + ); + } + } + + #[test] + fn round_trip_initialize_result_preserves_variant() { + let json = json!({ + "protocolVersion": "2025-03-26", + "capabilities": {}, + "serverInfo": { "name": "test", "version": "1.0" } + }); + // Parse as ServerResult, serialize back, parse again — must stay InitializeResult. + let result = parse_result(wrap_response(json.clone())); + assert!(matches!(&result, ServerResult::InitializeResult(_))); + let reserialized = serde_json::to_value(&result).unwrap(); + let result2 = parse_result(wrap_response(reserialized)); + assert!(matches!(result2, ServerResult::InitializeResult(_))); + } + + #[test] + fn round_trip_call_tool_result_preserves_variant() { + let original = + CallToolResult::success(vec![rmcp::model::ContentBlock::text("hello world")]); + let json = serde_json::to_value(&original).unwrap(); + let result = parse_result(wrap_response(json)); + assert!(matches!(result, ServerResult::CallToolResult(_))); + } +} diff --git a/vendor/rmcp/tests/test_deserialization/tool_list_result.json b/vendor/rmcp/tests/test_deserialization/tool_list_result.json new file mode 100644 index 000000000..38f7b34f5 --- /dev/null +++ b/vendor/rmcp/tests/test_deserialization/tool_list_result.json @@ -0,0 +1,31 @@ +{ + "result": { + "tools": [ + { + "name": "add", + "inputSchema": { + "type": "object", + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "optional" + } + } + ] + }, + "jsonrpc": "2.0", + "id": 2 +} \ No newline at end of file diff --git a/vendor/rmcp/tests/test_discover_http_client_startup.rs b/vendor/rmcp/tests/test_discover_http_client_startup.rs new file mode 100644 index 000000000..c6051a047 --- /dev/null +++ b/vendor/rmcp/tests/test_discover_http_client_startup.rs @@ -0,0 +1,137 @@ +#![cfg(all( + not(feature = "local"), + feature = "client", + feature = "reqwest", + feature = "transport-streamable-http-server" +))] + +use std::borrow::Cow; + +use rmcp::{ + ClientLifecycleMode, ClientServiceExt, ServerHandler, + model::{ClientInfo, DiscoverResult, ErrorCode, ErrorData, ProtocolVersion}, + service::{MaybeSendFuture, RequestContext, RoleServer}, + transport::{ + StreamableHttpClientTransport, + streamable_http_client::StreamableHttpClientTransportConfig, + streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, + }, +}; +use tokio_util::sync::CancellationToken; + +#[derive(Clone, Default)] +struct DiscoverHttpServer; + +impl ServerHandler for DiscoverHttpServer { + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(&[ProtocolVersion::V_2026_07_28]) + } +} + +#[derive(Clone, Default)] +struct LegacyHttpServer; + +impl ServerHandler for LegacyHttpServer { + fn discover( + &self, + _context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Err(ErrorData::new( + ErrorCode::METHOD_NOT_FOUND, + "Method not found", + None, + ))) + } +} + +#[tokio::test] +async fn discover_http_client_bootstraps_headers_without_initialize() { + let ct = CancellationToken::new(); + let service: StreamableHttpService = + StreamableHttpService::new( + || Ok(DiscoverHttpServer), + Default::default(), + StreamableHttpServerConfig::default() + .with_legacy_session_mode(false) + .with_json_response(true) + .with_cancellation_token(ct.child_token()), + ); + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(format!("http://{address}/mcp")), + ); + let client = ClientInfo::default() + .serve_with_lifecycle( + transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await + .expect("discover HTTP client should start"); + client.list_tools(None).await.expect("list tools"); + client.cancel().await.expect("cancel client"); + + ct.cancel(); + server.await.expect("server task"); +} + +#[tokio::test] +async fn auto_http_client_falls_back_to_stateful_legacy_startup() { + let ct = CancellationToken::new(); + let service: StreamableHttpService = + StreamableHttpService::new( + || Ok(LegacyHttpServer), + Default::default(), + StreamableHttpServerConfig::default() + .with_json_response(true) + .with_cancellation_token(ct.child_token()), + ); + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(format!("http://{address}/mcp")), + ); + let client = ClientInfo::default() + .serve_with_lifecycle( + transport, + ClientLifecycleMode::Auto { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + legacy_version: Some(ProtocolVersion::V_2025_11_25), + }, + ) + .await + .expect("auto HTTP client should fall back"); + client.list_tools(None).await.expect("list tools"); + client.cancel().await.expect("cancel client"); + + ct.cancel(); + server.await.expect("server task"); +} diff --git a/vendor/rmcp/tests/test_elicitation.rs b/vendor/rmcp/tests/test_elicitation.rs new file mode 100644 index 000000000..953648c0e --- /dev/null +++ b/vendor/rmcp/tests/test_elicitation.rs @@ -0,0 +1,2077 @@ +//cargo test --test test_elicitation --features "client server" + +use rmcp::{model::*, object, service::*}; +// For typed elicitation tests +#[cfg(feature = "schemars")] +use schemars::JsonSchema; +#[cfg(feature = "schemars")] +use serde::{Deserialize, Serialize}; +use serde_json::json; + +/// Test that elicitation data structures can be serialized and deserialized correctly +/// This ensures JSON-RPC compatibility with MCP 2025-06-18 specification +#[tokio::test] +async fn test_elicitation_serialization() { + // Test ElicitationAction enum serialization + let accept = ElicitationAction::Accept; + let decline = ElicitationAction::Decline; + let cancel = ElicitationAction::Cancel; + + assert_eq!(serde_json::to_string(&accept).unwrap(), "\"accept\""); + assert_eq!(serde_json::to_string(&decline).unwrap(), "\"decline\""); + assert_eq!(serde_json::to_string(&cancel).unwrap(), "\"cancel\""); + + // Test deserialization + assert_eq!( + serde_json::from_str::("\"accept\"").unwrap(), + ElicitationAction::Accept + ); + assert_eq!( + serde_json::from_str::("\"decline\"").unwrap(), + ElicitationAction::Decline + ); + assert_eq!( + serde_json::from_str::("\"cancel\"").unwrap(), + ElicitationAction::Cancel + ); +} + +/// Test ElicitRequestParams structure serialization/deserialization +#[tokio::test] +async fn test_elicitation_request_param_serialization() { + let schema = ElicitationSchema::builder() + .required_property( + "email", + PrimitiveSchemaDefinition::String(StringSchema::email()), + ) + .build() + .unwrap(); + + let request_param = ElicitRequestParams::FormElicitationParams { + meta: None, + message: "Please provide your email address".to_string(), + requested_schema: schema, + }; + + // Test serialization + let json = serde_json::to_value(&request_param).unwrap(); + let expected = json!({ + "mode": "form", + "message": "Please provide your email address", + "requestedSchema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + } + }, + "required": ["email"] + } + }); + + assert_eq!(json, expected); + + // Test deserialization + let deserialized: ElicitRequestParams = serde_json::from_value(expected).unwrap(); + match (&deserialized, &request_param) { + ( + ElicitRequestParams::FormElicitationParams { + meta: None, + message: msg1, + requested_schema: schema1, + }, + ElicitRequestParams::FormElicitationParams { + meta: None, + message: msg2, + requested_schema: schema2, + }, + ) => { + assert_eq!(msg1, msg2); + assert_eq!(schema1, schema2); + } + _ => panic!("Expected FormElicitationParams variant"), + } +} + +/// Test ElicitResult structure with different action types +#[tokio::test] +async fn test_elicitation_result_serialization() { + // Test Accept with content + let accept_result = ElicitResult::new(ElicitationAction::Accept) + .with_content(json!({"email": "user@example.com"})); + + let json = serde_json::to_value(&accept_result).unwrap(); + let expected = json!({ + "action": "accept", + "content": {"email": "user@example.com"} + }); + assert_eq!(json, expected); + + // Test Decline without content + let decline_result = ElicitResult::new(ElicitationAction::Decline); + + let json = serde_json::to_value(&decline_result).unwrap(); + let expected = json!({ + "action": "decline" + // content should be omitted when None due to skip_serializing_if + }); + assert_eq!(json, expected); + + // Test deserialization + let deserialized: ElicitResult = serde_json::from_value(expected).unwrap(); + assert_eq!(deserialized.action, ElicitationAction::Decline); + assert_eq!(deserialized.content, None); + assert_eq!(deserialized.meta, None); + + // Test protocol-level metadata round-trips as _meta. + let meta_result = ElicitResult::new(ElicitationAction::Accept).with_meta(MetaObject(object!({ + "traceId": "elicitation-123" + }))); + + let json = serde_json::to_value(&meta_result).unwrap(); + let expected = json!({ + "action": "accept", + "_meta": {"traceId": "elicitation-123"} + }); + assert_eq!(json, expected); + + let deserialized: ElicitResult = serde_json::from_value(expected).unwrap(); + assert_eq!( + deserialized.meta, + Some(MetaObject(object!({ "traceId": "elicitation-123" }))) + ); +} + +/// Test that elicitation requests can be created and handled through the JSON-RPC protocol +#[tokio::test] +async fn test_elicitation_json_rpc_protocol() { + let schema = ElicitationSchema::builder() + .required_property( + "confirmation", + PrimitiveSchemaDefinition::Boolean(BooleanSchema::new()), + ) + .build() + .unwrap(); + + // Create a complete JSON-RPC request for elicitation + let request = JsonRpcRequest { + jsonrpc: JsonRpcVersion2_0, + id: RequestId::Number(1), + request: ElicitRequest::new(ElicitRequestParams::FormElicitationParams { + meta: None, + message: "Do you want to continue?".to_string(), + requested_schema: schema, + }), + }; + + // Test serialization of complete request + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["jsonrpc"], "2.0"); + assert_eq!(json["id"], 1); + assert_eq!(json["method"], "elicitation/create"); + assert_eq!(json["params"]["message"], "Do you want to continue?"); + + // Test deserialization + let deserialized: JsonRpcRequest = serde_json::from_value(json).unwrap(); + assert_eq!(deserialized.id, RequestId::Number(1)); + match &deserialized.request.params { + ElicitRequestParams::FormElicitationParams { message, .. } => { + assert_eq!(message, "Do you want to continue?"); + } + _ => panic!("Expected FormElicitationParams variant"), + } +} + +/// Test elicitation action types and their expected behavior +#[tokio::test] +async fn test_elicitation_action_types() { + // Test all three action types + let actions = [ + ElicitationAction::Accept, + ElicitationAction::Decline, + ElicitationAction::Cancel, + ]; + + // Each action should have a unique string representation + let serialized: Vec = actions + .iter() + .map(|action| serde_json::to_string(action).unwrap()) + .collect(); + + assert_eq!(serialized.len(), 3); + assert!(serialized.contains(&"\"accept\"".to_string())); + assert!(serialized.contains(&"\"decline\"".to_string())); + assert!(serialized.contains(&"\"cancel\"".to_string())); + + // Test round-trip serialization + for action in actions { + let json = serde_json::to_string(&action).unwrap(); + let deserialized: ElicitationAction = serde_json::from_str(&json).unwrap(); + assert_eq!(action, deserialized); + } +} + +/// Test MCP 2025-06-18 specification compliance +/// Ensures our implementation matches the latest MCP spec +#[tokio::test] +async fn test_elicitation_spec_compliance() { + // Test that method names match the specification + assert_eq!(ElicitationCreateRequestMethod::VALUE, "elicitation/create"); + assert_eq!( + ElicitationResponseNotificationMethod::VALUE, + "notifications/elicitation/response" + ); + + // Test that enum values match specification + let actions = [ + ElicitationAction::Accept, + ElicitationAction::Decline, + ElicitationAction::Cancel, + ]; + + let serialized: Vec = actions + .iter() + .map(|a| serde_json::to_string(a).unwrap()) + .collect(); + + assert_eq!(serialized, vec!["\"accept\"", "\"decline\"", "\"cancel\""]); +} + +/// Test error handling and edge cases for elicitation +#[tokio::test] +async fn test_elicitation_error_handling() { + // Test minimal schema handling (empty properties is technically valid) + let minimal_schema_request = ElicitRequestParams::FormElicitationParams { + meta: None, + message: "Test message".to_string(), + requested_schema: ElicitationSchema::builder().build().unwrap(), + }; + + // Should serialize without error + let _json = serde_json::to_value(&minimal_schema_request).unwrap(); + + // Test empty message + let empty_message_request = ElicitRequestParams::FormElicitationParams { + meta: None, + message: "".to_string(), + requested_schema: ElicitationSchema::builder() + .property( + "value", + PrimitiveSchemaDefinition::String(StringSchema::new()), + ) + .build() + .unwrap(), + }; + + // Should serialize without error (validation is up to the implementation) + let _json = serde_json::to_value(&empty_message_request).unwrap(); + + // Test that we can deserialize invalid action types (should fail) + let invalid_action_json = json!("invalid_action"); + let result = serde_json::from_value::(invalid_action_json); + assert!(result.is_err()); +} + +/// Benchmark-style test for elicitation performance +#[tokio::test] +async fn test_elicitation_performance() { + let schema = ElicitationSchema::builder() + .property( + "data", + PrimitiveSchemaDefinition::String(StringSchema::new()), + ) + .build() + .unwrap(); + + let request = ElicitRequestParams::FormElicitationParams { + meta: None, + message: "Performance test message".to_string(), + requested_schema: schema, + }; + + let start = std::time::Instant::now(); + + // Serialize/deserialize 1000 times + for _ in 0..1000 { + let json = serde_json::to_value(&request).unwrap(); + let _deserialized: ElicitRequestParams = serde_json::from_value(json).unwrap(); + } + + let duration = start.elapsed(); + println!( + "1000 elicitation serialization/deserialization cycles took: {:?}", + duration + ); + + // Should complete in reasonable time (less than 100ms on modern hardware) + assert!( + duration.as_millis() < 1000, + "Performance test took too long: {:?}", + duration + ); +} + +/// Test elicitation capabilities integration +/// Ensures that elicitation capability can be properly configured and serialized +#[tokio::test] +async fn test_elicitation_capabilities() { + use rmcp::model::{ClientCapabilities, ElicitationCapability}; + + // Test basic elicitation capability + let mut elicitation_cap = ElicitationCapability::default(); + assert_eq!(elicitation_cap.form, None); + assert_eq!(elicitation_cap.url, None); + + // Test with schema validation enabled + elicitation_cap.form = Some(FormElicitationCapability::new().with_schema_validation(true)); + + // Test serialization + let json = serde_json::to_value(&elicitation_cap).unwrap(); + let expected = json!({"form":{"schemaValidation": true}}); + assert_eq!(json, expected); + + // Test deserialization + let deserialized: ElicitationCapability = serde_json::from_value(expected).unwrap(); + assert_eq!( + deserialized.form.as_ref().unwrap().schema_validation, + Some(true) + ); + + // Test ClientCapabilities builder with elicitation + let client_caps = ClientCapabilities::builder() + .enable_elicitation() + .enable_elicitation_schema_validation() + .build(); + + assert!(client_caps.elicitation.is_some()); + assert_eq!( + client_caps + .elicitation + .as_ref() + .unwrap() + .form + .as_ref() + .unwrap() + .schema_validation, + Some(true) + ); + + // Test full client capabilities serialization + let json = serde_json::to_value(&client_caps).unwrap(); + assert!( + json["elicitation"]["form"]["schemaValidation"] + .as_bool() + .unwrap_or(false) + ); +} + +/// Test convenience methods for common elicitation scenarios +/// This ensures the helper methods create proper requests with expected schemas +#[tokio::test] +async fn test_elicitation_convenience_methods() { + // Test that convenience methods produce the expected request parameters + + // Test confirmation schema + let confirmation_schema = serde_json::json!({ + "type": "boolean", + "description": "User confirmation (true for yes, false for no)" + }); + + // Verify the schema structure for boolean confirmation + assert_eq!(confirmation_schema["type"], "boolean"); + assert!(confirmation_schema["description"].is_string()); + + // Test text input schema (non-required) + let text_schema = serde_json::json!({ + "type": "string", + "description": "User text input" + }); + + assert_eq!(text_schema["type"], "string"); + assert!(text_schema.get("minLength").is_none()); + + // Test text input schema (required) + let required_text_schema = serde_json::json!({ + "type": "string", + "description": "User text input", + "minLength": 1 + }); + + assert_eq!(required_text_schema["minLength"], 1); + + // Test choice schema + let options = ["Option A", "Option B", "Option C"]; + let choice_schema = serde_json::json!({ + "type": "integer", + "minimum": 0, + "maximum": options.len() - 1, + "description": format!("Choose an option: {}", options.join(", ")) + }); + + assert_eq!(choice_schema["type"], "integer"); + assert_eq!(choice_schema["minimum"], 0); + assert_eq!(choice_schema["maximum"], 2); + assert!( + choice_schema["description"] + .as_str() + .unwrap() + .contains("Option A") + ); + + // Test that ElicitRequestParams can be created with type-safe schemas + let confirmation_request = ElicitRequestParams::FormElicitationParams { + meta: None, + message: "Test confirmation".to_string(), + requested_schema: ElicitationSchema::builder() + .property( + "confirmed", + PrimitiveSchemaDefinition::Boolean( + BooleanSchema::new() + .description("User confirmation (true for yes, false for no)"), + ), + ) + .build() + .unwrap(), + }; + + // Test serialization of convenience method request + let json = serde_json::to_value(&confirmation_request).unwrap(); + assert_eq!(json["message"], "Test confirmation"); + assert_eq!(json["requestedSchema"]["type"], "object"); + assert_eq!( + json["requestedSchema"]["properties"]["confirmed"]["type"], + "boolean" + ); +} + +/// Test structured input with multiple primitive properties +/// Ensures that schemas with multiple primitive properties work correctly with elicitation +#[tokio::test] +async fn test_elicitation_structured_schemas() { + // Test schema with multiple primitive properties + let schema = ElicitationSchema::builder() + .required_string_with("name", |s| s.length(1, 100)) + .required_email("email") + .required_integer("age", 0, 150) + .optional_bool("newsletter", false) + .required_enum_schema( + "country", + EnumSchema::builder(vec!["US".to_string(), "UK".to_string(), "CA".to_string()]).build(), + ) + .description("User registration information") + .build() + .unwrap(); + + let request = ElicitRequestParams::FormElicitationParams { + meta: None, + message: "Please provide your user information".to_string(), + requested_schema: schema, + }; + + // Test that complex schemas serialize/deserialize correctly + let json = serde_json::to_value(&request).unwrap(); + let deserialized: ElicitRequestParams = serde_json::from_value(json).unwrap(); + + match deserialized { + ElicitRequestParams::FormElicitationParams { + message, + requested_schema, + .. + } => { + assert_eq!(message, "Please provide your user information"); + assert_eq!(requested_schema.properties.len(), 5); + assert!(requested_schema.properties.contains_key("name")); + assert!(requested_schema.properties.contains_key("email")); + assert!(requested_schema.properties.contains_key("age")); + assert!(requested_schema.properties.contains_key("newsletter")); + assert!(requested_schema.properties.contains_key("country")); + assert_eq!( + requested_schema.required, + Some(vec![ + "name".to_string(), + "email".to_string(), + "age".to_string(), + "country".to_string() + ]) + ); + } + _ => panic!("Expected FormElicitationParams variant"), + } +} + +// Typed elicitation tests using the API with schemars +#[cfg(feature = "schemars")] +mod typed_elicitation_tests { + use super::*; + + /// Simple user confirmation with reason + #[derive(Debug, Serialize, Deserialize, JsonSchema, PartialEq)] + #[schemars(description = "User confirmation with optional reasoning")] + struct UserConfirmation { + #[schemars(description = "User's decision (true for yes, false for no)")] + confirmed: bool, + + #[schemars(description = "Optional reason for the decision")] + reason: Option, + } + + /// User profile with validation constraints + #[derive(Debug, Serialize, Deserialize, JsonSchema, PartialEq)] + #[schemars(description = "Complete user profile information")] + struct UserProfile { + #[schemars(description = "Full name")] + name: String, + + #[schemars(description = "Email address")] + email: String, + + #[schemars(description = "Age in years")] + age: u8, + + #[schemars(description = "User preferences")] + preferences: UserPreferences, + } + + /// User preferences + #[derive(Debug, Serialize, Deserialize, JsonSchema, PartialEq)] + struct UserPreferences { + #[schemars(description = "UI theme preference")] + theme: Theme, + + #[schemars(description = "Enable notifications")] + notifications: bool, + + #[schemars(description = "Language preference")] + language: String, + } + + /// UI theme options + #[derive(Debug, Serialize, Deserialize, JsonSchema, PartialEq)] + #[schemars(description = "Available UI themes")] + enum Theme { + #[schemars(description = "Light theme")] + Light, + #[schemars(description = "Dark theme")] + Dark, + #[schemars(description = "Auto-detect based on system")] + Auto, + } + + // Mark types as safe for elicitation (they generate object schemas) + rmcp::elicit_safe!(UserConfirmation, UserProfile, UserPreferences); + + /// Test automatic schema generation for simple types + #[tokio::test] + async fn test_typed_elicitation_simple_schema() { + // Test that schema generation works for simple types + let schema = rmcp::handler::server::tool::schema_for_type::(); + + // Verify schema contains expected fields + assert!(schema.contains_key("type")); + assert_eq!(schema.get("type"), Some(&json!("object"))); + assert!(schema.contains_key("properties")); + + if let Some(properties) = schema.get("properties") { + assert!(properties.is_object()); + let props = properties.as_object().unwrap(); + assert!(props.contains_key("confirmed")); + assert!(props.contains_key("reason")); + + // Check confirmed field is boolean + if let Some(confirmed_schema) = props.get("confirmed") { + let confirmed_obj = confirmed_schema.as_object().unwrap(); + assert_eq!(confirmed_obj.get("type"), Some(&json!("boolean"))); + } + + // Check reason field is optional string + if let Some(reason_schema) = props.get("reason") { + assert!(reason_schema.is_object()); + } + } + } + + /// Test automatic schema generation for complex nested types + #[tokio::test] + async fn test_typed_elicitation_complex_schema() { + // Test complex nested structure schema generation + let schema = rmcp::handler::server::tool::schema_for_type::(); + + // Verify schema structure + assert!(schema.contains_key("type")); + assert_eq!(schema.get("type"), Some(&json!("object"))); + + if let Some(properties) = schema.get("properties") { + let props = properties.as_object().unwrap(); + + // Check required fields exist + assert!(props.contains_key("name")); + assert!(props.contains_key("email")); + assert!(props.contains_key("age")); + assert!(props.contains_key("preferences")); + + // Check validation constraints for name + if let Some(name_schema) = props.get("name") { + let name_obj = name_schema.as_object().unwrap(); + assert_eq!(name_obj.get("type"), Some(&json!("string"))); + // Note: schemars might generate constraints differently + // The exact structure depends on schemars version + } + + // Check email format constraint + if let Some(email_schema) = props.get("email") { + let email_obj = email_schema.as_object().unwrap(); + assert_eq!(email_obj.get("type"), Some(&json!("string"))); + } + + // Check age numeric constraints + if let Some(age_schema) = props.get("age") { + let age_obj = age_schema.as_object().unwrap(); + assert_eq!(age_obj.get("type"), Some(&json!("integer"))); + } + } + } + + /// Test enum schema generation + #[tokio::test] + async fn test_enum_schema_generation() { + // Test enum schema generation + let schema = rmcp::handler::server::tool::schema_for_type::(); + + // Verify enum schema structure - schemars might use oneOf or enum depending on version + assert!( + schema.contains_key("type") + || schema.contains_key("oneOf") + || schema.contains_key("enum") + ); + + // The exact structure depends on schemars configuration, but it should be valid + let json = serde_json::to_string(&schema).unwrap(); + assert!(!json.is_empty()); + } + + /// Test that the schema generation for nested structures works + #[tokio::test] + async fn test_nested_structure_schema() { + // Test that nested structures generate proper schemas + let preferences_schema = rmcp::handler::server::tool::schema_for_type::(); + + // Verify basic structure + assert!(preferences_schema.contains_key("type")); + assert_eq!(preferences_schema.get("type"), Some(&json!("object"))); + + if let Some(properties) = preferences_schema.get("properties") { + let props = properties.as_object().unwrap(); + assert!(props.contains_key("theme")); + assert!(props.contains_key("notifications")); + assert!(props.contains_key("language")); + } + } +} + +// ============================================================================= +// ELICITATION ENUM TESTS (MCP 2025-11-25 COMPLIANCE) +// ============================================================================= + +/// Test elicitation with multi-select enum schema +#[tokio::test] +async fn test_elicitation_multi_select_enum() { + let enum_schema = EnumSchema::builder(vec!["A".into(), "B".into(), "C".into()]) + .multiselect() + .enum_titles(vec![ + "A name".to_string(), + "B name".to_string(), + "C name".to_string(), + ]) + .expect("Number of title must match number of enum values") + .min_items(1) + .expect("Min items must be less than or equal to number of enum values") + .max_items(2) + .expect("Max items must be less than or equal to number of enum values") + .build(); + + let schema = ElicitationSchema::builder() + .required_enum_schema("choices", enum_schema) + .build() + .unwrap(); + + let request = ElicitRequestParams::FormElicitationParams { + meta: None, + message: "Please provide your user information".to_string(), + requested_schema: schema, + }; + + // Test that complex schemas serialize/deserialize correctly + let json = serde_json::to_value(&request).unwrap(); + let deserialized: ElicitRequestParams = serde_json::from_value(json).unwrap(); + + match deserialized { + ElicitRequestParams::FormElicitationParams { + message, + requested_schema, + .. + } => { + assert_eq!(message, "Please provide your user information"); + assert_eq!(requested_schema.properties.len(), 1); + assert!(requested_schema.properties.contains_key("choices")); + assert_eq!(requested_schema.required, Some(vec!["choices".to_string()])); + + assert!(matches!( + requested_schema.properties.get("choices").unwrap(), + PrimitiveSchemaDefinition::Enum(EnumSchema::Multi(_)) + )); + + if let Some(PrimitiveSchemaDefinition::Enum(schema)) = + requested_schema.properties.get("choices") + { + assert_eq!( + schema, + &EnumSchema::Multi(MultiSelectEnumSchema::Titled( + TitledMultiSelectEnumSchema::new(TitledItems::new(vec![ + ConstTitle::new("A", "A name"), + ConstTitle::new("B", "B name"), + ConstTitle::new("C", "C name"), + ])) + .with_min_items(1) + .with_max_items(2) + )) + ) + } + } + _ => panic!("Expected FormElicitationParams variant"), + } +} + +/// Test elicitation with single-select enum schema +#[tokio::test] +async fn test_elicitation_single_select_enum() { + let enum_schema = EnumSchema::builder(vec!["A".into(), "B".into(), "C".into()]) + .enum_titles(vec![ + "A name".to_string(), + "B name".to_string(), + "C name".to_string(), + ]) + .expect("Number of title must match number of enum values") + .build(); + + let schema = ElicitationSchema::builder() + .required_enum_schema("choices", enum_schema) + .build() + .unwrap(); + + let request = ElicitRequestParams::FormElicitationParams { + meta: None, + message: "Please provide your user information".to_string(), + requested_schema: schema, + }; + + // Test that complex schemas serialize/deserialize correctly + let json = serde_json::to_value(&request).unwrap(); + let deserialized: ElicitRequestParams = serde_json::from_value(json).unwrap(); + + match deserialized { + ElicitRequestParams::FormElicitationParams { + message, + requested_schema, + .. + } => { + assert_eq!(message, "Please provide your user information"); + assert_eq!(requested_schema.properties.len(), 1); + assert!(requested_schema.properties.contains_key("choices")); + assert_eq!(requested_schema.required, Some(vec!["choices".to_string()])); + assert!(matches!( + requested_schema.properties.get("choices").unwrap(), + PrimitiveSchemaDefinition::Enum(EnumSchema::Single(_)) + )); + + if let Some(PrimitiveSchemaDefinition::Enum(schema)) = + requested_schema.properties.get("choices") + { + assert_eq!( + schema, + &EnumSchema::Single(SingleSelectEnumSchema::Titled( + TitledSingleSelectEnumSchema::new(vec![ + ConstTitle::new("A", "A name"), + ConstTitle::new("B", "B name"), + ConstTitle::new("C", "C name"), + ]) + )) + ) + } + } + _ => panic!("Expected FormElicitationParams variant"), + } +} + +// ============================================================================= +// ELICITATION DIRECTION TESTS (MCP 2025-06-18 COMPLIANCE) +// ============================================================================= + +/// Test that elicitation requests flow from server to client (not client to server) +/// This verifies compliance with MCP 2025-06-18 specification +#[cfg(all(feature = "client", feature = "server"))] +#[tokio::test] +async fn test_elicitation_direction_server_to_client() { + use rmcp::model::*; + use serde_json::json; + + // Test that server can create elicitation requests + let schema = ElicitationSchema::builder() + .property( + "name", + PrimitiveSchemaDefinition::String(StringSchema::new().description("Enter your name")), + ) + .build() + .unwrap(); + + let elicitation_request = ElicitRequestParams::FormElicitationParams { + meta: None, + message: "Please enter your name".to_string(), + requested_schema: schema, + }; + + // Verify request can be serialized + let serialized = serde_json::to_value(&elicitation_request).unwrap(); + assert_eq!(serialized["message"], "Please enter your name"); + assert_eq!(serialized["requestedSchema"]["type"], "object"); + + // Test that elicitation requests are part of ServerRequest + let _server_request = ServerRequest::ElicitRequest(ElicitRequest::new(elicitation_request)); + + // Test that client can respond with elicitation results + let client_result = ClientResult::ElicitResult( + ElicitResult::new(ElicitationAction::Accept).with_content(json!("John Doe")), + ); + + // Verify client result can be serialized + match client_result { + ClientResult::ElicitResult(result) => { + assert_eq!(result.action, ElicitationAction::Accept); + assert_eq!(result.content, Some(json!("John Doe"))); + } + _ => panic!("ElicitResult should be part of ClientResult"), + } +} + +/// Test complete JSON-RPC message flow: Server → Client → Server +#[cfg(all(feature = "client", feature = "server"))] +#[tokio::test] +async fn test_elicitation_json_rpc_direction() { + use rmcp::model::*; + use serde_json::json; + + let schema = ElicitationSchema::builder() + .property( + "continue", + PrimitiveSchemaDefinition::Boolean( + BooleanSchema::new().description("Do you want to continue?"), + ), + ) + .build() + .unwrap(); + + // 1. Server creates elicitation request + let server_request = ServerJsonRpcMessage::request( + ServerRequest::ElicitRequest(ElicitRequest::new( + ElicitRequestParams::FormElicitationParams { + meta: None, + message: "Do you want to continue?".to_string(), + requested_schema: schema, + }, + )), + RequestId::Number(1), + ); + + // Serialize server request + let server_json = serde_json::to_value(&server_request).unwrap(); + assert_eq!(server_json["method"], "elicitation/create"); + assert_eq!(server_json["id"], 1); + assert_eq!(server_json["params"]["message"], "Do you want to continue?"); + + // 2. Client responds with elicitation result + let client_response = ClientJsonRpcMessage::response( + ClientResult::ElicitResult( + ElicitResult::new(ElicitationAction::Accept).with_content(json!(true)), + ), + RequestId::Number(1), + ); + + // Serialize client response + let client_json = serde_json::to_value(&client_response).unwrap(); + assert_eq!(client_json["id"], 1); + if let Some(result) = client_json["result"].as_object() { + assert_eq!(result["action"], "accept"); + assert_eq!(result["content"], true); + } else { + panic!("Client response should contain result"); + } +} + +/// Test all three elicitation actions according to MCP spec +#[cfg(all(feature = "client", feature = "server"))] +#[tokio::test] +async fn test_elicitation_actions_compliance() { + use rmcp::model::*; + + // Test all three elicitation actions according to MCP spec + let actions = [ + ElicitationAction::Accept, + ElicitationAction::Decline, + ElicitationAction::Cancel, + ]; + + for action in actions { + let result = { + let r = ElicitResult::new(action.clone()); + match action { + ElicitationAction::Accept => r.with_content(serde_json::json!("some data")), + _ => r, + } + }; + + let json = serde_json::to_value(&result).unwrap(); + + match action { + ElicitationAction::Accept => { + assert_eq!(json["action"], "accept"); + assert!(json["content"].is_string()); + } + ElicitationAction::Decline => { + assert_eq!(json["action"], "decline"); + assert!(json.get("content").is_none() || json["content"].is_null()); + } + ElicitationAction::Cancel => { + assert_eq!(json["action"], "cancel"); + assert!(json.get("content").is_none() || json["content"].is_null()); + } + _ => {} + } + } +} + +/// Test that ElicitResult IS in ClientResult (response compliance) +#[tokio::test] +async fn test_elicitation_result_in_client_result() { + use rmcp::model::*; + + // Test that clients can return elicitation results + let result = ClientResult::ElicitResult(ElicitResult::new(ElicitationAction::Decline)); + + match result { + ClientResult::ElicitResult(elicit_result) => { + assert_eq!(elicit_result.action, ElicitationAction::Decline); + assert_eq!(elicit_result.content, None); + } + _ => panic!("ElicitResult should be part of ClientResult"), + } +} + +// ============================================================================= +// ELICITATION CAPABILITIES TESTS +// ============================================================================= + +/// Test ElicitationCapability structure and serialization +#[tokio::test] +async fn test_elicitation_capability_structure() { + // Test default ElicitationCapability + let default_cap = ElicitationCapability::default(); + assert!(default_cap.form.is_none()); + assert!(default_cap.url.is_none()); + + // Test ElicitationCapability with schema validation enabled + let cap_with_validation = ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(true)); + assert_eq!( + cap_with_validation.form.as_ref().unwrap().schema_validation, + Some(true) + ); + + // Test ElicitationCapability with schema validation disabled + let cap_without_validation = ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(false)); + assert_eq!( + cap_without_validation + .form + .as_ref() + .unwrap() + .schema_validation, + Some(false) + ); + + // Test JSON serialization + let json = serde_json::to_value(&cap_with_validation).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "form": { + "schemaValidation": true + } + }) + ); + + // Test JSON deserialization + let deserialized: ElicitationCapability = serde_json::from_value(json).unwrap(); + assert_eq!( + deserialized.form.as_ref().unwrap().schema_validation, + Some(true) + ); +} + +/// Test ClientCapabilities with elicitation capability +#[tokio::test] +async fn test_client_capabilities_with_elicitation() { + // Test ClientCapabilities with elicitation capability + let capabilities = ClientCapabilities::builder() + .enable_elicitation_with( + ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(true)), + ) + .build(); + + // Verify elicitation capability is present + assert!(capabilities.elicitation.is_some()); + assert_eq!( + capabilities + .elicitation + .as_ref() + .unwrap() + .form + .as_ref() + .unwrap() + .schema_validation, + Some(true) + ); + + // Test JSON serialization + let json = serde_json::to_value(&capabilities).unwrap(); + assert!( + json["elicitation"]["form"]["schemaValidation"] + .as_bool() + .unwrap_or(false) + ); + + // Test ClientCapabilities without elicitation + let capabilities_without = ClientCapabilities::default(); + + assert!(capabilities_without.elicitation.is_none()); +} + +#[tokio::test] +async fn test_initialize_request_with_elicitation() { + let init_param = InitializeRequestParams::new( + ClientCapabilities::builder() + .enable_elicitation_with( + ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(true)), + ) + .build(), + Implementation::new("test-client", "1.0.0"), + ); + + // Verify the structure + assert!(init_param.capabilities.elicitation.is_some()); + assert_eq!( + init_param + .capabilities + .elicitation + .as_ref() + .unwrap() + .form + .as_ref() + .unwrap() + .schema_validation, + Some(true) + ); + + // Test JSON serialization + let json = serde_json::to_value(&init_param).unwrap(); + assert!( + json["capabilities"]["elicitation"]["form"]["schemaValidation"] + .as_bool() + .unwrap_or(false) + ); +} + +/// Test capability checking logic (simulated) +#[tokio::test] +async fn test_capability_checking_logic() { + // Simulate the logic that would be used in supports_elicitation() + + // Case 1: Client with elicitation capability + let client_with_capability = InitializeRequestParams::new( + ClientCapabilities::builder() + .enable_elicitation_with( + ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(true)), + ) + .build(), + Implementation::new("test-client", "1.0.0"), + ); + + // Simulate supports_elicitation() logic + let supports_elicitation = client_with_capability.capabilities.elicitation.is_some(); + assert!(supports_elicitation); + + // Case 2: Client without elicitation capability + let client_without_capability = InitializeRequestParams::new( + ClientCapabilities::default(), + Implementation::new("test-client", "1.0.0"), + ); + let supports_elicitation = client_without_capability.capabilities.elicitation.is_some(); + assert!(!supports_elicitation); +} + +/// Test CapabilityNotSupported error message formatting +#[tokio::test] +async fn test_capability_not_supported_error_message() { + let error = ElicitationError::CapabilityNotSupported; + let message = format!("{}", error); + + assert_eq!( + message, + "Client does not support elicitation - capability not declared during initialization" + ); +} + +/// Test all ElicitationError variants and their messages +#[tokio::test] +async fn test_elicitation_error_variants() { + // Test CapabilityNotSupported + let capability_error = ElicitationError::CapabilityNotSupported; + assert_eq!( + format!("{}", capability_error), + "Client does not support elicitation - capability not declared during initialization" + ); + + // Test UserDeclined + let user_declined = ElicitationError::UserDeclined; + assert_eq!( + format!("{}", user_declined), + "User explicitly declined the request" + ); + + // Test UserCancelled + let user_cancelled = ElicitationError::UserCancelled; + assert_eq!( + format!("{}", user_cancelled), + "User cancelled/dismissed the request" + ); + + // Test NoContent + let no_content = ElicitationError::NoContent; + assert_eq!(format!("{}", no_content), "No response content provided"); + + // Test Service error + let service_error = ElicitationError::Service(ServiceError::UnexpectedResponse); + let message = format!("{}", service_error); + assert!(message.starts_with("Service error:")); + + // Test ParseError + let json_error = serde_json::from_str::("\"not_an_integer\"").unwrap_err(); + let data = serde_json::json!({"key": "value"}); + let parse_error = ElicitationError::ParseError { + error: json_error, + data: data.clone(), + }; + let message = format!("{}", parse_error); + assert!(message.starts_with("Failed to parse response data:")); + assert!(message.contains("Received data:")); + + // Test error matching + match capability_error { + ElicitationError::CapabilityNotSupported => {} // Expected + _ => panic!("Should match CapabilityNotSupported"), + } + + match user_declined { + ElicitationError::UserDeclined => {} // Expected + _ => panic!("Should match UserDeclined"), + } + + match user_cancelled { + ElicitationError::UserCancelled => {} // Expected + _ => panic!("Should match UserCancelled"), + } + + match no_content { + ElicitationError::NoContent => {} // Expected + _ => panic!("Should match NoContent"), + } +} + +/// Test ElicitationCapability serialization with schema validation +#[tokio::test] +async fn test_elicitation_capability_serialization() { + use rmcp::model::ElicitationCapability; + + // Test default capability (no schema validation) + let default_cap = ElicitationCapability::default(); + let json = serde_json::to_value(&default_cap).unwrap(); + + // Should serialize to empty object when no fields are set + assert_eq!(json, serde_json::json!({})); + + // Test capability with schema validation enabled + let cap_with_validation = ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(true)); + let json = serde_json::to_value(&cap_with_validation).unwrap(); + + assert_eq!( + json, + serde_json::json!({ + "form": { + "schemaValidation": true + } + }) + ); + + // Test capability with schema validation disabled + let cap_without_validation = ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(false)); + let json = serde_json::to_value(&cap_without_validation).unwrap(); + + assert_eq!( + json, + serde_json::json!({ + "form": { + "schemaValidation": false + } + }) + ); + + // Test deserialization + let deserialized: ElicitationCapability = serde_json::from_value(serde_json::json!({ + "form":{"schemaValidation": true} + })) + .unwrap(); + + assert_eq!(deserialized.form.unwrap().schema_validation, Some(true)); +} + +/// Test ClientCapabilities builder with elicitation capability methods +#[tokio::test] +async fn test_client_capabilities_elicitation_builder() { + use rmcp::model::{ClientCapabilities, ElicitationCapability}; + + // Test enabling elicitation capability + let caps = ClientCapabilities::builder().enable_elicitation().build(); + + assert!(caps.elicitation.is_some()); + assert_eq!(caps.elicitation.as_ref().unwrap().form, None); + + // Test enabling elicitation with schema validation + let caps_with_validation = ClientCapabilities::builder() + .enable_elicitation() + .enable_elicitation_schema_validation() + .build(); + + assert!(caps_with_validation.elicitation.is_some()); + assert_eq!( + caps_with_validation + .elicitation + .as_ref() + .unwrap() + .form + .as_ref() + .unwrap() + .schema_validation, + Some(true) + ); + + // Test enabling elicitation with custom capability + let custom_elicitation = ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(false)); + + let caps_custom = ClientCapabilities::builder() + .enable_elicitation_with(custom_elicitation.clone()) + .build(); + + assert!(caps_custom.elicitation.is_some()); + assert_eq!( + caps_custom.elicitation.as_ref().unwrap(), + &custom_elicitation + ); +} + +// ============================================================================= +// TIMEOUT TESTS +// ============================================================================= + +/// Test basic timeout functionality for create_elicitation_with_timeout +#[tokio::test] +async fn test_create_elicitation_with_timeout_basic() { + use std::time::Duration; + + // This test verifies that the method accepts timeout parameter + let schema = ElicitationSchema::builder() + .required_property( + "name", + PrimitiveSchemaDefinition::String(StringSchema::new()), + ) + .required_property( + "email", + PrimitiveSchemaDefinition::String(StringSchema::new()), + ) + .build() + .unwrap(); + + let _params = ElicitRequestParams::FormElicitationParams { + meta: None, + message: "Enter your details".to_string(), + requested_schema: schema, + }; + + // Test different timeout values + let timeout_short = Duration::from_millis(100); + let timeout_long = Duration::from_secs(30); + let timeout_none: Option = None; + + // Verify timeout parameter types are correct + assert!(!timeout_short.is_zero()); + assert!(!timeout_long.is_zero()); + assert!(timeout_none.is_none()); + + // Verify timeout values are reasonable + assert_eq!(timeout_short.as_millis(), 100); + assert_eq!(timeout_long.as_secs(), 30); +} + +/// Test timeout behavior with elicit_with_timeout method +#[tokio::test] +async fn test_elicit_with_timeout_method_signature() { + use std::time::Duration; + + // Test that method signature works with different timeout values + let timeout_values = vec![ + None, + Some(Duration::from_millis(500)), + Some(Duration::from_secs(1)), + Some(Duration::from_secs(30)), + Some(Duration::from_secs(60)), + ]; + + for timeout in timeout_values { + // Verify timeout value is properly handled + match timeout { + None => assert!(timeout.is_none()), + Some(duration) => { + assert!(duration > Duration::from_millis(0)); + assert!(duration <= Duration::from_secs(300)); // Max 5 minutes + } + } + } +} + +/// Test timeout value validation +#[tokio::test] +async fn test_timeout_value_validation() { + use std::time::Duration; + + // Test valid timeout ranges + let valid_timeouts = vec![ + Duration::from_millis(1), // Minimum + Duration::from_millis(100), // Short + Duration::from_secs(1), // 1 second + Duration::from_secs(30), // 30 seconds + Duration::from_secs(300), // 5 minutes + ]; + + for timeout in valid_timeouts { + assert!(timeout >= Duration::from_millis(1)); + assert!(timeout <= Duration::from_secs(300)); + } + + // Test edge cases + let zero_timeout = Duration::from_millis(0); + let very_long_timeout = Duration::from_secs(3600); // 1 hour + + // Zero timeout should be handled gracefully + assert_eq!(zero_timeout, Duration::from_millis(0)); + + // Very long timeout should work but may not be practical + assert!(very_long_timeout > Duration::from_secs(300)); +} + +/// Test timeout error message formatting +#[tokio::test] +async fn test_timeout_error_formatting() { + use std::time::Duration; + + let timeout = Duration::from_secs(30); + + // Simulate a timeout error + let timeout_error = ServiceError::Timeout { timeout }; + + // Verify error contains timeout information + let error_string = format!("{}", timeout_error); + assert!(error_string.contains("timeout")); + assert!(error_string.contains("30")); +} + +/// Test elicitation error handling with timeout +#[tokio::test] +async fn test_elicitation_timeout_error_conversion() { + use std::time::Duration; + + let timeout = Duration::from_millis(500); + let service_timeout_error = ServiceError::Timeout { timeout }; + let elicitation_error = ElicitationError::Service(service_timeout_error); + + // Verify error chain is preserved + match elicitation_error { + ElicitationError::Service(ServiceError::Timeout { timeout: t }) => { + assert_eq!(t, timeout); + } + _ => panic!("Expected timeout error"), + } +} + +/// Test timeout parameter pass-through in PeerRequestOptions +#[tokio::test] +async fn test_peer_request_options_timeout() { + use std::time::Duration; + + let timeout = Some(Duration::from_secs(15)); + + let mut options = PeerRequestOptions::default(); + options.timeout = timeout; + + // Verify timeout is properly stored + assert_eq!(options.timeout, timeout); + assert!(options.meta.is_none()); + + // Test with no timeout + let options_no_timeout = PeerRequestOptions::default(); + + assert!(options_no_timeout.timeout.is_none()); +} + +/// Test realistic timeout scenarios +#[tokio::test] +async fn test_realistic_timeout_scenarios() { + use std::time::Duration; + + // Test common timeout scenarios users might encounter + + // Quick response (5 seconds) + let quick_timeout = Duration::from_secs(5); + assert!(quick_timeout >= Duration::from_secs(1)); + assert!(quick_timeout <= Duration::from_secs(10)); + + // Normal interaction (30 seconds) + let normal_timeout = Duration::from_secs(30); + assert!(normal_timeout >= Duration::from_secs(10)); + assert!(normal_timeout <= Duration::from_secs(60)); + + // Long form input (2 minutes) + let long_timeout = Duration::from_secs(120); + assert!(long_timeout >= Duration::from_secs(60)); + assert!(long_timeout <= Duration::from_secs(300)); +} + +/// Test that different ElicitationAction values map to correct error types +#[tokio::test] +async fn test_elicitation_action_error_mapping() { + use rmcp::{model::ElicitationAction, service::ElicitationError}; + + // Test that each action type produces the expected error + let test_cases = vec![ + (ElicitationAction::Decline, "UserDeclined"), + (ElicitationAction::Cancel, "UserCancelled"), + ]; + + for (action, _expected_error_type) in test_cases { + // Verify that the action exists and has the expected semantics + match action { + ElicitationAction::Accept => { + // Accept should not produce an error (it provides content) + } + ElicitationAction::Decline => { + // Should map to UserDeclined error + let error = ElicitationError::UserDeclined; + assert!(format!("{}", error).contains("explicitly declined")); + } + ElicitationAction::Cancel => { + // Should map to UserCancelled error + let error = ElicitationError::UserCancelled; + assert!(format!("{}", error).contains("cancelled/dismissed")); + } + _ => {} + } + } +} + +/// Test elicitation action semantics according to MCP specification +#[tokio::test] +async fn test_elicitation_action_semantics() { + use rmcp::model::ElicitationAction; + + // According to MCP spec: + // - Accept: User explicitly approved and submitted with data + // - Decline: User explicitly declined the request + // - Cancel: User dismissed without making an explicit choice + + // Test that all three actions are available + let actions = vec![ + ElicitationAction::Accept, + ElicitationAction::Decline, + ElicitationAction::Cancel, + ]; + + assert_eq!(actions.len(), 3); + + // Test serialization/deserialization + for action in actions { + let serialized = serde_json::to_string(&action).expect("Should serialize"); + let deserialized: ElicitationAction = + serde_json::from_str(&serialized).expect("Should deserialize"); + + // Actions should round-trip correctly + match (action, deserialized) { + (ElicitationAction::Accept, ElicitationAction::Accept) => {} + (ElicitationAction::Decline, ElicitationAction::Decline) => {} + (ElicitationAction::Cancel, ElicitationAction::Cancel) => {} + _ => panic!("Action serialization round-trip failed"), + } + } +} + +/// Test compile-time type safety for elicitation +#[tokio::test] +async fn test_elicitation_type_safety() { + use rmcp::service::ElicitationSafe; + use schemars::JsonSchema; + + // Test that our types implement ElicitationSafe + #[derive(serde::Serialize, serde::Deserialize, JsonSchema)] + struct SafeType { + name: String, + value: i32, + } + + rmcp::elicit_safe!(SafeType); + + // Verify that SafeType implements the required traits + fn assert_elicitation_safe() {} + assert_elicitation_safe::(); + + // Test that SafeType can generate schema (compile-time check) + let _schema = schemars::schema_for!(SafeType); +} + +/// Test that elicit_safe! macro works with multiple types +#[tokio::test] +async fn test_elicit_safe_macro() { + use schemars::JsonSchema; + + #[derive(serde::Serialize, serde::Deserialize, JsonSchema)] + struct TypeA { + field_a: String, + } + + #[derive(serde::Serialize, serde::Deserialize, JsonSchema)] + struct TypeB { + field_b: i32, + } + + #[derive(serde::Serialize, serde::Deserialize, JsonSchema)] + struct TypeC { + field_c: bool, + } + + // Test macro with multiple types + rmcp::elicit_safe!(TypeA, TypeB, TypeC); + + // All should implement ElicitationSafe + fn assert_all_safe() {} + assert_all_safe::(); + assert_all_safe::(); + assert_all_safe::(); +} + +/// Test ElicitationSafe trait behavior +#[tokio::test] +async fn test_elicitation_safe_trait() { + use schemars::JsonSchema; + + // Test object type validation + #[derive(serde::Serialize, serde::Deserialize, JsonSchema)] + struct ObjectType { + name: String, + count: usize, + active: bool, + } + + rmcp::elicit_safe!(ObjectType); + + // Test that ObjectType can generate schema (compile-time check) + let _schema = schemars::schema_for!(ObjectType); +} + +/// Test documentation examples compile correctly +#[tokio::test] +async fn test_elicitation_examples_compile() { + use schemars::JsonSchema; + use serde::{Deserialize, Serialize}; + + // Example from trait documentation + #[allow(dead_code)] + #[derive(Serialize, Deserialize, JsonSchema)] + struct UserProfile { + name: String, + email: String, + } + + rmcp::elicit_safe!(UserProfile); + + // This should compile and work + fn _example_usage() { + fn _assert_safe() {} + _assert_safe::(); + } +} + +// ============================================================================= +// BUILD-TIME VALIDATION TESTS +// ============================================================================= + +/// Test that build() validates required fields exist in properties +#[tokio::test] +async fn test_build_validation_required_field_not_in_properties() { + // Try to mark a field as required that doesn't exist in properties + let result = ElicitationSchema::builder() + .property( + "email", + PrimitiveSchemaDefinition::String(StringSchema::email()), + ) + .mark_required("nonexistent_field") + .build(); + + // Should return an error + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "Required field does not exist in properties" + ); +} + +/// Test that build() succeeds when all required fields exist +#[tokio::test] +async fn test_build_validation_required_field_exists() { + let result = ElicitationSchema::builder() + .property( + "email", + PrimitiveSchemaDefinition::String(StringSchema::email()), + ) + .property( + "name", + PrimitiveSchemaDefinition::String(StringSchema::new()), + ) + .mark_required("email") + .mark_required("name") + .build(); + + // Should succeed + assert!(result.is_ok()); + let schema = result.unwrap(); + assert_eq!(schema.properties.len(), 2); + assert_eq!( + schema.required, + Some(vec!["email".to_string(), "name".to_string()]) + ); +} + +/// Test that build_unchecked() panics on validation errors +#[tokio::test] +#[should_panic(expected = "Invalid elicitation schema")] +async fn test_build_unchecked_panics_on_invalid() { + // build_unchecked validates but panics instead of returning Result + let _schema = ElicitationSchema::builder() + .property( + "email", + PrimitiveSchemaDefinition::String(StringSchema::email()), + ) + .mark_required("nonexistent_field") + .build_unchecked(); +} + +/// Test convenience methods handle validation correctly +#[tokio::test] +async fn test_convenience_methods_validation() { + // required_string_property should add both property and mark as required + let result = ElicitationSchema::builder() + .required_string_property("name", |s| s) + .required_email("email") + .build(); + + assert!(result.is_ok()); + let schema = result.unwrap(); + assert_eq!(schema.properties.len(), 2); + assert!( + schema + .required + .as_ref() + .unwrap() + .contains(&"name".to_string()) + ); + assert!( + schema + .required + .as_ref() + .unwrap() + .contains(&"email".to_string()) + ); +} + +/// Test typed property methods work correctly +#[tokio::test] +async fn test_typed_property_methods() { + let result = ElicitationSchema::builder() + .string_property("name", |s| s.length(1, 100)) + .number_property("price", |n| n.range(0.0, 1000.0)) + .integer_property("quantity", |i| i.range(1, 100)) + .bool_property("in_stock", |b| b.with_default(true)) + .build(); + + assert!(result.is_ok()); + let schema = result.unwrap(); + assert_eq!(schema.properties.len(), 4); + + // Verify types are correct + if let Some(PrimitiveSchemaDefinition::String(_)) = schema.properties.get("name") { + // Expected + } else { + panic!("name should be StringSchema"); + } + + if let Some(PrimitiveSchemaDefinition::Number(_)) = schema.properties.get("price") { + // Expected + } else { + panic!("price should be NumberSchema"); + } + + if let Some(PrimitiveSchemaDefinition::Integer(_)) = schema.properties.get("quantity") { + // Expected + } else { + panic!("quantity should be IntegerSchema"); + } + + if let Some(PrimitiveSchemaDefinition::Boolean(_)) = schema.properties.get("in_stock") { + // Expected + } else { + panic!("in_stock should be BooleanSchema"); + } +} + +/// Test required typed property methods +#[tokio::test] +async fn test_required_typed_property_methods() { + let result = ElicitationSchema::builder() + .required_string_property("name", |s| s) + .required_number_property("price", |n| n) + .required_integer_property("age", |i| i) + .required_bool_property("active", |b| b) + .build(); + + assert!(result.is_ok()); + let schema = result.unwrap(); + assert_eq!(schema.properties.len(), 4); + assert_eq!(schema.required.as_ref().unwrap().len(), 4); + + // All should be marked as required + let required = schema.required.as_ref().unwrap(); + assert!(required.contains(&"name".to_string())); + assert!(required.contains(&"price".to_string())); + assert!(required.contains(&"age".to_string())); + assert!(required.contains(&"active".to_string())); +} + +// ============================================================================= +// URL ELICITATION TESTS +// ============================================================================= + +/// Test URL elicitation request parameter serialization/deserialization +#[tokio::test] +async fn test_url_elicitation_request_param_serialization() { + let request_param = ElicitRequestParams::UrlElicitationParams { + meta: None, + message: "Please visit the following URL to complete verification".to_string(), + url: "https://example.com/verify".to_string(), + elicitation_id: "elicit-123".to_string(), + }; + + // Test serialization + let json = serde_json::to_value(&request_param).unwrap(); + let expected = json!({ + "mode": "url", + "message": "Please visit the following URL to complete verification", + "url": "https://example.com/verify", + "elicitationId": "elicit-123" + }); + + assert_eq!(json, expected); + + // Test deserialization + let deserialized: ElicitRequestParams = serde_json::from_value(expected).unwrap(); + match deserialized { + ElicitRequestParams::UrlElicitationParams { + message, + url, + elicitation_id, + .. + } => { + assert_eq!( + message, + "Please visit the following URL to complete verification" + ); + assert_eq!(url, "https://example.com/verify"); + assert_eq!(elicitation_id, "elicit-123"); + } + _ => panic!("Expected UrlElicitationParams variant"), + } +} + +/// Test URL elicitation request in JSON-RPC protocol +#[tokio::test] +async fn test_url_elicitation_json_rpc_protocol() { + // Create a complete JSON-RPC request for URL elicitation + let request = JsonRpcRequest { + jsonrpc: JsonRpcVersion2_0, + id: RequestId::Number(1), + request: ElicitRequest::new(ElicitRequestParams::UrlElicitationParams { + meta: None, + message: "Please authorize this action at the following URL".to_string(), + url: "https://auth.example.com/authorize/abc123".to_string(), + elicitation_id: "auth-request-456".to_string(), + }), + }; + + // Test serialization of complete request + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["jsonrpc"], "2.0"); + assert_eq!(json["id"], 1); + assert_eq!(json["method"], "elicitation/create"); + assert_eq!(json["params"]["mode"], "url"); + assert_eq!( + json["params"]["message"], + "Please authorize this action at the following URL" + ); + assert_eq!( + json["params"]["url"], + "https://auth.example.com/authorize/abc123" + ); + assert_eq!(json["params"]["elicitationId"], "auth-request-456"); + + // Test deserialization + let deserialized: JsonRpcRequest = serde_json::from_value(json).unwrap(); + assert_eq!(deserialized.id, RequestId::Number(1)); + match &deserialized.request.params { + ElicitRequestParams::UrlElicitationParams { + message, + url, + elicitation_id, + .. + } => { + assert_eq!(message, "Please authorize this action at the following URL"); + assert_eq!(url, "https://auth.example.com/authorize/abc123"); + assert_eq!(elicitation_id, "auth-request-456"); + } + _ => panic!("Expected UrlElicitationParams variant"), + } +} + +/// Test UrlElicitationCapability structure and serialization +#[tokio::test] +async fn test_url_elicitation_capability() { + // Test default UrlElicitationCapability + let url_cap = UrlElicitationCapability::default(); + + // Test serialization - should be empty object + let json = serde_json::to_value(&url_cap).unwrap(); + assert_eq!(json, json!({})); + + // Test deserialization + let deserialized: UrlElicitationCapability = serde_json::from_value(json!({})).unwrap(); + assert_eq!(deserialized, url_cap); + + // Test ElicitationCapability with URL mode enabled + let elicitation_cap = ElicitationCapability::new().with_url(UrlElicitationCapability::new()); + + let json = serde_json::to_value(&elicitation_cap).unwrap(); + assert_eq!( + json, + json!({ + "url": {} + }) + ); + + // Test ElicitationCapability with both form and URL modes + let both_cap = ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(true)) + .with_url(UrlElicitationCapability::new()); + + let json = serde_json::to_value(&both_cap).unwrap(); + assert_eq!( + json, + json!({ + "form": { + "schemaValidation": true + }, + "url": {} + }) + ); +} + +/// Test backward compatibility: ElicitRequestParams without mode tag +#[tokio::test] +async fn test_elicitation_backward_compatibility_no_mode() { + let json_without_mode = json!({ + "message": "Please enter your details", + "requestedSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + }); + + let deserialized: ElicitRequestParams = serde_json::from_value(json_without_mode).unwrap(); + + match deserialized { + ElicitRequestParams::FormElicitationParams { + message, + requested_schema, + .. + } => { + assert_eq!(message, "Please enter your details"); + assert_eq!(requested_schema.properties.len(), 1); + assert!(requested_schema.properties.contains_key("name")); + } + _ => panic!("Expected FormElicitationParams for backward compatibility"), + } +} + +/// Test both form and URL elicitation modes in the same test +#[tokio::test] +async fn test_elicitation_both_modes() { + // Form mode + let form_schema = ElicitationSchema::builder() + .required_property( + "email", + PrimitiveSchemaDefinition::String(StringSchema::email()), + ) + .build() + .unwrap(); + + let form_request = ElicitRequestParams::FormElicitationParams { + meta: None, + message: "Enter email".to_string(), + requested_schema: form_schema, + }; + + let form_json = serde_json::to_value(&form_request).unwrap(); + assert_eq!(form_json["mode"], "form"); + assert!(form_json.get("requestedSchema").is_some()); + assert!(form_json.get("url").is_none()); + + // URL mode + let url_request = ElicitRequestParams::UrlElicitationParams { + meta: None, + message: "Visit URL".to_string(), + url: "https://example.com".to_string(), + elicitation_id: "id-123".to_string(), + }; + + let url_json = serde_json::to_value(&url_request).unwrap(); + assert_eq!(url_json["mode"], "url"); + assert!(url_json.get("url").is_some()); + assert!(url_json.get("elicitationId").is_some()); + assert!(url_json.get("requestedSchema").is_none()); +} + +/// Test ClientCapabilities with different elicitation mode combinations +#[tokio::test] +async fn test_client_capabilities_elicitation_modes() { + // Test with form-only capability + let form_only_caps = ClientCapabilities::builder() + .enable_elicitation_with( + ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(true)), + ) + .build(); + + let json = serde_json::to_value(&form_only_caps).unwrap(); + assert!(json["elicitation"]["form"].is_object()); + assert!( + json["elicitation"]["url"].is_null() + || !json["elicitation"].as_object().unwrap().contains_key("url") + ); + + // Test with URL-only capability + let url_only_caps = ClientCapabilities::builder() + .enable_elicitation_with( + ElicitationCapability::new().with_url(UrlElicitationCapability::new()), + ) + .build(); + + let json = serde_json::to_value(&url_only_caps).unwrap(); + assert!(json["elicitation"]["url"].is_object()); + assert!( + json["elicitation"]["form"].is_null() + || !json["elicitation"] + .as_object() + .unwrap() + .contains_key("form") + ); + + // Test with both capabilities + let both_caps = ClientCapabilities::builder() + .enable_elicitation_with( + ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(false)) + .with_url(UrlElicitationCapability::new()), + ) + .build(); + + let json = serde_json::to_value(&both_caps).unwrap(); + assert!(json["elicitation"]["form"].is_object()); + assert!(json["elicitation"]["url"].is_object()); +} + +/// Test ElicitationAction with URL elicitation workflow +#[tokio::test] +async fn test_url_elicitation_action_workflow() { + // Test Accept action for URL elicitation (user visited URL and confirmed) + let accept_result = ElicitResult::new(ElicitationAction::Accept); + + let json = serde_json::to_value(&accept_result).unwrap(); + assert_eq!(json["action"], "accept"); + // content should be omitted when None + assert!(json.get("content").is_none() || json["content"].is_null()); + + // Test Decline action for URL elicitation + let decline_result = ElicitResult::new(ElicitationAction::Decline); + + let json = serde_json::to_value(&decline_result).unwrap(); + assert_eq!(json["action"], "decline"); + + // Test Cancel action for URL elicitation + let cancel_result = ElicitResult::new(ElicitationAction::Cancel); + + let json = serde_json::to_value(&cancel_result).unwrap(); + assert_eq!(json["action"], "cancel"); +} + +/// Test method constants for URL elicitation +#[tokio::test] +async fn test_elicitation_method_constants() { + // Test existing methods + assert_eq!(ElicitationCreateRequestMethod::VALUE, "elicitation/create"); + assert_eq!( + ElicitationResponseNotificationMethod::VALUE, + "notifications/elicitation/response" + ); +} diff --git a/vendor/rmcp/tests/test_embedded_resource_meta.rs b/vendor/rmcp/tests/test_embedded_resource_meta.rs new file mode 100644 index 000000000..5854f3f54 --- /dev/null +++ b/vendor/rmcp/tests/test_embedded_resource_meta.rs @@ -0,0 +1,114 @@ +use rmcp::model::{ContentBlock, EmbeddedResource, MetaObject, ResourceContents}; +use serde_json::json; + +#[test] +fn serialize_embedded_text_resource_with_meta() { + let mut resource_content_meta = MetaObject::new(); + resource_content_meta.insert("inner".to_string(), json!(2)); + + let mut resource_meta = MetaObject::new(); + resource_meta.insert("top".to_string(), json!(1)); + + let content = ContentBlock::Resource( + EmbeddedResource::new(ResourceContents::TextResourceContents { + uri: "str://example".to_string(), + mime_type: Some("text/plain".to_string()), + text: "hello".to_string(), + meta: Some(resource_content_meta), + }) + .with_meta(resource_meta), + ); + + let v = serde_json::to_value(&content).unwrap(); + + let expected = json!({ + "type": "resource", + "_meta": {"top": 1}, + "resource": { + "uri": "str://example", + "mimeType": "text/plain", + "text": "hello", + "_meta": {"inner": 2} + } + }); + + assert_eq!(v, expected); +} + +#[test] +fn serialize_embedded_text_resource_without_meta_omits_fields() { + let content = ContentBlock::Resource(EmbeddedResource::new( + ResourceContents::TextResourceContents { + uri: "str://no-meta".to_string(), + mime_type: Some("text/plain".to_string()), + text: "hi".to_string(), + meta: None, + }, + )); + + let v = serde_json::to_value(&content).unwrap(); + + assert_eq!(v.get("_meta"), None); + let inner = v.get("resource").and_then(|r| r.as_object()).unwrap(); + assert_eq!(inner.get("_meta"), None); +} + +#[test] +fn deserialize_embedded_text_resource_with_meta() { + let raw = json!({ + "type": "resource", + "_meta": {"x": true}, + "resource": { + "uri": "str://from-json", + "text": "ok", + "_meta": {"y": 42} + } + }); + + let content: ContentBlock = serde_json::from_value(raw).unwrap(); + + let er = match &content { + ContentBlock::Resource(er) => er, + _ => panic!("expected resource"), + }; + + let top = er.meta.as_ref().expect("top-level meta missing"); + assert_eq!(top.get("x").unwrap(), &json!(true)); + + match &er.resource { + ResourceContents::TextResourceContents { + meta, uri, text, .. + } => { + assert_eq!(uri, "str://from-json"); + assert_eq!(text, "ok"); + let inner = meta.as_ref().expect("inner meta missing"); + assert_eq!(inner.get("y").unwrap(), &json!(42)); + } + _ => panic!("expected text resource contents"), + } +} + +#[test] +fn serialize_embedded_blob_resource_with_meta() { + let mut resource_content_meta = MetaObject::new(); + resource_content_meta.insert("blob_inner".to_string(), json!(true)); + + let mut resource_meta = MetaObject::new(); + resource_meta.insert("blob_top".to_string(), json!("t")); + + let content = ContentBlock::Resource( + EmbeddedResource::new(ResourceContents::BlobResourceContents { + uri: "str://blob".to_string(), + mime_type: Some("application/octet-stream".to_string()), + blob: "Zm9v".to_string(), + meta: Some(resource_content_meta), + }) + .with_meta(resource_meta), + ); + + let v = serde_json::to_value(&content).unwrap(); + + assert_eq!(v.get("_meta").unwrap(), &json!({"blob_top": "t"})); + let inner = v.get("resource").unwrap(); + assert_eq!(inner.get("_meta").unwrap(), &json!({"blob_inner": true})); +} diff --git a/vendor/rmcp/tests/test_handler_wrappers.rs b/vendor/rmcp/tests/test_handler_wrappers.rs new file mode 100644 index 000000000..18ec242ba --- /dev/null +++ b/vendor/rmcp/tests/test_handler_wrappers.rs @@ -0,0 +1,31 @@ +// cargo test --test test_handler_wrappers --features "client server" + +mod common; + +use std::sync::Arc; + +use common::handlers::TestServer; +use rmcp::ServerHandler; + +#[test] +fn test_wrapped_server_handlers() { + // This test asserts that, when T: ServerHandler, both Box and Arc also implement ServerHandler. + fn accepts_server_handler(_handler: H) {} + + accepts_server_handler(Box::new(TestServer::new())); + accepts_server_handler(Arc::new(TestServer::new())); +} + +#[cfg(feature = "client")] +#[test] +fn test_wrapped_client_handlers() { + use common::handlers::TestClientHandler; + use rmcp::ClientHandler; + // This test asserts that, when T: ClientHandler, both Box and Arc also implement ClientHandler. + fn accepts_client_handler(_handler: H) {} + + let client = TestClientHandler::new(false, false); + + accepts_client_handler(Box::new(client.clone())); + accepts_client_handler(Arc::new(client)); +} diff --git a/vendor/rmcp/tests/test_inflight_response_drain.rs b/vendor/rmcp/tests/test_inflight_response_drain.rs new file mode 100644 index 000000000..c75e17c42 --- /dev/null +++ b/vendor/rmcp/tests/test_inflight_response_drain.rs @@ -0,0 +1,159 @@ +#![cfg(all(feature = "client", feature = "server", not(feature = "local")))] +// cargo test --test test_inflight_response_drain --features "client server" + +use std::{ + pin::Pin, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + task::{Context, Poll}, + time::Duration, +}; + +use rmcp::{ + ServerHandler, ServiceExt, + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{CallToolRequestParams, ClientInfo, ServerCapabilities, ServerInfo}, + service::QuitReason, + tool, tool_handler, tool_router, +}; +use tokio::io::{AsyncRead, ReadBuf}; + +// A slow tool server that sleeps before returning a response. +#[derive(Debug, Clone)] +struct SlowToolServer { + #[expect(dead_code, reason = "tool_handler macro accesses this router field")] + tool_router: ToolRouter, +} + +impl SlowToolServer { + fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct SlowToolRequest { + #[schemars(description = "how long to sleep in milliseconds")] + sleep_ms: u64, +} + +#[tool_router] +impl SlowToolServer { + #[tool(description = "A tool that sleeps then returns")] + async fn slow_tool( + &self, + Parameters(SlowToolRequest { sleep_ms }): Parameters, + ) -> String { + tokio::time::sleep(Duration::from_millis(sleep_ms)).await; + format!("done after {}ms", sleep_ms) + } +} + +#[tool_handler] +impl ServerHandler for SlowToolServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } +} + +#[derive(Debug, Clone, Default)] +struct DummyClientHandler; + +impl rmcp::ClientHandler for DummyClientHandler { + fn get_info(&self) -> ClientInfo { + ClientInfo::default() + } +} + +/// An `AsyncRead` wrapper that delegates to the inner reader until signalled, +/// then returns EOF (read 0 bytes). +struct ClosableReader { + inner: R, + eof_flag: Arc, +} + +impl AsyncRead for ClosableReader { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if self.eof_flag.load(Ordering::Acquire) { + return Poll::Ready(Ok(())); + } + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +/// When the server's input stream returns EOF while a tool handler is still +/// in-flight, the drain phase should flush pending responses before closing. +#[tokio::test] +async fn test_inflight_response_drain_on_eof() -> anyhow::Result<()> { + // Two unidirectional channels: + // client_write → server_read (client sends requests to server) + // server_write → client_read (server sends responses to client) + let (client_write, server_read) = tokio::io::duplex(4096); + let (server_write, client_read) = tokio::io::duplex(4096); + + // Wrap the server's read side so we can signal EOF from the test. + let eof_flag = Arc::new(AtomicBool::new(false)); + let closable_read = ClosableReader { + inner: server_read, + eof_flag: eof_flag.clone(), + }; + + let server_transport = (closable_read, server_write); + let client_transport = (client_read, client_write); + + // Start server with slow tool handler + let server_handle = tokio::spawn(async move { + let server = SlowToolServer::new(); + let running = server.serve(server_transport).await?; + let reason = running.waiting().await?; + assert!( + matches!(reason, QuitReason::Closed), + "expected Closed quit reason, got {:?}", + reason, + ); + anyhow::Ok(()) + }); + + // Start client + let client = DummyClientHandler.serve(client_transport).await?; + + // Call the slow tool (200ms sleep). Concurrently, signal the server's + // read side to return EOF after the request has been sent but before + // the handler finishes. + let tool_future = client.call_tool( + CallToolRequestParams::new("slow_tool").with_arguments( + serde_json::json!({ "sleep_ms": 200 }) + .as_object() + .unwrap() + .clone(), + ), + ); + + let (tool_result, _) = tokio::join!(tool_future, async { + // Wait for the request to be sent and received by the server, + // then signal EOF on the server's read side. + tokio::time::sleep(Duration::from_millis(50)).await; + eof_flag.store(true, Ordering::Release); + }); + + // The tool result should still arrive thanks to the drain phase. + let result = tool_result?; + let text = result + .content + .first() + .and_then(|c| c.as_text()) + .map(|t| t.text.as_str()) + .expect("expected text content in tool result"); + assert_eq!(text, "done after 200ms"); + + server_handle.await??; + Ok(()) +} diff --git a/vendor/rmcp/tests/test_json_schema_detection.rs b/vendor/rmcp/tests/test_json_schema_detection.rs new file mode 100644 index 000000000..e13125c40 --- /dev/null +++ b/vendor/rmcp/tests/test_json_schema_detection.rs @@ -0,0 +1,188 @@ +#![allow(clippy::exhaustive_structs)] +//cargo test --test test_json_schema_detection --features "client server macros" +use rmcp::{ + Json, ServerHandler, handler::server::router::tool::ToolRouter, tool, tool_handler, tool_router, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct TestData { + pub value: String, +} + +#[tool_handler(router = self.tool_router)] +impl ServerHandler for TestServer {} + +#[derive(Debug, Clone)] +pub struct TestServer { + tool_router: ToolRouter, +} + +impl Default for TestServer { + fn default() -> Self { + Self::new() + } +} + +#[tool_router(router = tool_router)] +impl TestServer { + pub fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } + + /// Tool that returns Json - should have output schema + #[tool(name = "with-json")] + pub async fn with_json(&self) -> Result, String> { + Ok(Json(TestData { + value: "test".to_string(), + })) + } + + /// Tool that returns regular type - should NOT have output schema + #[tool(name = "without-json")] + pub async fn without_json(&self) -> Result { + Ok("test".to_string()) + } + + /// Tool that returns Result with inner Json - should have output schema + #[tool(name = "result-with-json")] + pub async fn result_with_json(&self) -> Result, rmcp::ErrorData> { + Ok(Json(TestData { + value: "test".to_string(), + })) + } + + /// Tool with explicit output_schema attribute - should have output schema + #[tool(name = "explicit-schema", output_schema = rmcp::handler::server::tool::schema_for_type::())] + pub async fn explicit_schema(&self) -> Result { + Ok("test".to_string()) + } + + /// Tool that returns Json> - array output schema + #[tool(name = "with-json-array")] + pub async fn with_json_array(&self) -> Result>, String> { + Ok(Json(vec![TestData { + value: "test".to_string(), + }])) + } + + /// Tool that returns Result>, ErrorData> - array output schema + #[tool(name = "result-with-json-array")] + pub async fn result_with_json_array(&self) -> Result>, rmcp::ErrorData> { + Ok(Json(vec![TestData { + value: "test".to_string(), + }])) + } + + /// Tool that returns Json - string output schema + #[tool(name = "with-json-string")] + pub async fn with_json_string(&self) -> Result, String> { + Ok(Json("test".to_string())) + } +} + +#[tokio::test] +async fn test_json_type_generates_schema() { + let server = TestServer::new(); + let tools = server.tool_router.list_all(); + + // Find the with-json tool + let json_tool = tools.iter().find(|t| t.name == "with-json").unwrap(); + assert!( + json_tool.output_schema.is_some(), + "Json return type should generate output schema" + ); +} + +#[tokio::test] +async fn test_non_json_type_no_schema() { + let server = TestServer::new(); + let tools = server.tool_router.list_all(); + + // Find the without-json tool + let non_json_tool = tools.iter().find(|t| t.name == "without-json").unwrap(); + assert!( + non_json_tool.output_schema.is_none(), + "Regular return type should NOT generate output schema" + ); +} + +#[tokio::test] +async fn test_result_with_json_generates_schema() { + let server = TestServer::new(); + let tools = server.tool_router.list_all(); + + // Find the result-with-json tool + let result_json_tool = tools.iter().find(|t| t.name == "result-with-json").unwrap(); + assert!( + result_json_tool.output_schema.is_some(), + "Result, E> return type should generate output schema" + ); +} + +#[tokio::test] +async fn test_explicit_schema_override() { + let server = TestServer::new(); + let tools = server.tool_router.list_all(); + + // Find the explicit-schema tool + let explicit_tool = tools.iter().find(|t| t.name == "explicit-schema").unwrap(); + assert!( + explicit_tool.output_schema.is_some(), + "Explicit output_schema attribute should work" + ); +} + +#[tokio::test] +async fn test_json_array_type_generates_schema() { + let server = TestServer::new(); + let tools = server.tool_router.list_all(); + + let array_tool = tools.iter().find(|t| t.name == "with-json-array").unwrap(); + assert!( + array_tool.output_schema.is_some(), + "Json> return type should generate output schema" + ); + let schema = array_tool.output_schema.as_ref().unwrap(); + assert_eq!( + schema.get("type").and_then(|v| v.as_str()), + Some("array"), + "Json> should produce an array schema" + ); +} + +#[tokio::test] +async fn test_result_with_json_array_generates_schema() { + let server = TestServer::new(); + let tools = server.tool_router.list_all(); + + let result_array_tool = tools + .iter() + .find(|t| t.name == "result-with-json-array") + .unwrap(); + assert!( + result_array_tool.output_schema.is_some(), + "Result>, ErrorData> return type should generate output schema" + ); +} + +#[tokio::test] +async fn test_json_string_type_generates_schema() { + let server = TestServer::new(); + let tools = server.tool_router.list_all(); + + let string_tool = tools.iter().find(|t| t.name == "with-json-string").unwrap(); + assert!( + string_tool.output_schema.is_some(), + "Json return type should generate output schema" + ); + let schema = string_tool.output_schema.as_ref().unwrap(); + assert_eq!( + schema.get("type").and_then(|v| v.as_str()), + Some("string"), + "Json should produce a string schema" + ); +} diff --git a/vendor/rmcp/tests/test_list_tools_result.rs b/vendor/rmcp/tests/test_list_tools_result.rs new file mode 100644 index 000000000..4d8dc70aa --- /dev/null +++ b/vendor/rmcp/tests/test_list_tools_result.rs @@ -0,0 +1,48 @@ +#![cfg(all(feature = "server", feature = "macros", not(feature = "local")))] + +use rmcp::{ + Json, + handler::server::wrapper::Parameters, + model::{ListToolsResult, NumberOrString, ServerJsonRpcMessage, ServerResult}, +}; + +/// Parameters for adding two numbers. +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct AddRequest { + /// The left-hand number. + a: f64, + /// The right-hand number. + b: f64, +} + +/// Result of adding two numbers. +#[derive(Debug, serde::Serialize, schemars::JsonSchema)] +struct AddResult { + /// The sum of the two numbers. + sum: f64, +} + +/// Add two numbers. +#[rmcp::tool] +fn add(Parameters(AddRequest { a, b }): Parameters) -> Json { + Json(AddResult { sum: a + b }) +} + +#[test] +fn list_tools_result_matches_expected_json() { + let expected_json = std::fs::read("tests/test_list_tools_result/list_tools_result.json") + .expect("missing expected list tools result JSON fixture"); + let expected: serde_json::Value = + serde_json::from_slice(&expected_json).expect("invalid expected JSON fixture"); + + assert_eq!(add(Parameters(AddRequest { a: 1.0, b: 2.0 })).0.sum, 3.0); + + let result = ListToolsResult::with_all_items(vec![add_tool_attr()]); + let response = ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(result), + NumberOrString::Number(2), + ); + + let actual = serde_json::to_value(response).expect("failed to serialize list tools response"); + assert_eq!(actual, expected); +} diff --git a/vendor/rmcp/tests/test_list_tools_result/list_tools_result.json b/vendor/rmcp/tests/test_list_tools_result/list_tools_result.json new file mode 100644 index 000000000..c6616b596 --- /dev/null +++ b/vendor/rmcp/tests/test_list_tools_result/list_tools_result.json @@ -0,0 +1,47 @@ +{ + "result": { + "resultType": "complete", + "tools": [ + { + "name": "add", + "description": "Add two numbers.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "a": { + "description": "The left-hand number.", + "format": "double", + "type": "number" + }, + "b": { + "description": "The right-hand number.", + "format": "double", + "type": "number" + } + }, + "required": [ + "a", + "b" + ] + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "sum": { + "description": "The sum of the two numbers.", + "format": "double", + "type": "number" + } + }, + "required": [ + "sum" + ] + } + } + ] + }, + "jsonrpc": "2.0", + "id": 2 +} diff --git a/vendor/rmcp/tests/test_local_client_handler.rs b/vendor/rmcp/tests/test_local_client_handler.rs new file mode 100644 index 000000000..e85d3d8c9 --- /dev/null +++ b/vendor/rmcp/tests/test_local_client_handler.rs @@ -0,0 +1,16 @@ +use std::rc::Rc; + +use rmcp::ClientHandler; + +struct LocalClientHandler { + _state: Rc<()>, +} + +impl ClientHandler for LocalClientHandler {} + +#[test] +fn client_handler_accepts_non_send_sync_state_with_local_feature() { + fn assert_client_handler() {} + + assert_client_handler::(); +} diff --git a/vendor/rmcp/tests/test_logging.rs b/vendor/rmcp/tests/test_logging.rs new file mode 100644 index 000000000..bf00352bc --- /dev/null +++ b/vendor/rmcp/tests/test_logging.rs @@ -0,0 +1,344 @@ +// cargo test --features "server client" --package rmcp test_logging +#![cfg(not(feature = "local"))] +#![allow(deprecated)] +mod common; + +use std::sync::{Arc, Mutex}; + +use common::handlers::{TestClientHandler, TestServer}; +use rmcp::{ + ServiceExt, + model::{LoggingLevel, LoggingMessageNotificationParam, SetLevelRequestParams}, +}; +use serde_json::json; +use tokio::sync::Notify; + +#[tokio::test] +async fn test_logging_spec_compliance() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let receive_signal = Arc::new(Notify::new()); + let received_messages = Arc::new(Mutex::new(Vec::::new())); + + // Start server in a separate task + let server_handle = tokio::spawn(async move { + let server = TestServer::new().serve(server_transport).await?; + + // Test server can send messages before level is set + server + .peer() + .notify_logging_message( + LoggingMessageNotificationParam::new( + LoggingLevel::Info, + serde_json::json!({ + "message": "Server initiated message", + "timestamp": chrono::Utc::now().to_rfc3339(), + }), + ) + .with_logger("test_server"), + ) + .await?; + + server.waiting().await?; + anyhow::Ok(()) + }); + + let client = TestClientHandler::with_notification( + true, + true, + receive_signal.clone(), + received_messages.clone(), + ) + .serve(client_transport) + .await?; + + // Wait for the initial server message + receive_signal.notified().await; + { + let mut messages = received_messages.lock().unwrap(); + assert_eq!(messages.len(), 1, "Should receive server-initiated message"); + messages.clear(); + } + + // Test level filtering and message format + for level in [ + LoggingLevel::Emergency, + LoggingLevel::Warning, + LoggingLevel::Debug, + ] { + client + .peer() + .set_level(SetLevelRequestParams::new(level)) + .await?; + + // Wait for each message response + receive_signal.notified().await; + + let mut messages = received_messages.lock().unwrap(); + let msg = messages.last().unwrap(); + + // Verify required fields + assert_eq!(msg.level, level); + assert!(msg.data.is_object()); + + // Verify data format + let data = msg.data.as_object().unwrap(); + assert!(data.contains_key("message")); + assert!(data.contains_key("timestamp")); + + // Verify timestamp + let timestamp = data["timestamp"].as_str().unwrap(); + chrono::DateTime::parse_from_rfc3339(timestamp).expect("RFC3339 timestamp"); + + messages.clear(); + } + + // Important: Cancel the client before ending the test + client.cancel().await?; + + // Wait for server to complete + server_handle.await??; + + Ok(()) +} + +#[tokio::test] +async fn test_logging_user_scenarios() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let receive_signal = Arc::new(Notify::new()); + let received_messages = Arc::new(Mutex::new(Vec::::new())); + + let server_handle = tokio::spawn(async move { + let server = TestServer::new().serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + + let client = TestClientHandler::with_notification( + true, + true, + receive_signal.clone(), + received_messages.clone(), + ) + .serve(client_transport) + .await?; + + // Test 1: Error reporting scenario + client + .peer() + .set_level(SetLevelRequestParams::new(LoggingLevel::Error)) + .await?; + receive_signal.notified().await; // Wait for response + { + let messages = received_messages.lock().unwrap(); + let msg = &messages[0]; + let data = msg.data.as_object().unwrap(); + assert!( + data.contains_key("error_code"), + "Error should have an error code" + ); + assert!( + data.contains_key("error_details"), + "Error should have details" + ); + assert!( + data.contains_key("timestamp"), + "Should know when error occurred" + ); + } + + // Test 2: Debug scenario + client + .peer() + .set_level(SetLevelRequestParams::new(LoggingLevel::Debug)) + .await?; + receive_signal.notified().await; // Wait for response + { + let messages = received_messages.lock().unwrap(); + let msg = messages.last().unwrap(); + let data = msg.data.as_object().unwrap(); + assert!( + data.contains_key("function"), + "Debug should show function name" + ); + assert!(data.contains_key("line"), "Debug should show line number"); + assert!( + data.contains_key("context"), + "Debug should show execution context" + ); + } + + // Test 3: Production monitoring scenario + client + .peer() + .set_level(SetLevelRequestParams::new(LoggingLevel::Info)) + .await?; + receive_signal.notified().await; // Wait for response + { + let messages = received_messages.lock().unwrap(); + let msg = messages.last().unwrap(); + let data = msg.data.as_object().unwrap(); + assert!(data.contains_key("status"), "Should show system status"); + assert!(data.contains_key("metrics"), "Should include metrics"); + } + + // Important: Cancel client and wait for server before ending + client.cancel().await?; + server_handle.await??; + + Ok(()) +} + +#[test] +fn test_logging_level_serialization() { + // Test all levels match spec exactly + let test_cases = [ + (LoggingLevel::Alert, "alert"), + (LoggingLevel::Critical, "critical"), + (LoggingLevel::Debug, "debug"), + (LoggingLevel::Emergency, "emergency"), + (LoggingLevel::Error, "error"), + (LoggingLevel::Info, "info"), + (LoggingLevel::Notice, "notice"), + (LoggingLevel::Warning, "warning"), + ]; + + for (level, expected) in test_cases { + let serialized = serde_json::to_string(&level).unwrap(); + // Remove quotes from serialized string + let serialized = serialized.trim_matches('"'); + assert_eq!( + serialized, expected, + "LoggingLevel::{:?} should serialize to \"{}\"", + level, expected + ); + } + + // Test deserialization from spec strings + for (level, spec_string) in test_cases { + let deserialized: LoggingLevel = + serde_json::from_str(&format!("\"{}\"", spec_string)).unwrap(); + assert_eq!( + deserialized, level, + "\"{}\" should deserialize to LoggingLevel::{:?}", + spec_string, level + ); + } +} + +#[tokio::test] +async fn test_logging_edge_cases() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let receive_signal = Arc::new(Notify::new()); + let received_messages = Arc::new(Mutex::new(Vec::::new())); + + let server_handle = tokio::spawn(async move { + let server = TestServer::new().serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + + let client = TestClientHandler::with_notification( + true, + true, + receive_signal.clone(), + received_messages.clone(), + ) + .serve(client_transport) + .await?; + + // Test all logging levels from spec + for level in [ + LoggingLevel::Alert, + LoggingLevel::Critical, + LoggingLevel::Notice, // These weren't tested before + ] { + client + .peer() + .set_level(SetLevelRequestParams::new(level)) + .await?; + receive_signal.notified().await; + + let messages = received_messages.lock().unwrap(); + let msg = messages.last().unwrap(); + assert_eq!(msg.level, level); + } + + client.cancel().await?; + server_handle.await??; + Ok(()) +} + +#[tokio::test] +async fn test_logging_optional_fields() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let receive_signal = Arc::new(Notify::new()); + let received_messages = Arc::new(Mutex::new(Vec::::new())); + + let server_handle = tokio::spawn(async move { + let server = TestServer::new().serve(server_transport).await?; + + // Test message with and without optional logger field + for (level, has_logger) in [(LoggingLevel::Info, true), (LoggingLevel::Debug, false)] { + let mut param = LoggingMessageNotificationParam::new(level, json!({"test": "data"})); + param.logger = has_logger.then(|| "test_logger".to_string()); + server.peer().notify_logging_message(param).await?; + } + + server.waiting().await?; + anyhow::Ok(()) + }); + + let client = TestClientHandler::with_notification( + true, + true, + receive_signal.clone(), + received_messages.clone(), + ) + .serve(client_transport) + .await?; + + // Wait for the initial server message + receive_signal.notified().await; + { + let mut messages = received_messages.lock().unwrap(); + assert_eq!(messages.len(), 2, "Should receive two messages"); + messages.clear(); + } + + // Test level filtering and message format + for level in [LoggingLevel::Info, LoggingLevel::Debug] { + client + .peer() + .set_level(SetLevelRequestParams::new(level)) + .await?; + + // Wait for each message response + receive_signal.notified().await; + + let mut messages = received_messages.lock().unwrap(); + let msg = messages.last().unwrap(); + + // Verify required fields + assert_eq!(msg.level, level); + assert!(msg.data.is_object()); + + // Verify data format + let data = msg.data.as_object().unwrap(); + assert!(data.contains_key("message")); + assert!(data.contains_key("timestamp")); + + // Verify timestamp + let timestamp = data["timestamp"].as_str().unwrap(); + chrono::DateTime::parse_from_rfc3339(timestamp).expect("RFC3339 timestamp"); + + messages.clear(); + } + + // Important: Cancel the client before ending the test + client.cancel().await?; + + // Wait for server to complete + server_handle.await??; + + Ok(()) +} diff --git a/vendor/rmcp/tests/test_message_protocol.rs b/vendor/rmcp/tests/test_message_protocol.rs new file mode 100644 index 000000000..f42cfe0a1 --- /dev/null +++ b/vendor/rmcp/tests/test_message_protocol.rs @@ -0,0 +1,426 @@ +//cargo test --test test_message_protocol --features "client server" +#![cfg(not(feature = "local"))] +#![expect(deprecated)] // exercises SEP-2577-deprecated Sampling/Roots/Logging types + +mod common; +use common::handlers::{TestClientHandler, TestServer}; +use rmcp::{ + ServiceExt, + model::*, + service::{RequestContext, Service}, +}; + +// Tests start here +#[tokio::test] +async fn test_message_roles() { + let messages = vec![ + SamplingMessage::user_text("user message"), + SamplingMessage::assistant_text("assistant message"), + ]; + + // Verify all roles can be serialized/deserialized correctly + let json = serde_json::to_string(&messages).unwrap(); + let deserialized: Vec = serde_json::from_str(&json).unwrap(); + assert_eq!(messages, deserialized); +} + +#[tokio::test] +async fn test_context_inclusion_integration() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + // Start server + let server_handle = tokio::spawn(async move { + let server = TestServer::new().serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + + // Start client that honors context requests + let handler = TestClientHandler::new(true, true); + let client = handler.clone().serve(client_transport).await?; + + // Test ThisServer context inclusion + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new(vec![SamplingMessage::user_text("test message")], 100) + .with_include_context(ContextInclusion::ThisServer), + )); + + let result = handler + .handle_request( + request.clone(), + RequestContext::new(NumberOrString::Number(1), client.peer().clone()), + ) + .await?; + + if let ClientResult::CreateMessageResult(result) = result { + let text = result + .message + .content + .first() + .unwrap() + .as_text() + .unwrap() + .text + .as_str(); + assert!( + text.contains("test context"), + "Response should include context for ThisServer" + ); + } else { + panic!("Expected CreateMessageResult"); + } + + // Test AllServers context inclusion + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new(vec![SamplingMessage::user_text("test message")], 100) + .with_include_context(ContextInclusion::AllServers), + )); + + let result = handler + .handle_request( + request.clone(), + RequestContext::new(NumberOrString::Number(2), client.peer().clone()), + ) + .await?; + + if let ClientResult::CreateMessageResult(result) = result { + let text = result + .message + .content + .first() + .unwrap() + .as_text() + .unwrap() + .text + .as_str(); + assert!( + text.contains("test context"), + "Response should include context for AllServers" + ); + } else { + panic!("Expected CreateMessageResult"); + } + + // Test No context inclusion + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new(vec![SamplingMessage::user_text("test message")], 100) + .with_include_context(ContextInclusion::None), + )); + + let result = handler + .handle_request( + request.clone(), + RequestContext::new(NumberOrString::Number(3), client.peer().clone()), + ) + .await?; + + if let ClientResult::CreateMessageResult(result) = result { + let text = result + .message + .content + .first() + .unwrap() + .as_text() + .unwrap() + .text + .as_str(); + assert!( + !text.contains("test context"), + "Response should not include context for None" + ); + } else { + panic!("Expected CreateMessageResult"); + } + + client.cancel().await?; + server_handle.await??; + Ok(()) +} + +#[tokio::test] +async fn test_context_inclusion_ignored_integration() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + // Start server + let server_handle = tokio::spawn(async move { + let server = TestServer::new().serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + + // Start client that ignores context requests + let handler = TestClientHandler::new(false, false); + let client = handler.clone().serve(client_transport).await?; + + // Test that context requests are ignored + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new(vec![SamplingMessage::user_text("test message")], 100) + .with_include_context(ContextInclusion::ThisServer), + )); + + let result = handler + .handle_request( + request.clone(), + RequestContext::new(NumberOrString::Number(1), client.peer().clone()), + ) + .await?; + + if let ClientResult::CreateMessageResult(result) = result { + let text = result + .message + .content + .first() + .unwrap() + .as_text() + .unwrap() + .text + .as_str(); + assert!( + !text.contains("test context"), + "Context should be ignored when client chooses not to honor requests" + ); + } else { + panic!("Expected CreateMessageResult"); + } + + client.cancel().await?; + server_handle.await??; + Ok(()) +} + +#[tokio::test] +async fn test_message_sequence_integration() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + // Start server + let server_handle = tokio::spawn(async move { + let server = TestServer::new().serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + + // Start client + let handler = TestClientHandler::new(true, true); + let client = handler.clone().serve(client_transport).await?; + + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new( + vec![ + SamplingMessage::user_text("first message"), + SamplingMessage::assistant_text("second message"), + ], + 100, + ) + .with_include_context(ContextInclusion::ThisServer), + )); + + let result = handler + .handle_request( + request.clone(), + RequestContext::new(NumberOrString::Number(1), client.peer().clone()), + ) + .await?; + + if let ClientResult::CreateMessageResult(result) = result { + let text = result + .message + .content + .first() + .unwrap() + .as_text() + .unwrap() + .text + .as_str(); + assert!( + text.contains("test context"), + "Response should include context when ThisServer is specified" + ); + assert_eq!(result.model, "test-model"); + assert_eq!( + result.stop_reason, + Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()) + ); + } else { + panic!("Expected CreateMessageResult"); + } + + client.cancel().await?; + server_handle.await??; + Ok(()) +} + +#[tokio::test] +async fn test_message_sequence_validation_integration() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let server_handle = tokio::spawn(async move { + let server = TestServer::new().serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + + let handler = TestClientHandler::new(true, true); + let client = handler.clone().serve(client_transport).await?; + + // Test valid sequence: User -> Assistant -> User + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new( + vec![ + SamplingMessage::user_text("first user message"), + SamplingMessage::assistant_text("first assistant response"), + SamplingMessage::user_text("second user message"), + ], + 100, + ), + )); + + let result = handler + .handle_request( + request.clone(), + RequestContext::new(NumberOrString::Number(1), client.peer().clone()), + ) + .await?; + + assert!(matches!(result, ClientResult::CreateMessageResult(_))); + + // Test invalid: No user message + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new( + vec![SamplingMessage::assistant_text("assistant message")], + 100, + ), + )); + + let result = handler + .handle_request( + request.clone(), + RequestContext::new(NumberOrString::Number(2), client.peer().clone()), + ) + .await; + + assert!(result.is_err()); + + client.cancel().await?; + server_handle.await??; + Ok(()) +} + +#[tokio::test] +async fn test_selective_context_handling_integration() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let server_handle = tokio::spawn(async move { + let server = TestServer::new().serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + + // Client that only honors ThisServer but ignores AllServers + let handler = TestClientHandler::new(true, false); + let client = handler.clone().serve(client_transport).await?; + + // Test ThisServer is honored + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new(vec![SamplingMessage::user_text("test message")], 100) + .with_include_context(ContextInclusion::ThisServer), + )); + + let result = handler + .handle_request( + request.clone(), + RequestContext::new(NumberOrString::Number(1), client.peer().clone()), + ) + .await?; + + if let ClientResult::CreateMessageResult(result) = result { + let text = result + .message + .content + .first() + .unwrap() + .as_text() + .unwrap() + .text + .as_str(); + assert!( + text.contains("test context"), + "ThisServer context request should be honored" + ); + } + + // Test AllServers is ignored + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new(vec![SamplingMessage::user_text("test message")], 100) + .with_include_context(ContextInclusion::AllServers), + )); + + let result = handler + .handle_request( + request.clone(), + RequestContext::new(NumberOrString::Number(2), client.peer().clone()), + ) + .await?; + + if let ClientResult::CreateMessageResult(result) = result { + let text = result + .message + .content + .first() + .unwrap() + .as_text() + .unwrap() + .text + .as_str(); + assert!( + !text.contains("test context"), + "AllServers context request should be ignored" + ); + } + + client.cancel().await?; + server_handle.await??; + Ok(()) +} + +#[tokio::test] +async fn test_context_inclusion() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server_handle = tokio::spawn(async move { + let server = TestServer::new().serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + + let handler = TestClientHandler::new(true, true); + let client = handler.clone().serve(client_transport).await?; + + // Test context handling + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new(vec![SamplingMessage::user_text("test")], 100) + .with_include_context(ContextInclusion::ThisServer), + )); + + let result = handler + .handle_request( + request.clone(), + RequestContext::new(NumberOrString::Number(1), client.peer().clone()), + ) + .await?; + + if let ClientResult::CreateMessageResult(result) = result { + let text = result + .message + .content + .first() + .unwrap() + .as_text() + .unwrap() + .text + .as_str(); + assert!(text.contains("test context")); + } + + client.cancel().await?; + server_handle.await??; + Ok(()) +} diff --git a/vendor/rmcp/tests/test_message_schema.rs b/vendor/rmcp/tests/test_message_schema.rs new file mode 100644 index 000000000..027a2cc86 --- /dev/null +++ b/vendor/rmcp/tests/test_message_schema.rs @@ -0,0 +1,139 @@ +mod tests { + use rmcp::model::{ClientJsonRpcMessage, ServerJsonRpcMessage}; + use schemars::generate::SchemaSettings; + + fn compare_schemas(name: &str, actual: &str, expected_file: &str) { + let expected = match std::fs::read_to_string(expected_file) { + Ok(content) => content, + Err(e) => { + panic!( + "Failed to read expected schema file {}: {}", + expected_file, e + ); + } + }; + + let actual_json: serde_json::Value = + serde_json::from_str(actual).expect("Failed to parse actual schema as JSON"); + let expected_json: serde_json::Value = + serde_json::from_str(&expected).expect("Failed to parse expected schema as JSON"); + + if actual_json == expected_json { + println!("{} schema matches expected", name); + return; + } + + // Write current schema to file for comparison + let current_file = expected_file.replace(".json", "_current.json"); + std::fs::write(¤t_file, actual).expect("Failed to write current schema"); + + println!("{} schema differs from expected", name); + println!("Expected: {}", expected_file); + println!("Current: {}", current_file); + println!( + "Run 'diff {} {}' to see differences", + expected_file, current_file + ); + + // UPDATE_SCHEMA=1 cargo test -p rmcp --test test_message_schema --features="server client schemars" + if std::env::var("UPDATE_SCHEMA").is_ok() { + println!("UPDATE_SCHEMA is set, updating expected file"); + std::fs::write(expected_file, actual).expect("Failed to update expected schema file"); + println!("Updated {}", expected_file); + } else { + println!("Set UPDATE_SCHEMA=1 to auto-update expected schemas"); + panic!("Schema validation failed"); + } + } + + #[test] + fn test_client_json_rpc_message_schema() { + let settings = SchemaSettings::draft07(); + let schema = settings + .into_generator() + .into_root_schema_for::(); + let schema_str = serde_json::to_string_pretty(&schema).expect("Failed to serialize schema"); + + compare_schemas( + "ClientJsonRpcMessage", + &schema_str, + "tests/test_message_schema/client_json_rpc_message_schema.json", + ); + } + + /// The three metadata definitions must expose the MCP 2026-07-28 + /// vocabulary: `MetaObject` is an open map, `RequestMetaObject` reserves + /// `progressToken` plus the SEP-2575 keys, and `NotificationMetaObject` + /// reserves `io.modelcontextprotocol/subscriptionId`. Version-specific + /// required keys stay optional because rmcp generates one schema shared + /// by every supported protocol version; current-version validation is + /// a runtime concern (`RequestMetaObject::missing_required_keys`). + #[test] + fn test_metadata_definitions_match_2026_07_28_schema() { + let settings = SchemaSettings::draft07(); + let schema = settings + .into_generator() + .into_root_schema_for::(); + let schema = serde_json::to_value(&schema).expect("Failed to serialize schema"); + let definitions = &schema["definitions"]; + + assert_eq!( + definitions["MetaObject"], + serde_json::json!({ + "description": "See [MCP general fields](https://modelcontextprotocol.io/specification/2026-07-28/basic#general-fields) for notes on _meta usage.", + "type": "object", + "additionalProperties": true, + }) + ); + + assert_eq!( + definitions["RequestMetaObject"], + serde_json::json!({ + "description": "Metadata reserved by MCP on requests. Extension keys are also allowed.", + "type": "object", + "properties": { + "progressToken": { "$ref": "#/definitions/ProgressToken" }, + "io.modelcontextprotocol/protocolVersion": { "type": "string" }, + "io.modelcontextprotocol/clientInfo": { "$ref": "#/definitions/Implementation" }, + "io.modelcontextprotocol/clientCapabilities": { "$ref": "#/definitions/ClientCapabilities" }, + "io.modelcontextprotocol/logLevel": { "$ref": "#/definitions/LoggingLevel" }, + }, + "additionalProperties": true, + }) + ); + + assert_eq!( + definitions["NotificationMetaObject"], + serde_json::json!({ + "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", + "type": "object", + "properties": { + "io.modelcontextprotocol/subscriptionId": { "$ref": "#/definitions/NumberOrString" }, + }, + "additionalProperties": true, + }) + ); + } + + #[test] + fn test_server_json_rpc_message_schema() { + let settings = SchemaSettings::draft07(); + let schema = settings + .into_generator() + .into_root_schema_for::(); + let schema_value = + serde_json::to_value(&schema).expect("Failed to serialize server schema"); + let discover_result = &schema_value["definitions"]["DiscoverResult"]; + assert!( + discover_result["properties"].get("serverInfo").is_none(), + "DiscoverResult serverInfo belongs in namespaced _meta" + ); + let schema_str = serde_json::to_string_pretty(&schema).expect("Failed to serialize schema"); + + compare_schemas( + "ServerJsonRpcMessage", + &schema_str, + "tests/test_message_schema/server_json_rpc_message_schema.json", + ); + } +} diff --git a/vendor/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/vendor/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json new file mode 100644 index 000000000..760d9e18c --- /dev/null +++ b/vendor/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -0,0 +1,2447 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "JsonRpcMessage", + "description": "Represents any JSON-RPC message that can be sent or received.\n\nThis enum covers all possible message types in the JSON-RPC protocol:\nindividual requests/responses, notifications, and errors.\nIt serves as the top-level message container for MCP communication.", + "anyOf": [ + { + "description": "A single request expecting a response", + "allOf": [ + { + "$ref": "#/definitions/JsonRpcRequest" + } + ] + }, + { + "description": "A response to a previous request", + "allOf": [ + { + "$ref": "#/definitions/JsonRpcResponse" + } + ] + }, + { + "description": "A one-way notification (no response expected)", + "allOf": [ + { + "$ref": "#/definitions/JsonRpcNotification" + } + ] + }, + { + "description": "An error response", + "allOf": [ + { + "$ref": "#/definitions/JsonRpcError" + } + ] + } + ], + "definitions": { + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are\nused or displayed.", + "type": "object", + "properties": { + "audience": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Role" + } + }, + "lastModified": { + "type": [ + "string", + "null" + ] + }, + "priority": { + "type": [ + "number", + "null" + ], + "format": "float" + } + } + }, + "ArgumentInfo": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ] + }, + "AudioContent": { + "description": "Audio content with base64-encoded data (spec `AudioContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "description": "The base64-encoded audio data.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the audio (e.g. `audio/wav`).", + "type": "string" + } + }, + "required": [ + "data", + "mimeType" + ] + }, + "CallToolRequestMethod": { + "type": "string", + "format": "const", + "const": "tools/call" + }, + "CallToolRequestParams": { + "description": "Parameters for calling a tool provided by an MCP server.\n\nContains the tool name and optional arguments needed to execute\nthe tool operation.", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "arguments": { + "description": "Arguments to pass to the tool (must match the tool's input schema)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "inputResponses": { + "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`]. Present only when retrying after an incomplete result.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "name": { + "description": "The name of the tool to call", + "type": "string" + }, + "requestState": { + "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].\nClients MUST return this value exactly as received.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name" + ] + }, + "CancelTaskMethod": { + "type": "string", + "format": "const", + "const": "tasks/cancel" + }, + "CancelTaskParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "taskId": { + "type": "string" + } + }, + "required": [ + "taskId" + ] + }, + "CancelledNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/cancelled" + }, + "CancelledNotificationParam": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "requestId": { + "anyOf": [ + { + "$ref": "#/definitions/NumberOrString" + }, + { + "type": "null" + } + ] + } + } + }, + "ClientCapabilities": { + "title": "Builder", + "description": "```rust\n# use rmcp::model::ClientCapabilities;\nlet cap = ClientCapabilities::builder()\n .enable_experimental()\n .build();\n```", + "type": "object", + "properties": { + "elicitation": { + "description": "Capability to handle elicitation requests from servers for interactive user input", + "anyOf": [ + { + "$ref": "#/definitions/ElicitationCapability" + }, + { + "type": "null" + } + ] + }, + "experimental": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "extensions": { + "description": "Optional MCP extensions that the client supports (SEP-1724).\nKeys are extension identifiers (e.g., `\"io.modelcontextprotocol/ui\"`),\nvalues are per-extension settings objects. An empty object indicates\nsupport with no settings.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "roots": { + "description": "Capability for filesystem roots (deprecated by SEP-2577).", + "anyOf": [ + { + "$ref": "#/definitions/RootsCapabilities" + }, + { + "type": "null" + } + ] + }, + "sampling": { + "description": "Capability for LLM sampling requests (SEP-1577, deprecated by SEP-2577).", + "anyOf": [ + { + "$ref": "#/definitions/SamplingCapability" + }, + { + "type": "null" + } + ] + } + } + }, + "ClientResult": { + "anyOf": [ + { + "$ref": "#/definitions/CreateMessageResult" + }, + { + "$ref": "#/definitions/ListRootsResult" + }, + { + "$ref": "#/definitions/ElicitResult" + }, + { + "$ref": "#/definitions/EmptyObject" + }, + { + "$ref": "#/definitions/CustomResult" + } + ] + }, + "CompleteRequestMethod": { + "type": "string", + "format": "const", + "const": "completion/complete" + }, + "CompleteRequestParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "argument": { + "$ref": "#/definitions/ArgumentInfo" + }, + "context": { + "description": "Optional context containing previously resolved argument values", + "anyOf": [ + { + "$ref": "#/definitions/CompletionContext" + }, + { + "type": "null" + } + ] + }, + "ref": { + "$ref": "#/definitions/Reference" + } + }, + "required": [ + "ref", + "argument" + ] + }, + "CompletionContext": { + "description": "Context for completion requests providing previously resolved arguments.\n\nThis enables context-aware completion where subsequent argument completions\ncan take into account the values of previously resolved arguments.", + "type": "object", + "properties": { + "arguments": { + "description": "Previously resolved argument values that can inform completion suggestions", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + } + } + } + }, + "ContentBlock": { + "description": "Unified content block union (spec `ContentBlock`).\n\n`text | image | audio | resource_link | resource`", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "allOf": [ + { + "$ref": "#/definitions/TextContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ImageContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "audio" + } + }, + "allOf": [ + { + "$ref": "#/definitions/AudioContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource" + } + }, + "allOf": [ + { + "$ref": "#/definitions/EmbeddedResource" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource_link" + } + }, + "allOf": [ + { + "$ref": "#/definitions/Resource" + } + ], + "required": [ + "type" + ] + } + ] + }, + "CreateMessageResult": { + "description": "Result of sampling/createMessage (SEP-1577).\nThe result of a sampling/createMessage request containing the generated response.\n\nThis structure contains the generated message along with metadata about\nhow the generation was performed and why it stopped.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "content": { + "description": "The actual content of the message (text, image, audio, tool use, or tool result)", + "allOf": [ + { + "$ref": "#/definitions/SamplingContent" + } + ] + }, + "model": { + "description": "The identifier of the model that generated the response", + "type": "string" + }, + "role": { + "description": "The role of the message sender (User or Assistant)", + "allOf": [ + { + "$ref": "#/definitions/Role" + } + ] + }, + "stopReason": { + "description": "The reason why generation stopped (e.g., \"endTurn\", \"maxTokens\")", + "type": [ + "string", + "null" + ] + } + }, + "deprecated": true, + "required": [ + "model", + "role", + "content" + ] + }, + "CustomNotification": { + "description": "A catch-all notification either side can use to send custom messages to its peer.\n\nThis preserves the raw `method` name and `params` payload so handlers can\ndeserialize them into domain-specific types.", + "type": "object", + "properties": { + "method": { + "type": "string" + }, + "params": true + }, + "required": [ + "method" + ] + }, + "CustomRequest": { + "description": "A catch-all request either side can use to send custom messages to its peer.\n\nThis preserves the raw `method` name and `params` payload so handlers can\ndeserialize them into domain-specific types.", + "type": "object", + "properties": { + "method": { + "type": "string" + }, + "params": true + }, + "required": [ + "method" + ] + }, + "CustomResult": { + "description": "A catch-all response either side can use for custom requests." + }, + "DiscoverRequestMethod": { + "type": "string", + "format": "const", + "const": "server/discover" + }, + "DiscoverRequestParams": { + "type": "object", + "properties": { + "_meta": { + "$ref": "#/definitions/RequestMetaObject" + } + }, + "required": [ + "_meta" + ] + }, + "ElicitResult": { + "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this result.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "action": { + "description": "The user's decision on how to handle the elicitation request", + "allOf": [ + { + "$ref": "#/definitions/ElicitationAction" + } + ] + }, + "content": { + "description": "The actual data provided by the user, if they accepted the request.\nMust conform to the JSON schema specified in the original request.\nOnly present when action is Accept." + } + }, + "required": [ + "action" + ] + }, + "ElicitationAction": { + "description": "Represents the possible actions a user can take in response to an elicitation request.\n\nWhen a server requests user input through elicitation, the user can:\n- Accept: Provide the requested information and continue\n- Decline: Refuse to provide the information but continue the operation\n- Cancel: Stop the entire operation", + "oneOf": [ + { + "description": "User accepts the request and provides the requested information", + "type": "string", + "const": "accept" + }, + { + "description": "User declines to provide the information but allows the operation to continue", + "type": "string", + "const": "decline" + }, + { + "description": "User cancels the entire operation", + "type": "string", + "const": "cancel" + } + ] + }, + "ElicitationCapability": { + "description": "Elicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.", + "type": "object", + "properties": { + "form": { + "description": "Whether client supports form-based elicitation.", + "anyOf": [ + { + "$ref": "#/definitions/FormElicitationCapability" + }, + { + "type": "null" + } + ] + }, + "url": { + "description": "Whether client supports URL-based elicitation.", + "anyOf": [ + { + "$ref": "#/definitions/UrlElicitationCapability" + }, + { + "type": "null" + } + ] + } + } + }, + "EmbeddedResource": { + "description": "Embedded resource content (spec `EmbeddedResource`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "resource": { + "description": "The embedded resource contents (text or blob).", + "allOf": [ + { + "$ref": "#/definitions/ResourceContents" + } + ] + } + }, + "required": [ + "resource" + ] + }, + "EmptyObject": { + "description": "This is commonly used for representing empty objects in MCP messages.\n\nwithout returning any specific data.", + "type": "object", + "additionalProperties": false + }, + "ErrorCode": { + "description": "Standard JSON-RPC error codes used throughout the MCP protocol.\n\nThese codes follow the JSON-RPC 2.0 specification and provide\nstandardized error reporting across all MCP implementations.", + "type": "integer", + "format": "int32" + }, + "ErrorData": { + "description": "Error information for JSON-RPC error responses.\n\nThis structure follows the JSON-RPC 2.0 specification for error reporting,\nproviding a standardized way to communicate errors between clients and servers.", + "type": "object", + "properties": { + "code": { + "description": "The error type that occurred (using standard JSON-RPC error codes)", + "allOf": [ + { + "$ref": "#/definitions/ErrorCode" + } + ] + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the\nsender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ] + }, + "FormElicitationCapability": { + "description": "Capability for handling elicitation requests from servers.\nElicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.\n\nCapability for form mode elicitation.", + "type": "object", + "properties": { + "schemaValidation": { + "description": "Whether the client supports JSON Schema validation for elicitation responses.\nWhen true, the client will validate user input against the requested_schema\nbefore sending the response back to the server.", + "type": [ + "boolean", + "null" + ] + } + } + }, + "GetPromptRequestMethod": { + "type": "string", + "format": "const", + "const": "prompts/get" + }, + "GetPromptRequestParams": { + "description": "Parameters for retrieving a specific prompt", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "arguments": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "inputResponses": { + "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`].", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "name": { + "type": "string" + }, + "requestState": { + "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name" + ] + }, + "GetTaskMethod": { + "type": "string", + "format": "const", + "const": "tasks/get" + }, + "GetTaskParams": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "taskId": { + "description": "Identifier of the task to query.", + "type": "string" + } + }, + "required": [ + "taskId" + ] + }, + "Icon": { + "description": "A URL pointing to an icon resource or a base64-encoded data URI.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- image/png - PNG images (safe, universal compatibility)\n- image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- image/svg+xml - SVG images (scalable but requires security precautions)\n- image/webp - WebP images (modern, efficient format)", + "type": "object", + "properties": { + "mimeType": { + "description": "Optional override if the server's MIME type is missing or generic", + "type": [ + "string", + "null" + ] + }, + "sizes": { + "description": "Size specification, each string should be in WxH format (e.g., `\\\"48x48\\\"`, `\\\"96x96\\\"`) or `\\\"any\\\"` for scalable formats like SVG", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "src": { + "description": "A standard URI pointing to an icon resource", + "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for\nIf not provided, the client should assume the icon can be used with any theme.", + "anyOf": [ + { + "$ref": "#/definitions/IconTheme" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "src" + ] + }, + "IconTheme": { + "description": "Icon themes supported by the MCP specification", + "oneOf": [ + { + "description": "Indicates the icon is designed to be used with a light background", + "type": "string", + "const": "light" + }, + { + "description": "Indicates the icon is designed to be used with a dark background", + "type": "string", + "const": "dark" + } + ] + }, + "ImageContent": { + "description": "Image content with base64-encoded data (spec `ImageContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "description": "The base64-encoded image data.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the image (e.g. `image/png`).", + "type": "string" + } + }, + "required": [ + "data", + "mimeType" + ] + }, + "Implementation": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "version" + ] + }, + "InitializeRequestParams": { + "description": "Parameters sent by a client when initializing a connection to an MCP server.\n\nThis contains the client's protocol version, capabilities, and implementation\ninformation, allowing the server to understand what the client supports.", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "capabilities": { + "description": "The capabilities this client supports (sampling, roots, etc.)", + "allOf": [ + { + "$ref": "#/definitions/ClientCapabilities" + } + ] + }, + "clientInfo": { + "description": "Information about the client implementation", + "allOf": [ + { + "$ref": "#/definitions/Implementation" + } + ] + }, + "protocolVersion": { + "description": "The MCP protocol version this client supports", + "allOf": [ + { + "$ref": "#/definitions/ProtocolVersion" + } + ] + } + }, + "required": [ + "protocolVersion", + "capabilities", + "clientInfo" + ] + }, + "InitializeResultMethod": { + "type": "string", + "format": "const", + "const": "initialize" + }, + "InitializedNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/initialized" + }, + "JsonRpcError": { + "type": "object", + "properties": { + "error": { + "$ref": "#/definitions/ErrorData" + }, + "id": { + "anyOf": [ + { + "$ref": "#/definitions/NumberOrString" + }, + { + "type": "null" + } + ] + }, + "jsonrpc": { + "$ref": "#/definitions/JsonRpcVersion2_0" + } + }, + "required": [ + "jsonrpc", + "error" + ] + }, + "JsonRpcNotification": { + "type": "object", + "properties": { + "jsonrpc": { + "$ref": "#/definitions/JsonRpcVersion2_0" + } + }, + "anyOf": [ + { + "$ref": "#/definitions/Notification" + }, + { + "$ref": "#/definitions/Notification2" + }, + { + "$ref": "#/definitions/NotificationNoParam" + }, + { + "$ref": "#/definitions/NotificationNoParam2" + }, + { + "$ref": "#/definitions/CustomNotification" + } + ], + "required": [ + "jsonrpc" + ] + }, + "JsonRpcRequest": { + "type": "object", + "properties": { + "id": { + "$ref": "#/definitions/NumberOrString" + }, + "jsonrpc": { + "$ref": "#/definitions/JsonRpcVersion2_0" + } + }, + "anyOf": [ + { + "$ref": "#/definitions/RequestNoParam" + }, + { + "$ref": "#/definitions/Request" + }, + { + "$ref": "#/definitions/Request2" + }, + { + "$ref": "#/definitions/Request3" + }, + { + "$ref": "#/definitions/Request4" + }, + { + "$ref": "#/definitions/Request5" + }, + { + "$ref": "#/definitions/RequestOptionalParam" + }, + { + "$ref": "#/definitions/RequestOptionalParam2" + }, + { + "$ref": "#/definitions/RequestOptionalParam3" + }, + { + "$ref": "#/definitions/Request6" + }, + { + "$ref": "#/definitions/Request7" + }, + { + "$ref": "#/definitions/Request8" + }, + { + "$ref": "#/definitions/Request9" + }, + { + "$ref": "#/definitions/Request10" + }, + { + "$ref": "#/definitions/RequestOptionalParam4" + }, + { + "$ref": "#/definitions/Request11" + }, + { + "$ref": "#/definitions/Request12" + }, + { + "$ref": "#/definitions/Request13" + }, + { + "$ref": "#/definitions/CustomRequest" + } + ], + "required": [ + "jsonrpc", + "id" + ] + }, + "JsonRpcResponse": { + "type": "object", + "properties": { + "id": { + "$ref": "#/definitions/NumberOrString" + }, + "jsonrpc": { + "$ref": "#/definitions/JsonRpcVersion2_0" + }, + "result": { + "$ref": "#/definitions/ClientResult" + } + }, + "required": [ + "jsonrpc", + "id", + "result" + ] + }, + "JsonRpcVersion2_0": { + "type": "string", + "format": "const", + "const": "2.0" + }, + "ListPromptsRequestMethod": { + "type": "string", + "format": "const", + "const": "prompts/list" + }, + "ListResourceTemplatesRequestMethod": { + "type": "string", + "format": "const", + "const": "resources/templates/list" + }, + "ListResourcesRequestMethod": { + "type": "string", + "format": "const", + "const": "resources/list" + }, + "ListRootsResult": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "roots": { + "type": "array", + "items": { + "$ref": "#/definitions/Root" + } + } + }, + "deprecated": true, + "required": [ + "roots" + ] + }, + "ListToolsRequestMethod": { + "type": "string", + "format": "const", + "const": "tools/list" + }, + "LoggingLevel": { + "description": "Logging levels supported by the MCP protocol", + "type": "string", + "deprecated": true, + "enum": [ + "debug", + "info", + "notice", + "warning", + "error", + "critical", + "alert", + "emergency" + ] + }, + "MetaObject": { + "description": "See [MCP general fields](https://modelcontextprotocol.io/specification/2026-07-28/basic#general-fields) for notes on _meta usage.", + "type": "object", + "additionalProperties": true + }, + "Notification": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/CancelledNotificationMethod" + }, + "params": { + "$ref": "#/definitions/CancelledNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, + "Notification2": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ProgressNotificationMethod" + }, + "params": { + "$ref": "#/definitions/ProgressNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, + "NotificationMetaObject": { + "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", + "type": "object", + "properties": { + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/definitions/NumberOrString" + } + }, + "additionalProperties": true + }, + "NotificationNoParam": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/InitializedNotificationMethod" + } + }, + "required": [ + "method" + ] + }, + "NotificationNoParam2": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/RootsListChangedNotificationMethod" + } + }, + "required": [ + "method" + ] + }, + "NumberOrString": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "PaginatedRequestParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "cursor": { + "type": [ + "string", + "null" + ] + } + } + }, + "PingRequestMethod": { + "type": "string", + "format": "const", + "const": "ping" + }, + "ProgressNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/progress" + }, + "ProgressNotificationParam": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] + }, + "message": { + "description": "An optional message describing the current progress.", + "type": [ + "string", + "null" + ] + }, + "progress": { + "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", + "type": "number", + "format": "double" + }, + "progressToken": { + "$ref": "#/definitions/ProgressToken" + }, + "total": { + "description": "Total number of items to process (or total progress required), if known", + "type": [ + "number", + "null" + ], + "format": "double" + } + }, + "required": [ + "progressToken", + "progress" + ] + }, + "ProgressToken": { + "description": "A token used to track the progress of long-running operations.\n\nProgress tokens allow clients and servers to associate progress notifications\nwith specific requests, enabling real-time updates on operation status.", + "allOf": [ + { + "$ref": "#/definitions/NumberOrString" + } + ] + }, + "PromptReference": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name" + ] + }, + "ProtocolVersion": { + "description": "Represents the MCP protocol version used for communication.\n\nThis ensures compatibility between clients and servers by specifying\nwhich version of the Model Context Protocol is being used.", + "type": "string" + }, + "ReadResourceRequestMethod": { + "type": "string", + "format": "const", + "const": "resources/read" + }, + "ReadResourceRequestParams": { + "description": "Parameters for reading a specific resource", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "inputResponses": { + "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`].", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "requestState": { + "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].", + "type": [ + "string", + "null" + ] + }, + "uri": { + "description": "The URI of the resource to read", + "type": "string" + } + }, + "required": [ + "uri" + ] + }, + "Reference": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "ref/resource" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ResourceTemplateReference" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "ref/prompt" + } + }, + "allOf": [ + { + "$ref": "#/definitions/PromptReference" + } + ], + "required": [ + "type" + ] + } + ] + }, + "Request": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/InitializeResultMethod" + }, + "params": { + "$ref": "#/definitions/InitializeRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request10": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/CallToolRequestMethod" + }, + "params": { + "$ref": "#/definitions/CallToolRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request11": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/GetTaskMethod" + }, + "params": { + "$ref": "#/definitions/GetTaskParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request12": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/UpdateTaskMethod" + }, + "params": { + "$ref": "#/definitions/UpdateTaskParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request13": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/CancelTaskMethod" + }, + "params": { + "$ref": "#/definitions/CancelTaskParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request2": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/DiscoverRequestMethod" + }, + "params": { + "$ref": "#/definitions/DiscoverRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request3": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/CompleteRequestMethod" + }, + "params": { + "$ref": "#/definitions/CompleteRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request4": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/SetLevelRequestMethod" + }, + "params": { + "$ref": "#/definitions/SetLevelRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request5": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/GetPromptRequestMethod" + }, + "params": { + "$ref": "#/definitions/GetPromptRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request6": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ReadResourceRequestMethod" + }, + "params": { + "$ref": "#/definitions/ReadResourceRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request7": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/SubscriptionsListenRequestMethod" + }, + "params": { + "$ref": "#/definitions/SubscriptionsListenRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request8": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/SubscribeRequestMethod" + }, + "params": { + "$ref": "#/definitions/SubscribeRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request9": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/UnsubscribeRequestMethod" + }, + "params": { + "$ref": "#/definitions/UnsubscribeRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "RequestMetaObject": { + "description": "Metadata reserved by MCP on requests. Extension keys are also allowed.", + "type": "object", + "properties": { + "io.modelcontextprotocol/clientCapabilities": { + "$ref": "#/definitions/ClientCapabilities" + }, + "io.modelcontextprotocol/clientInfo": { + "$ref": "#/definitions/Implementation" + }, + "io.modelcontextprotocol/logLevel": { + "$ref": "#/definitions/LoggingLevel" + }, + "io.modelcontextprotocol/protocolVersion": { + "type": "string" + }, + "progressToken": { + "$ref": "#/definitions/ProgressToken" + } + }, + "additionalProperties": true + }, + "RequestNoParam": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/PingRequestMethod" + } + }, + "required": [ + "method" + ] + }, + "RequestOptionalParam": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ListPromptsRequestMethod" + }, + "params": { + "anyOf": [ + { + "$ref": "#/definitions/PaginatedRequestParams" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "method" + ] + }, + "RequestOptionalParam2": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ListResourcesRequestMethod" + }, + "params": { + "anyOf": [ + { + "$ref": "#/definitions/PaginatedRequestParams" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "method" + ] + }, + "RequestOptionalParam3": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ListResourceTemplatesRequestMethod" + }, + "params": { + "anyOf": [ + { + "$ref": "#/definitions/PaginatedRequestParams" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "method" + ] + }, + "RequestOptionalParam4": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ListToolsRequestMethod" + }, + "params": { + "anyOf": [ + { + "$ref": "#/definitions/PaginatedRequestParams" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "method" + ] + }, + "Resource": { + "description": "A known resource that the server is capable of reading (spec `Resource`).\n\nAlso used as the inner type of `ContentBlock::ResourceLink` (spec `ResourceLink extends Resource`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this resource.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this resource.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "description": "Optional description of what this resource represents.", + "type": [ + "string", + "null" + ] + }, + "icons": { + "description": "Optional set of icons the client may display for this resource.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "The programmatic name of the resource.", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content in bytes (before base64/tokenization), if known.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "title": { + "description": "Optional human-readable display title.", + "type": [ + "string", + "null" + ] + }, + "uri": { + "description": "The URI of this resource (e.g. `file:///path/to/file`).", + "type": "string" + } + }, + "required": [ + "uri", + "name" + ] + }, + "ResourceContents": { + "description": "The contents of a specific resource or sub-resource.", + "anyOf": [ + { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "uri", + "text" + ] + }, + { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "blob": { + "type": "string" + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "uri", + "blob" + ] + } + ] + }, + "ResourceTemplateReference": { + "type": "object", + "properties": { + "uri": { + "type": "string" + } + }, + "required": [ + "uri" + ] + }, + "Role": { + "description": "Represents the role of a participant in a conversation or message exchange.\n\nUsed in sampling and chat contexts to distinguish between different\ntypes of message senders in the conversation flow.", + "oneOf": [ + { + "description": "A human user or client making a request", + "type": "string", + "const": "user" + }, + { + "description": "An AI assistant or server providing a response", + "type": "string", + "const": "assistant" + } + ] + }, + "Root": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": "string" + } + }, + "deprecated": true, + "required": [ + "uri" + ] + }, + "RootsCapabilities": { + "description": "Roots capability. Deprecated by SEP-2577; remains functional and will be\nremoved in a future release.\nSee .", + "type": "object", + "properties": { + "listChanged": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "RootsListChangedNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/roots/list_changed" + }, + "SamplingCapability": { + "description": "Sampling capability with optional sub-capabilities (SEP-1577).\n\nDeprecated by SEP-2577; remains functional and will be removed in a future\nrelease.\nSee .", + "type": "object", + "properties": { + "context": { + "description": "Support for `includeContext` (soft-deprecated)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "tools": { + "description": "Support for `tools` and `toolChoice` parameters", + "type": [ + "object", + "null" + ], + "additionalProperties": true + } + } + }, + "SamplingContent": { + "description": "Single or array content wrapper (SEP-1577).", + "anyOf": [ + { + "$ref": "#/definitions/SamplingMessageContentBlock" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/SamplingMessageContentBlock" + } + } + ] + }, + "SamplingMessageContentBlock": { + "description": "Content types for sampling messages (SEP-1577).", + "deprecated": true, + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "allOf": [ + { + "$ref": "#/definitions/TextContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ImageContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "audio" + } + }, + "allOf": [ + { + "$ref": "#/definitions/AudioContent" + } + ], + "required": [ + "type" + ] + }, + { + "description": "Assistant only", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_use" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ToolUseContent" + } + ], + "required": [ + "type" + ] + }, + { + "description": "User only", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_result" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ToolResultContent" + } + ], + "required": [ + "type" + ] + } + ] + }, + "SetLevelRequestMethod": { + "type": "string", + "format": "const", + "const": "logging/setLevel" + }, + "SetLevelRequestParams": { + "description": "Parameters for setting the logging level", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "level": { + "description": "The desired logging level", + "allOf": [ + { + "$ref": "#/definitions/LoggingLevel" + } + ] + } + }, + "deprecated": true, + "required": [ + "level" + ] + }, + "SubscribeRequestMethod": { + "type": "string", + "format": "const", + "const": "resources/subscribe" + }, + "SubscribeRequestParams": { + "description": "Parameters for subscribing to resource updates", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "uri": { + "description": "The URI of the resource to subscribe to", + "type": "string" + } + }, + "required": [ + "uri" + ] + }, + "SubscriptionFilter": { + "description": "Notification categories a client opts in to on a `subscriptions/listen` stream.", + "type": "object", + "properties": { + "promptsListChanged": { + "type": "boolean" + }, + "resourceSubscriptions": { + "type": "array", + "items": { + "type": "string" + } + }, + "resourcesListChanged": { + "type": "boolean" + }, + "toolsListChanged": { + "type": "boolean" + } + } + }, + "SubscriptionsListenRequestMethod": { + "type": "string", + "format": "const", + "const": "subscriptions/listen" + }, + "SubscriptionsListenRequestParams": { + "description": "Parameters for opening a long-lived notification subscription.", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata. Required by the 2026-07-28 wire schema.", + "type": "object", + "properties": { + "io.modelcontextprotocol/clientCapabilities": { + "$ref": "#/definitions/ClientCapabilities" + }, + "io.modelcontextprotocol/clientInfo": { + "$ref": "#/definitions/Implementation" + }, + "io.modelcontextprotocol/logLevel": { + "$ref": "#/definitions/LoggingLevel" + }, + "io.modelcontextprotocol/protocolVersion": { + "type": "string" + }, + "progressToken": { + "$ref": "#/definitions/ProgressToken" + } + }, + "additionalProperties": true, + "required": [ + "io.modelcontextprotocol/protocolVersion", + "io.modelcontextprotocol/clientCapabilities" + ] + }, + "notifications": { + "description": "Notification categories requested for this stream.", + "allOf": [ + { + "$ref": "#/definitions/SubscriptionFilter" + } + ] + } + }, + "required": [ + "_meta", + "notifications" + ] + }, + "TextContent": { + "description": "Text content block (spec `TextContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "text": { + "description": "The text content of the message.", + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "ToolResultContent": { + "description": "Tool execution result in user message (SEP-1577).", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/ContentBlock" + } + }, + "isError": { + "type": [ + "boolean", + "null" + ] + }, + "structuredContent": true, + "toolUseId": { + "type": "string" + } + }, + "deprecated": true, + "required": [ + "toolUseId", + "content" + ] + }, + "ToolUseContent": { + "description": "Tool call request from assistant (SEP-1577).", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "input": { + "type": "object", + "additionalProperties": true + }, + "name": { + "type": "string" + } + }, + "deprecated": true, + "required": [ + "id", + "name", + "input" + ] + }, + "UnsubscribeRequestMethod": { + "type": "string", + "format": "const", + "const": "resources/unsubscribe" + }, + "UnsubscribeRequestParams": { + "description": "Parameters for unsubscribing from resource updates", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "uri": { + "description": "The URI of the resource to unsubscribe from", + "type": "string" + } + }, + "required": [ + "uri" + ] + }, + "UpdateTaskMethod": { + "type": "string", + "format": "const", + "const": "tasks/update" + }, + "UpdateTaskParams": { + "description": "Parameters for `tasks/update` (SEP-2663): deliver responses to outstanding\nin-task server-to-client requests surfaced via `tasks/get` `inputRequests`.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "inputResponses": { + "description": "Responses to outstanding `inputRequests` previously surfaced by the\nserver. Each key MUST correspond to a currently-outstanding\n`inputRequests` key.", + "type": "object", + "additionalProperties": true + }, + "taskId": { + "description": "Identifier of the task to update.", + "type": "string" + } + }, + "required": [ + "taskId", + "inputResponses" + ] + }, + "UrlElicitationCapability": { + "description": "Capability for URL mode elicitation.", + "type": "object" + } + } +} \ No newline at end of file diff --git a/vendor/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/vendor/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json new file mode 100644 index 000000000..760d9e18c --- /dev/null +++ b/vendor/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -0,0 +1,2447 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "JsonRpcMessage", + "description": "Represents any JSON-RPC message that can be sent or received.\n\nThis enum covers all possible message types in the JSON-RPC protocol:\nindividual requests/responses, notifications, and errors.\nIt serves as the top-level message container for MCP communication.", + "anyOf": [ + { + "description": "A single request expecting a response", + "allOf": [ + { + "$ref": "#/definitions/JsonRpcRequest" + } + ] + }, + { + "description": "A response to a previous request", + "allOf": [ + { + "$ref": "#/definitions/JsonRpcResponse" + } + ] + }, + { + "description": "A one-way notification (no response expected)", + "allOf": [ + { + "$ref": "#/definitions/JsonRpcNotification" + } + ] + }, + { + "description": "An error response", + "allOf": [ + { + "$ref": "#/definitions/JsonRpcError" + } + ] + } + ], + "definitions": { + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are\nused or displayed.", + "type": "object", + "properties": { + "audience": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Role" + } + }, + "lastModified": { + "type": [ + "string", + "null" + ] + }, + "priority": { + "type": [ + "number", + "null" + ], + "format": "float" + } + } + }, + "ArgumentInfo": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ] + }, + "AudioContent": { + "description": "Audio content with base64-encoded data (spec `AudioContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "description": "The base64-encoded audio data.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the audio (e.g. `audio/wav`).", + "type": "string" + } + }, + "required": [ + "data", + "mimeType" + ] + }, + "CallToolRequestMethod": { + "type": "string", + "format": "const", + "const": "tools/call" + }, + "CallToolRequestParams": { + "description": "Parameters for calling a tool provided by an MCP server.\n\nContains the tool name and optional arguments needed to execute\nthe tool operation.", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "arguments": { + "description": "Arguments to pass to the tool (must match the tool's input schema)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "inputResponses": { + "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`]. Present only when retrying after an incomplete result.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "name": { + "description": "The name of the tool to call", + "type": "string" + }, + "requestState": { + "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].\nClients MUST return this value exactly as received.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name" + ] + }, + "CancelTaskMethod": { + "type": "string", + "format": "const", + "const": "tasks/cancel" + }, + "CancelTaskParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "taskId": { + "type": "string" + } + }, + "required": [ + "taskId" + ] + }, + "CancelledNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/cancelled" + }, + "CancelledNotificationParam": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "requestId": { + "anyOf": [ + { + "$ref": "#/definitions/NumberOrString" + }, + { + "type": "null" + } + ] + } + } + }, + "ClientCapabilities": { + "title": "Builder", + "description": "```rust\n# use rmcp::model::ClientCapabilities;\nlet cap = ClientCapabilities::builder()\n .enable_experimental()\n .build();\n```", + "type": "object", + "properties": { + "elicitation": { + "description": "Capability to handle elicitation requests from servers for interactive user input", + "anyOf": [ + { + "$ref": "#/definitions/ElicitationCapability" + }, + { + "type": "null" + } + ] + }, + "experimental": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "extensions": { + "description": "Optional MCP extensions that the client supports (SEP-1724).\nKeys are extension identifiers (e.g., `\"io.modelcontextprotocol/ui\"`),\nvalues are per-extension settings objects. An empty object indicates\nsupport with no settings.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "roots": { + "description": "Capability for filesystem roots (deprecated by SEP-2577).", + "anyOf": [ + { + "$ref": "#/definitions/RootsCapabilities" + }, + { + "type": "null" + } + ] + }, + "sampling": { + "description": "Capability for LLM sampling requests (SEP-1577, deprecated by SEP-2577).", + "anyOf": [ + { + "$ref": "#/definitions/SamplingCapability" + }, + { + "type": "null" + } + ] + } + } + }, + "ClientResult": { + "anyOf": [ + { + "$ref": "#/definitions/CreateMessageResult" + }, + { + "$ref": "#/definitions/ListRootsResult" + }, + { + "$ref": "#/definitions/ElicitResult" + }, + { + "$ref": "#/definitions/EmptyObject" + }, + { + "$ref": "#/definitions/CustomResult" + } + ] + }, + "CompleteRequestMethod": { + "type": "string", + "format": "const", + "const": "completion/complete" + }, + "CompleteRequestParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "argument": { + "$ref": "#/definitions/ArgumentInfo" + }, + "context": { + "description": "Optional context containing previously resolved argument values", + "anyOf": [ + { + "$ref": "#/definitions/CompletionContext" + }, + { + "type": "null" + } + ] + }, + "ref": { + "$ref": "#/definitions/Reference" + } + }, + "required": [ + "ref", + "argument" + ] + }, + "CompletionContext": { + "description": "Context for completion requests providing previously resolved arguments.\n\nThis enables context-aware completion where subsequent argument completions\ncan take into account the values of previously resolved arguments.", + "type": "object", + "properties": { + "arguments": { + "description": "Previously resolved argument values that can inform completion suggestions", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + } + } + } + }, + "ContentBlock": { + "description": "Unified content block union (spec `ContentBlock`).\n\n`text | image | audio | resource_link | resource`", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "allOf": [ + { + "$ref": "#/definitions/TextContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ImageContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "audio" + } + }, + "allOf": [ + { + "$ref": "#/definitions/AudioContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource" + } + }, + "allOf": [ + { + "$ref": "#/definitions/EmbeddedResource" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource_link" + } + }, + "allOf": [ + { + "$ref": "#/definitions/Resource" + } + ], + "required": [ + "type" + ] + } + ] + }, + "CreateMessageResult": { + "description": "Result of sampling/createMessage (SEP-1577).\nThe result of a sampling/createMessage request containing the generated response.\n\nThis structure contains the generated message along with metadata about\nhow the generation was performed and why it stopped.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "content": { + "description": "The actual content of the message (text, image, audio, tool use, or tool result)", + "allOf": [ + { + "$ref": "#/definitions/SamplingContent" + } + ] + }, + "model": { + "description": "The identifier of the model that generated the response", + "type": "string" + }, + "role": { + "description": "The role of the message sender (User or Assistant)", + "allOf": [ + { + "$ref": "#/definitions/Role" + } + ] + }, + "stopReason": { + "description": "The reason why generation stopped (e.g., \"endTurn\", \"maxTokens\")", + "type": [ + "string", + "null" + ] + } + }, + "deprecated": true, + "required": [ + "model", + "role", + "content" + ] + }, + "CustomNotification": { + "description": "A catch-all notification either side can use to send custom messages to its peer.\n\nThis preserves the raw `method` name and `params` payload so handlers can\ndeserialize them into domain-specific types.", + "type": "object", + "properties": { + "method": { + "type": "string" + }, + "params": true + }, + "required": [ + "method" + ] + }, + "CustomRequest": { + "description": "A catch-all request either side can use to send custom messages to its peer.\n\nThis preserves the raw `method` name and `params` payload so handlers can\ndeserialize them into domain-specific types.", + "type": "object", + "properties": { + "method": { + "type": "string" + }, + "params": true + }, + "required": [ + "method" + ] + }, + "CustomResult": { + "description": "A catch-all response either side can use for custom requests." + }, + "DiscoverRequestMethod": { + "type": "string", + "format": "const", + "const": "server/discover" + }, + "DiscoverRequestParams": { + "type": "object", + "properties": { + "_meta": { + "$ref": "#/definitions/RequestMetaObject" + } + }, + "required": [ + "_meta" + ] + }, + "ElicitResult": { + "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this result.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "action": { + "description": "The user's decision on how to handle the elicitation request", + "allOf": [ + { + "$ref": "#/definitions/ElicitationAction" + } + ] + }, + "content": { + "description": "The actual data provided by the user, if they accepted the request.\nMust conform to the JSON schema specified in the original request.\nOnly present when action is Accept." + } + }, + "required": [ + "action" + ] + }, + "ElicitationAction": { + "description": "Represents the possible actions a user can take in response to an elicitation request.\n\nWhen a server requests user input through elicitation, the user can:\n- Accept: Provide the requested information and continue\n- Decline: Refuse to provide the information but continue the operation\n- Cancel: Stop the entire operation", + "oneOf": [ + { + "description": "User accepts the request and provides the requested information", + "type": "string", + "const": "accept" + }, + { + "description": "User declines to provide the information but allows the operation to continue", + "type": "string", + "const": "decline" + }, + { + "description": "User cancels the entire operation", + "type": "string", + "const": "cancel" + } + ] + }, + "ElicitationCapability": { + "description": "Elicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.", + "type": "object", + "properties": { + "form": { + "description": "Whether client supports form-based elicitation.", + "anyOf": [ + { + "$ref": "#/definitions/FormElicitationCapability" + }, + { + "type": "null" + } + ] + }, + "url": { + "description": "Whether client supports URL-based elicitation.", + "anyOf": [ + { + "$ref": "#/definitions/UrlElicitationCapability" + }, + { + "type": "null" + } + ] + } + } + }, + "EmbeddedResource": { + "description": "Embedded resource content (spec `EmbeddedResource`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "resource": { + "description": "The embedded resource contents (text or blob).", + "allOf": [ + { + "$ref": "#/definitions/ResourceContents" + } + ] + } + }, + "required": [ + "resource" + ] + }, + "EmptyObject": { + "description": "This is commonly used for representing empty objects in MCP messages.\n\nwithout returning any specific data.", + "type": "object", + "additionalProperties": false + }, + "ErrorCode": { + "description": "Standard JSON-RPC error codes used throughout the MCP protocol.\n\nThese codes follow the JSON-RPC 2.0 specification and provide\nstandardized error reporting across all MCP implementations.", + "type": "integer", + "format": "int32" + }, + "ErrorData": { + "description": "Error information for JSON-RPC error responses.\n\nThis structure follows the JSON-RPC 2.0 specification for error reporting,\nproviding a standardized way to communicate errors between clients and servers.", + "type": "object", + "properties": { + "code": { + "description": "The error type that occurred (using standard JSON-RPC error codes)", + "allOf": [ + { + "$ref": "#/definitions/ErrorCode" + } + ] + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the\nsender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ] + }, + "FormElicitationCapability": { + "description": "Capability for handling elicitation requests from servers.\nElicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.\n\nCapability for form mode elicitation.", + "type": "object", + "properties": { + "schemaValidation": { + "description": "Whether the client supports JSON Schema validation for elicitation responses.\nWhen true, the client will validate user input against the requested_schema\nbefore sending the response back to the server.", + "type": [ + "boolean", + "null" + ] + } + } + }, + "GetPromptRequestMethod": { + "type": "string", + "format": "const", + "const": "prompts/get" + }, + "GetPromptRequestParams": { + "description": "Parameters for retrieving a specific prompt", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "arguments": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "inputResponses": { + "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`].", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "name": { + "type": "string" + }, + "requestState": { + "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name" + ] + }, + "GetTaskMethod": { + "type": "string", + "format": "const", + "const": "tasks/get" + }, + "GetTaskParams": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "taskId": { + "description": "Identifier of the task to query.", + "type": "string" + } + }, + "required": [ + "taskId" + ] + }, + "Icon": { + "description": "A URL pointing to an icon resource or a base64-encoded data URI.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- image/png - PNG images (safe, universal compatibility)\n- image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- image/svg+xml - SVG images (scalable but requires security precautions)\n- image/webp - WebP images (modern, efficient format)", + "type": "object", + "properties": { + "mimeType": { + "description": "Optional override if the server's MIME type is missing or generic", + "type": [ + "string", + "null" + ] + }, + "sizes": { + "description": "Size specification, each string should be in WxH format (e.g., `\\\"48x48\\\"`, `\\\"96x96\\\"`) or `\\\"any\\\"` for scalable formats like SVG", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "src": { + "description": "A standard URI pointing to an icon resource", + "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for\nIf not provided, the client should assume the icon can be used with any theme.", + "anyOf": [ + { + "$ref": "#/definitions/IconTheme" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "src" + ] + }, + "IconTheme": { + "description": "Icon themes supported by the MCP specification", + "oneOf": [ + { + "description": "Indicates the icon is designed to be used with a light background", + "type": "string", + "const": "light" + }, + { + "description": "Indicates the icon is designed to be used with a dark background", + "type": "string", + "const": "dark" + } + ] + }, + "ImageContent": { + "description": "Image content with base64-encoded data (spec `ImageContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "description": "The base64-encoded image data.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the image (e.g. `image/png`).", + "type": "string" + } + }, + "required": [ + "data", + "mimeType" + ] + }, + "Implementation": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "version" + ] + }, + "InitializeRequestParams": { + "description": "Parameters sent by a client when initializing a connection to an MCP server.\n\nThis contains the client's protocol version, capabilities, and implementation\ninformation, allowing the server to understand what the client supports.", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "capabilities": { + "description": "The capabilities this client supports (sampling, roots, etc.)", + "allOf": [ + { + "$ref": "#/definitions/ClientCapabilities" + } + ] + }, + "clientInfo": { + "description": "Information about the client implementation", + "allOf": [ + { + "$ref": "#/definitions/Implementation" + } + ] + }, + "protocolVersion": { + "description": "The MCP protocol version this client supports", + "allOf": [ + { + "$ref": "#/definitions/ProtocolVersion" + } + ] + } + }, + "required": [ + "protocolVersion", + "capabilities", + "clientInfo" + ] + }, + "InitializeResultMethod": { + "type": "string", + "format": "const", + "const": "initialize" + }, + "InitializedNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/initialized" + }, + "JsonRpcError": { + "type": "object", + "properties": { + "error": { + "$ref": "#/definitions/ErrorData" + }, + "id": { + "anyOf": [ + { + "$ref": "#/definitions/NumberOrString" + }, + { + "type": "null" + } + ] + }, + "jsonrpc": { + "$ref": "#/definitions/JsonRpcVersion2_0" + } + }, + "required": [ + "jsonrpc", + "error" + ] + }, + "JsonRpcNotification": { + "type": "object", + "properties": { + "jsonrpc": { + "$ref": "#/definitions/JsonRpcVersion2_0" + } + }, + "anyOf": [ + { + "$ref": "#/definitions/Notification" + }, + { + "$ref": "#/definitions/Notification2" + }, + { + "$ref": "#/definitions/NotificationNoParam" + }, + { + "$ref": "#/definitions/NotificationNoParam2" + }, + { + "$ref": "#/definitions/CustomNotification" + } + ], + "required": [ + "jsonrpc" + ] + }, + "JsonRpcRequest": { + "type": "object", + "properties": { + "id": { + "$ref": "#/definitions/NumberOrString" + }, + "jsonrpc": { + "$ref": "#/definitions/JsonRpcVersion2_0" + } + }, + "anyOf": [ + { + "$ref": "#/definitions/RequestNoParam" + }, + { + "$ref": "#/definitions/Request" + }, + { + "$ref": "#/definitions/Request2" + }, + { + "$ref": "#/definitions/Request3" + }, + { + "$ref": "#/definitions/Request4" + }, + { + "$ref": "#/definitions/Request5" + }, + { + "$ref": "#/definitions/RequestOptionalParam" + }, + { + "$ref": "#/definitions/RequestOptionalParam2" + }, + { + "$ref": "#/definitions/RequestOptionalParam3" + }, + { + "$ref": "#/definitions/Request6" + }, + { + "$ref": "#/definitions/Request7" + }, + { + "$ref": "#/definitions/Request8" + }, + { + "$ref": "#/definitions/Request9" + }, + { + "$ref": "#/definitions/Request10" + }, + { + "$ref": "#/definitions/RequestOptionalParam4" + }, + { + "$ref": "#/definitions/Request11" + }, + { + "$ref": "#/definitions/Request12" + }, + { + "$ref": "#/definitions/Request13" + }, + { + "$ref": "#/definitions/CustomRequest" + } + ], + "required": [ + "jsonrpc", + "id" + ] + }, + "JsonRpcResponse": { + "type": "object", + "properties": { + "id": { + "$ref": "#/definitions/NumberOrString" + }, + "jsonrpc": { + "$ref": "#/definitions/JsonRpcVersion2_0" + }, + "result": { + "$ref": "#/definitions/ClientResult" + } + }, + "required": [ + "jsonrpc", + "id", + "result" + ] + }, + "JsonRpcVersion2_0": { + "type": "string", + "format": "const", + "const": "2.0" + }, + "ListPromptsRequestMethod": { + "type": "string", + "format": "const", + "const": "prompts/list" + }, + "ListResourceTemplatesRequestMethod": { + "type": "string", + "format": "const", + "const": "resources/templates/list" + }, + "ListResourcesRequestMethod": { + "type": "string", + "format": "const", + "const": "resources/list" + }, + "ListRootsResult": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "roots": { + "type": "array", + "items": { + "$ref": "#/definitions/Root" + } + } + }, + "deprecated": true, + "required": [ + "roots" + ] + }, + "ListToolsRequestMethod": { + "type": "string", + "format": "const", + "const": "tools/list" + }, + "LoggingLevel": { + "description": "Logging levels supported by the MCP protocol", + "type": "string", + "deprecated": true, + "enum": [ + "debug", + "info", + "notice", + "warning", + "error", + "critical", + "alert", + "emergency" + ] + }, + "MetaObject": { + "description": "See [MCP general fields](https://modelcontextprotocol.io/specification/2026-07-28/basic#general-fields) for notes on _meta usage.", + "type": "object", + "additionalProperties": true + }, + "Notification": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/CancelledNotificationMethod" + }, + "params": { + "$ref": "#/definitions/CancelledNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, + "Notification2": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ProgressNotificationMethod" + }, + "params": { + "$ref": "#/definitions/ProgressNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, + "NotificationMetaObject": { + "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", + "type": "object", + "properties": { + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/definitions/NumberOrString" + } + }, + "additionalProperties": true + }, + "NotificationNoParam": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/InitializedNotificationMethod" + } + }, + "required": [ + "method" + ] + }, + "NotificationNoParam2": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/RootsListChangedNotificationMethod" + } + }, + "required": [ + "method" + ] + }, + "NumberOrString": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "PaginatedRequestParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "cursor": { + "type": [ + "string", + "null" + ] + } + } + }, + "PingRequestMethod": { + "type": "string", + "format": "const", + "const": "ping" + }, + "ProgressNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/progress" + }, + "ProgressNotificationParam": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] + }, + "message": { + "description": "An optional message describing the current progress.", + "type": [ + "string", + "null" + ] + }, + "progress": { + "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", + "type": "number", + "format": "double" + }, + "progressToken": { + "$ref": "#/definitions/ProgressToken" + }, + "total": { + "description": "Total number of items to process (or total progress required), if known", + "type": [ + "number", + "null" + ], + "format": "double" + } + }, + "required": [ + "progressToken", + "progress" + ] + }, + "ProgressToken": { + "description": "A token used to track the progress of long-running operations.\n\nProgress tokens allow clients and servers to associate progress notifications\nwith specific requests, enabling real-time updates on operation status.", + "allOf": [ + { + "$ref": "#/definitions/NumberOrString" + } + ] + }, + "PromptReference": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name" + ] + }, + "ProtocolVersion": { + "description": "Represents the MCP protocol version used for communication.\n\nThis ensures compatibility between clients and servers by specifying\nwhich version of the Model Context Protocol is being used.", + "type": "string" + }, + "ReadResourceRequestMethod": { + "type": "string", + "format": "const", + "const": "resources/read" + }, + "ReadResourceRequestParams": { + "description": "Parameters for reading a specific resource", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "inputResponses": { + "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`].", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "requestState": { + "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].", + "type": [ + "string", + "null" + ] + }, + "uri": { + "description": "The URI of the resource to read", + "type": "string" + } + }, + "required": [ + "uri" + ] + }, + "Reference": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "ref/resource" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ResourceTemplateReference" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "ref/prompt" + } + }, + "allOf": [ + { + "$ref": "#/definitions/PromptReference" + } + ], + "required": [ + "type" + ] + } + ] + }, + "Request": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/InitializeResultMethod" + }, + "params": { + "$ref": "#/definitions/InitializeRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request10": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/CallToolRequestMethod" + }, + "params": { + "$ref": "#/definitions/CallToolRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request11": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/GetTaskMethod" + }, + "params": { + "$ref": "#/definitions/GetTaskParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request12": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/UpdateTaskMethod" + }, + "params": { + "$ref": "#/definitions/UpdateTaskParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request13": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/CancelTaskMethod" + }, + "params": { + "$ref": "#/definitions/CancelTaskParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request2": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/DiscoverRequestMethod" + }, + "params": { + "$ref": "#/definitions/DiscoverRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request3": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/CompleteRequestMethod" + }, + "params": { + "$ref": "#/definitions/CompleteRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request4": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/SetLevelRequestMethod" + }, + "params": { + "$ref": "#/definitions/SetLevelRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request5": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/GetPromptRequestMethod" + }, + "params": { + "$ref": "#/definitions/GetPromptRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request6": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ReadResourceRequestMethod" + }, + "params": { + "$ref": "#/definitions/ReadResourceRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request7": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/SubscriptionsListenRequestMethod" + }, + "params": { + "$ref": "#/definitions/SubscriptionsListenRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request8": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/SubscribeRequestMethod" + }, + "params": { + "$ref": "#/definitions/SubscribeRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request9": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/UnsubscribeRequestMethod" + }, + "params": { + "$ref": "#/definitions/UnsubscribeRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "RequestMetaObject": { + "description": "Metadata reserved by MCP on requests. Extension keys are also allowed.", + "type": "object", + "properties": { + "io.modelcontextprotocol/clientCapabilities": { + "$ref": "#/definitions/ClientCapabilities" + }, + "io.modelcontextprotocol/clientInfo": { + "$ref": "#/definitions/Implementation" + }, + "io.modelcontextprotocol/logLevel": { + "$ref": "#/definitions/LoggingLevel" + }, + "io.modelcontextprotocol/protocolVersion": { + "type": "string" + }, + "progressToken": { + "$ref": "#/definitions/ProgressToken" + } + }, + "additionalProperties": true + }, + "RequestNoParam": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/PingRequestMethod" + } + }, + "required": [ + "method" + ] + }, + "RequestOptionalParam": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ListPromptsRequestMethod" + }, + "params": { + "anyOf": [ + { + "$ref": "#/definitions/PaginatedRequestParams" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "method" + ] + }, + "RequestOptionalParam2": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ListResourcesRequestMethod" + }, + "params": { + "anyOf": [ + { + "$ref": "#/definitions/PaginatedRequestParams" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "method" + ] + }, + "RequestOptionalParam3": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ListResourceTemplatesRequestMethod" + }, + "params": { + "anyOf": [ + { + "$ref": "#/definitions/PaginatedRequestParams" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "method" + ] + }, + "RequestOptionalParam4": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ListToolsRequestMethod" + }, + "params": { + "anyOf": [ + { + "$ref": "#/definitions/PaginatedRequestParams" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "method" + ] + }, + "Resource": { + "description": "A known resource that the server is capable of reading (spec `Resource`).\n\nAlso used as the inner type of `ContentBlock::ResourceLink` (spec `ResourceLink extends Resource`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this resource.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this resource.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "description": "Optional description of what this resource represents.", + "type": [ + "string", + "null" + ] + }, + "icons": { + "description": "Optional set of icons the client may display for this resource.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "The programmatic name of the resource.", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content in bytes (before base64/tokenization), if known.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "title": { + "description": "Optional human-readable display title.", + "type": [ + "string", + "null" + ] + }, + "uri": { + "description": "The URI of this resource (e.g. `file:///path/to/file`).", + "type": "string" + } + }, + "required": [ + "uri", + "name" + ] + }, + "ResourceContents": { + "description": "The contents of a specific resource or sub-resource.", + "anyOf": [ + { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "uri", + "text" + ] + }, + { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "blob": { + "type": "string" + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "uri", + "blob" + ] + } + ] + }, + "ResourceTemplateReference": { + "type": "object", + "properties": { + "uri": { + "type": "string" + } + }, + "required": [ + "uri" + ] + }, + "Role": { + "description": "Represents the role of a participant in a conversation or message exchange.\n\nUsed in sampling and chat contexts to distinguish between different\ntypes of message senders in the conversation flow.", + "oneOf": [ + { + "description": "A human user or client making a request", + "type": "string", + "const": "user" + }, + { + "description": "An AI assistant or server providing a response", + "type": "string", + "const": "assistant" + } + ] + }, + "Root": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": "string" + } + }, + "deprecated": true, + "required": [ + "uri" + ] + }, + "RootsCapabilities": { + "description": "Roots capability. Deprecated by SEP-2577; remains functional and will be\nremoved in a future release.\nSee .", + "type": "object", + "properties": { + "listChanged": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "RootsListChangedNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/roots/list_changed" + }, + "SamplingCapability": { + "description": "Sampling capability with optional sub-capabilities (SEP-1577).\n\nDeprecated by SEP-2577; remains functional and will be removed in a future\nrelease.\nSee .", + "type": "object", + "properties": { + "context": { + "description": "Support for `includeContext` (soft-deprecated)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "tools": { + "description": "Support for `tools` and `toolChoice` parameters", + "type": [ + "object", + "null" + ], + "additionalProperties": true + } + } + }, + "SamplingContent": { + "description": "Single or array content wrapper (SEP-1577).", + "anyOf": [ + { + "$ref": "#/definitions/SamplingMessageContentBlock" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/SamplingMessageContentBlock" + } + } + ] + }, + "SamplingMessageContentBlock": { + "description": "Content types for sampling messages (SEP-1577).", + "deprecated": true, + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "allOf": [ + { + "$ref": "#/definitions/TextContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ImageContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "audio" + } + }, + "allOf": [ + { + "$ref": "#/definitions/AudioContent" + } + ], + "required": [ + "type" + ] + }, + { + "description": "Assistant only", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_use" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ToolUseContent" + } + ], + "required": [ + "type" + ] + }, + { + "description": "User only", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_result" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ToolResultContent" + } + ], + "required": [ + "type" + ] + } + ] + }, + "SetLevelRequestMethod": { + "type": "string", + "format": "const", + "const": "logging/setLevel" + }, + "SetLevelRequestParams": { + "description": "Parameters for setting the logging level", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "level": { + "description": "The desired logging level", + "allOf": [ + { + "$ref": "#/definitions/LoggingLevel" + } + ] + } + }, + "deprecated": true, + "required": [ + "level" + ] + }, + "SubscribeRequestMethod": { + "type": "string", + "format": "const", + "const": "resources/subscribe" + }, + "SubscribeRequestParams": { + "description": "Parameters for subscribing to resource updates", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "uri": { + "description": "The URI of the resource to subscribe to", + "type": "string" + } + }, + "required": [ + "uri" + ] + }, + "SubscriptionFilter": { + "description": "Notification categories a client opts in to on a `subscriptions/listen` stream.", + "type": "object", + "properties": { + "promptsListChanged": { + "type": "boolean" + }, + "resourceSubscriptions": { + "type": "array", + "items": { + "type": "string" + } + }, + "resourcesListChanged": { + "type": "boolean" + }, + "toolsListChanged": { + "type": "boolean" + } + } + }, + "SubscriptionsListenRequestMethod": { + "type": "string", + "format": "const", + "const": "subscriptions/listen" + }, + "SubscriptionsListenRequestParams": { + "description": "Parameters for opening a long-lived notification subscription.", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata. Required by the 2026-07-28 wire schema.", + "type": "object", + "properties": { + "io.modelcontextprotocol/clientCapabilities": { + "$ref": "#/definitions/ClientCapabilities" + }, + "io.modelcontextprotocol/clientInfo": { + "$ref": "#/definitions/Implementation" + }, + "io.modelcontextprotocol/logLevel": { + "$ref": "#/definitions/LoggingLevel" + }, + "io.modelcontextprotocol/protocolVersion": { + "type": "string" + }, + "progressToken": { + "$ref": "#/definitions/ProgressToken" + } + }, + "additionalProperties": true, + "required": [ + "io.modelcontextprotocol/protocolVersion", + "io.modelcontextprotocol/clientCapabilities" + ] + }, + "notifications": { + "description": "Notification categories requested for this stream.", + "allOf": [ + { + "$ref": "#/definitions/SubscriptionFilter" + } + ] + } + }, + "required": [ + "_meta", + "notifications" + ] + }, + "TextContent": { + "description": "Text content block (spec `TextContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "text": { + "description": "The text content of the message.", + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "ToolResultContent": { + "description": "Tool execution result in user message (SEP-1577).", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/ContentBlock" + } + }, + "isError": { + "type": [ + "boolean", + "null" + ] + }, + "structuredContent": true, + "toolUseId": { + "type": "string" + } + }, + "deprecated": true, + "required": [ + "toolUseId", + "content" + ] + }, + "ToolUseContent": { + "description": "Tool call request from assistant (SEP-1577).", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "input": { + "type": "object", + "additionalProperties": true + }, + "name": { + "type": "string" + } + }, + "deprecated": true, + "required": [ + "id", + "name", + "input" + ] + }, + "UnsubscribeRequestMethod": { + "type": "string", + "format": "const", + "const": "resources/unsubscribe" + }, + "UnsubscribeRequestParams": { + "description": "Parameters for unsubscribing from resource updates", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "uri": { + "description": "The URI of the resource to unsubscribe from", + "type": "string" + } + }, + "required": [ + "uri" + ] + }, + "UpdateTaskMethod": { + "type": "string", + "format": "const", + "const": "tasks/update" + }, + "UpdateTaskParams": { + "description": "Parameters for `tasks/update` (SEP-2663): deliver responses to outstanding\nin-task server-to-client requests surfaced via `tasks/get` `inputRequests`.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "inputResponses": { + "description": "Responses to outstanding `inputRequests` previously surfaced by the\nserver. Each key MUST correspond to a currently-outstanding\n`inputRequests` key.", + "type": "object", + "additionalProperties": true + }, + "taskId": { + "description": "Identifier of the task to update.", + "type": "string" + } + }, + "required": [ + "taskId", + "inputResponses" + ] + }, + "UrlElicitationCapability": { + "description": "Capability for URL mode elicitation.", + "type": "object" + } + } +} \ No newline at end of file diff --git a/vendor/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/vendor/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json new file mode 100644 index 000000000..ca0ef5611 --- /dev/null +++ b/vendor/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -0,0 +1,4113 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "JsonRpcMessage", + "description": "Represents any JSON-RPC message that can be sent or received.\n\nThis enum covers all possible message types in the JSON-RPC protocol:\nindividual requests/responses, notifications, and errors.\nIt serves as the top-level message container for MCP communication.", + "anyOf": [ + { + "description": "A single request expecting a response", + "allOf": [ + { + "$ref": "#/definitions/JsonRpcRequest" + } + ] + }, + { + "description": "A response to a previous request", + "allOf": [ + { + "$ref": "#/definitions/JsonRpcResponse" + } + ] + }, + { + "description": "A one-way notification (no response expected)", + "allOf": [ + { + "$ref": "#/definitions/JsonRpcNotification" + } + ] + }, + { + "description": "An error response", + "allOf": [ + { + "$ref": "#/definitions/JsonRpcError" + } + ] + } + ], + "definitions": { + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are\nused or displayed.", + "type": "object", + "properties": { + "audience": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Role" + } + }, + "lastModified": { + "type": [ + "string", + "null" + ] + }, + "priority": { + "type": [ + "number", + "null" + ], + "format": "float" + } + } + }, + "ArrayTypeConst": { + "type": "string", + "format": "const", + "const": "array" + }, + "AudioContent": { + "description": "Audio content with base64-encoded data (spec `AudioContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "description": "The base64-encoded audio data.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the audio (e.g. `audio/wav`).", + "type": "string" + } + }, + "required": [ + "data", + "mimeType" + ] + }, + "BooleanSchema": { + "description": "Schema definition for boolean properties.", + "type": "object", + "properties": { + "default": { + "description": "Default value", + "type": [ + "boolean", + "null" + ] + }, + "description": { + "description": "Human-readable description", + "type": [ + "string", + "null" + ] + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Type discriminator", + "allOf": [ + { + "$ref": "#/definitions/BooleanTypeConst" + } + ] + } + }, + "required": [ + "type" + ] + }, + "BooleanTypeConst": { + "type": "string", + "format": "const", + "const": "boolean" + }, + "CacheScope": { + "description": "Scope describing who may cache cacheable list/read results (SEP-2549).\n\nDefaults to [`CacheScope::Public`] when absent from the wire.", + "oneOf": [ + { + "description": "Any client or intermediary may cache and serve the response to any user.", + "type": "string", + "const": "public" + }, + { + "description": "Only the requesting user's client may cache the response.", + "type": "string", + "const": "private" + } + ] + }, + "CallToolResult": { + "description": "The result of a tool call operation.\n\nContains the content returned by the tool execution and an optional\nflag indicating whether the operation resulted in an error.", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this result", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "content": { + "description": "The content returned by the tool (text, images, etc.)", + "type": "array", + "default": [], + "items": { + "$ref": "#/definitions/ContentBlock" + } + }, + "isError": { + "description": "Whether this result represents an error condition", + "type": [ + "boolean", + "null" + ] + }, + "resultType": { + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + }, + "structuredContent": { + "description": "An optional JSON object that represents the structured result of the tool call" + } + } + }, + "CancelledNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/cancelled" + }, + "CancelledNotificationParam": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "requestId": { + "anyOf": [ + { + "$ref": "#/definitions/NumberOrString" + }, + { + "type": "null" + } + ] + } + } + }, + "ClientCapabilities": { + "title": "Builder", + "description": "```rust\n# use rmcp::model::ClientCapabilities;\nlet cap = ClientCapabilities::builder()\n .enable_experimental()\n .build();\n```", + "type": "object", + "properties": { + "elicitation": { + "description": "Capability to handle elicitation requests from servers for interactive user input", + "anyOf": [ + { + "$ref": "#/definitions/ElicitationCapability" + }, + { + "type": "null" + } + ] + }, + "experimental": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "extensions": { + "description": "Optional MCP extensions that the client supports (SEP-1724).\nKeys are extension identifiers (e.g., `\"io.modelcontextprotocol/ui\"`),\nvalues are per-extension settings objects. An empty object indicates\nsupport with no settings.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "roots": { + "description": "Capability for filesystem roots (deprecated by SEP-2577).", + "anyOf": [ + { + "$ref": "#/definitions/RootsCapabilities" + }, + { + "type": "null" + } + ] + }, + "sampling": { + "description": "Capability for LLM sampling requests (SEP-1577, deprecated by SEP-2577).", + "anyOf": [ + { + "$ref": "#/definitions/SamplingCapability" + }, + { + "type": "null" + } + ] + } + } + }, + "CompleteResult": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "completion": { + "$ref": "#/definitions/CompletionInfo" + }, + "resultType": { + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "completion" + ] + }, + "CompletionInfo": { + "type": "object", + "properties": { + "hasMore": { + "type": [ + "boolean", + "null" + ] + }, + "total": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "values" + ] + }, + "ConstTitle": { + "description": "Schema definition for enum properties.\n\nRepresent single entry for titled item", + "type": "object", + "properties": { + "const": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "const", + "title" + ] + }, + "ContentBlock": { + "description": "Unified content block union (spec `ContentBlock`).\n\n`text | image | audio | resource_link | resource`", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "allOf": [ + { + "$ref": "#/definitions/TextContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ImageContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "audio" + } + }, + "allOf": [ + { + "$ref": "#/definitions/AudioContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource" + } + }, + "allOf": [ + { + "$ref": "#/definitions/EmbeddedResource" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource_link" + } + }, + "allOf": [ + { + "$ref": "#/definitions/Resource" + } + ], + "required": [ + "type" + ] + } + ] + }, + "ContextInclusion": { + "description": "Specifies how much context should be included in sampling requests.\n\nThis allows clients to control what additional context information\nshould be provided to the LLM when processing sampling requests.", + "oneOf": [ + { + "description": "Include context from all connected MCP servers", + "type": "string", + "const": "allServers" + }, + { + "description": "Include no additional context", + "type": "string", + "const": "none" + }, + { + "description": "Include context only from the requesting server", + "type": "string", + "const": "thisServer" + } + ] + }, + "CreateMessageRequestMethod": { + "type": "string", + "format": "const", + "const": "sampling/createMessage" + }, + "CreateMessageRequestParams": { + "description": "Parameters for creating a message through LLM sampling.\n\nThis structure contains all the necessary information for a client to\ngenerate an LLM response, including conversation history, model preferences,\nand generation parameters.\n\nThis implements `TaskAugmentedRequestParamsMeta` as sampling requests can be\nlong-running and may benefit from task-based execution.", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "includeContext": { + "description": "How much context to include from MCP servers", + "anyOf": [ + { + "$ref": "#/definitions/ContextInclusion" + }, + { + "type": "null" + } + ] + }, + "maxTokens": { + "description": "Maximum number of tokens to generate", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "messages": { + "description": "The conversation history and current messages", + "type": "array", + "items": { + "$ref": "#/definitions/SamplingMessage" + } + }, + "metadata": { + "description": "Additional metadata for the request" + }, + "modelPreferences": { + "description": "Preferences for model selection and behavior", + "anyOf": [ + { + "$ref": "#/definitions/ModelPreferences" + }, + { + "type": "null" + } + ] + }, + "stopSequences": { + "description": "Sequences that should stop generation", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "systemPrompt": { + "description": "System prompt to guide the model's behavior", + "type": [ + "string", + "null" + ] + }, + "temperature": { + "description": "Temperature for controlling randomness (0.0 to 1.0)", + "type": [ + "number", + "null" + ], + "format": "float" + }, + "toolChoice": { + "description": "Tool selection behavior (SEP-1577)", + "anyOf": [ + { + "$ref": "#/definitions/ToolChoice" + }, + { + "type": "null" + } + ] + }, + "tools": { + "description": "Tools available for the model to call (SEP-1577)", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Tool" + } + } + }, + "deprecated": true, + "required": [ + "messages", + "maxTokens" + ] + }, + "CreateTaskResult": { + "description": "Result returned in lieu of a standard result to indicate the request will\nbe processed asynchronously (spec `CreateTaskResult`, `resultType: \"task\"`).\n\nThe embedded task is the seed state for the task; the client uses\n`task.task_id` for all subsequent `tasks/get`, `tasks/update`, and\n`tasks/cancel` calls.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "description": "ISO 8601 timestamp when the task was created.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO 8601 timestamp when the task was last updated.", + "type": "string" + }, + "pollIntervalMs": { + "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "resultType": { + "description": "Always `\"task\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + }, + "status": { + "description": "Current task status.", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Stable identifier for this task, generated by the server.", + "type": "string" + }, + "ttlMs": { + "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "resultType", + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, + "CustomNotification": { + "description": "A catch-all notification either side can use to send custom messages to its peer.\n\nThis preserves the raw `method` name and `params` payload so handlers can\ndeserialize them into domain-specific types.", + "type": "object", + "properties": { + "method": { + "type": "string" + }, + "params": true + }, + "required": [ + "method" + ] + }, + "CustomRequest": { + "description": "A catch-all request either side can use to send custom messages to its peer.\n\nThis preserves the raw `method` name and `params` payload so handlers can\ndeserialize them into domain-specific types.", + "type": "object", + "properties": { + "method": { + "type": "string" + }, + "params": true + }, + "required": [ + "method" + ] + }, + "CustomResult": { + "description": "A catch-all response either side can use for custom requests." + }, + "DiscoverResult": { + "description": "The server's response to a [`DiscoverRequest`].", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level response metadata.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "cacheScope": { + "description": "Whether the cached result may be shared across authorization contexts.", + "allOf": [ + { + "$ref": "#/definitions/CacheScope" + } + ] + }, + "capabilities": { + "description": "Capabilities provided by this server.", + "allOf": [ + { + "$ref": "#/definitions/ServerCapabilities" + } + ] + }, + "instructions": { + "description": "Optional guidance for using the server.", + "type": [ + "string", + "null" + ] + }, + "resultType": { + "description": "Identifies how the result should be parsed.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + }, + "supportedVersions": { + "description": "Protocol versions implemented by this server.", + "type": "array", + "items": { + "$ref": "#/definitions/ProtocolVersion" + } + }, + "ttlMs": { + "description": "How long clients may consider this response fresh, in milliseconds.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "resultType", + "supportedVersions", + "capabilities", + "ttlMs", + "cacheScope" + ] + }, + "ElicitRequestParams": { + "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n1. Form-based elicitation request\n```rust\nuse rmcp::model::*;\n\nlet params = ElicitRequestParams::FormElicitationParams {\n meta: None,\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```\n2. URL-based elicitation request\n```rust\nuse rmcp::model::*;\nlet params = ElicitRequestParams::UrlElicitationParams {\n meta: None,\n message: \"Please provide your feedback at the following URL\".to_string(),\n url: \"https://example.com/feedback\".to_string(),\n elicitation_id: \"unique-id-123\".to_string(),\n};\n```", + "anyOf": [ + { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + }, + "mode": { + "type": "string", + "const": "form" + }, + "requestedSchema": { + "$ref": "#/definitions/ElicitationSchema" + } + }, + "required": [ + "mode", + "message", + "requestedSchema" + ] + }, + { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "elicitationId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "mode": { + "type": "string", + "const": "url" + }, + "url": { + "type": "string" + } + }, + "required": [ + "mode", + "message", + "url", + "elicitationId" + ] + }, + { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + }, + "requestedSchema": { + "$ref": "#/definitions/ElicitationSchema" + } + }, + "required": [ + "message", + "requestedSchema" + ] + } + ] + }, + "ElicitResult": { + "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this result.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "action": { + "description": "The user's decision on how to handle the elicitation request", + "allOf": [ + { + "$ref": "#/definitions/ElicitationAction" + } + ] + }, + "content": { + "description": "The actual data provided by the user, if they accepted the request.\nMust conform to the JSON schema specified in the original request.\nOnly present when action is Accept." + } + }, + "required": [ + "action" + ] + }, + "ElicitationAction": { + "description": "Represents the possible actions a user can take in response to an elicitation request.\n\nWhen a server requests user input through elicitation, the user can:\n- Accept: Provide the requested information and continue\n- Decline: Refuse to provide the information but continue the operation\n- Cancel: Stop the entire operation", + "oneOf": [ + { + "description": "User accepts the request and provides the requested information", + "type": "string", + "const": "accept" + }, + { + "description": "User declines to provide the information but allows the operation to continue", + "type": "string", + "const": "decline" + }, + { + "description": "User cancels the entire operation", + "type": "string", + "const": "cancel" + } + ] + }, + "ElicitationCapability": { + "description": "Elicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.", + "type": "object", + "properties": { + "form": { + "description": "Whether client supports form-based elicitation.", + "anyOf": [ + { + "$ref": "#/definitions/FormElicitationCapability" + }, + { + "type": "null" + } + ] + }, + "url": { + "description": "Whether client supports URL-based elicitation.", + "anyOf": [ + { + "$ref": "#/definitions/UrlElicitationCapability" + }, + { + "type": "null" + } + ] + } + } + }, + "ElicitationCreateRequestMethod": { + "type": "string", + "format": "const", + "const": "elicitation/create" + }, + "ElicitationSchema": { + "description": "Type-safe elicitation schema for requesting structured user input.\n\nThis enforces the MCP 2025-06-18 specification that elicitation schemas\nmust be objects with primitive-typed properties.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet schema = ElicitationSchema::builder()\n .required_email(\"email\")\n .required_integer(\"age\", 0, 150)\n .optional_bool(\"newsletter\", false)\n .build();\n```", + "type": "object", + "properties": { + "description": { + "description": "Optional description of what this schema represents", + "type": [ + "string", + "null" + ] + }, + "properties": { + "description": "Property definitions (must be primitive types)", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/PrimitiveSchemaDefinition" + } + }, + "required": { + "description": "List of required property names", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Always \"object\" for elicitation schemas", + "allOf": [ + { + "$ref": "#/definitions/ObjectTypeConst" + } + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "EmbeddedResource": { + "description": "Embedded resource content (spec `EmbeddedResource`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "resource": { + "description": "The embedded resource contents (text or blob).", + "allOf": [ + { + "$ref": "#/definitions/ResourceContents" + } + ] + } + }, + "required": [ + "resource" + ] + }, + "EmptyObject": { + "description": "This is commonly used for representing empty objects in MCP messages.\n\nwithout returning any specific data.", + "type": "object", + "additionalProperties": false + }, + "EnumSchema": { + "description": "Compliant with MCP 2025-06-18 specification for elicitation schemas.\nEnums must have string type for values and can optionally include human-readable names.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet enum_schema = EnumSchema::builder(vec![\"US\".to_string(), \"UK\".to_string()])\n .multiselect()\n .min_items(1u64).expect(\"Min items should be correct value\")\n .max_items(4u64).expect(\"Max items should be correct value\")\n .description(\"Country code\")\n .build();\n```", + "anyOf": [ + { + "$ref": "#/definitions/SingleSelectEnumSchema" + }, + { + "$ref": "#/definitions/MultiSelectEnumSchema" + }, + { + "$ref": "#/definitions/LegacyEnumSchema" + } + ] + }, + "ErrorCode": { + "description": "Standard JSON-RPC error codes used throughout the MCP protocol.\n\nThese codes follow the JSON-RPC 2.0 specification and provide\nstandardized error reporting across all MCP implementations.", + "type": "integer", + "format": "int32" + }, + "ErrorData": { + "description": "Error information for JSON-RPC error responses.\n\nThis structure follows the JSON-RPC 2.0 specification for error reporting,\nproviding a standardized way to communicate errors between clients and servers.", + "type": "object", + "properties": { + "code": { + "description": "The error type that occurred (using standard JSON-RPC error codes)", + "allOf": [ + { + "$ref": "#/definitions/ErrorCode" + } + ] + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the\nsender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ] + }, + "FormElicitationCapability": { + "description": "Capability for handling elicitation requests from servers.\nElicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.\n\nCapability for form mode elicitation.", + "type": "object", + "properties": { + "schemaValidation": { + "description": "Whether the client supports JSON Schema validation for elicitation responses.\nWhen true, the client will validate user input against the requested_schema\nbefore sending the response back to the server.", + "type": [ + "boolean", + "null" + ] + } + } + }, + "GetPromptResult": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/PromptMessage" + } + }, + "resultType": { + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "messages" + ] + }, + "GetTaskResult": { + "description": "Response to a `tasks/get` request (spec `GetTaskResult = Result & DetailedTask`).\n\n`resultType` is `\"complete\"` — this is the standard result shape for\n`tasks/get`, not a task handle.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "description": "ISO 8601 timestamp when the task was created.", + "type": "string" + }, + "error": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "inputRequests": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputRequest" + } + }, + "lastUpdatedAt": { + "description": "ISO 8601 timestamp when the task was last updated.", + "type": "string" + }, + "pollIntervalMs": { + "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "result": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "resultType": { + "description": "Result type discriminator. `tasks/get` responses are standard results:\n`\"complete\"` (SEP-2322). Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" + }, + "status": { + "description": "Current task status.", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Stable identifier for this task, generated by the server.", + "type": "string" + }, + "ttlMs": { + "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, + "Icon": { + "description": "A URL pointing to an icon resource or a base64-encoded data URI.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- image/png - PNG images (safe, universal compatibility)\n- image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- image/svg+xml - SVG images (scalable but requires security precautions)\n- image/webp - WebP images (modern, efficient format)", + "type": "object", + "properties": { + "mimeType": { + "description": "Optional override if the server's MIME type is missing or generic", + "type": [ + "string", + "null" + ] + }, + "sizes": { + "description": "Size specification, each string should be in WxH format (e.g., `\\\"48x48\\\"`, `\\\"96x96\\\"`) or `\\\"any\\\"` for scalable formats like SVG", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "src": { + "description": "A standard URI pointing to an icon resource", + "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for\nIf not provided, the client should assume the icon can be used with any theme.", + "anyOf": [ + { + "$ref": "#/definitions/IconTheme" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "src" + ] + }, + "IconTheme": { + "description": "Icon themes supported by the MCP specification", + "oneOf": [ + { + "description": "Indicates the icon is designed to be used with a light background", + "type": "string", + "const": "light" + }, + { + "description": "Indicates the icon is designed to be used with a dark background", + "type": "string", + "const": "dark" + } + ] + }, + "ImageContent": { + "description": "Image content with base64-encoded data (spec `ImageContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "description": "The base64-encoded image data.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the image (e.g. `image/png`).", + "type": "string" + } + }, + "required": [ + "data", + "mimeType" + ] + }, + "Implementation": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "version" + ] + }, + "InitializeResult": { + "description": "The server's response to an initialization request.\n\nContains the server's protocol version, capabilities, and implementation\ninformation, along with optional instructions for the client.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "capabilities": { + "description": "The capabilities this server provides (tools, resources, prompts, etc.)", + "allOf": [ + { + "$ref": "#/definitions/ServerCapabilities" + } + ] + }, + "instructions": { + "description": "Optional human-readable instructions about using this server", + "type": [ + "string", + "null" + ] + }, + "protocolVersion": { + "description": "The MCP protocol version this server supports", + "allOf": [ + { + "$ref": "#/definitions/ProtocolVersion" + } + ] + }, + "serverInfo": { + "description": "Information about the server implementation", + "allOf": [ + { + "$ref": "#/definitions/Implementation" + } + ] + } + }, + "required": [ + "protocolVersion", + "capabilities", + "serverInfo" + ] + }, + "InputRequest": { + "description": "A server-initiated request that can appear inside [`InputRequests`].\n\nPer the MCP spec, only `CreateMessageRequest` (sampling),\n`ElicitRequest` (elicitation), and `ListRootsRequest` (roots)\nare allowed. This is modeled as an untagged enum rather than a\n`ServerRequest` alias to prevent `PingRequest` or `CustomRequest` from\nbeing included.", + "anyOf": [ + { + "description": "A `sampling/createMessage` request.", + "allOf": [ + { + "$ref": "#/definitions/Request" + } + ] + }, + { + "description": "An `elicitation/create` request.", + "allOf": [ + { + "$ref": "#/definitions/Request2" + } + ] + }, + { + "description": "A `roots/list` request.", + "allOf": [ + { + "$ref": "#/definitions/RequestNoParam2" + } + ] + } + ] + }, + "InputRequiredResult": { + "description": "A result indicating that additional input is needed before the request\ncan be completed.\n\nAt least one of [`input_requests`](Self::input_requests) or\n[`request_state`](Self::request_state) MUST be present.\n\nServers MAY send this in response to `tools/call`, `prompts/get`, or\n`resources/read`. Servers MUST NOT send this for any other request.\n\n# Examples\n\n```\nuse rmcp::model::InputRequiredResult;\n\nlet result = InputRequiredResult::from_request_state(\"opaque-server-state\");\nassert!(result.input_requests.is_none());\nassert_eq!(result.request_state.as_deref(), Some(\"opaque-server-state\"));\n```", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "inputRequests": { + "description": "Server-initiated requests that the client must fulfill before retrying.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputRequest" + } + }, + "requestState": { + "description": "Opaque request state to be echoed back by the client on retry.\nClients MUST NOT inspect, parse, modify, or make any assumptions\nabout the contents.", + "type": [ + "string", + "null" + ] + }, + "resultType": { + "description": "Always `\"input_required\"` for this result type.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + } + }, + "required": [ + "resultType" + ] + }, + "IntegerSchema": { + "description": "Schema definition for integer properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", + "type": "object", + "properties": { + "default": { + "description": "Default value", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "description": { + "description": "Human-readable description", + "type": [ + "string", + "null" + ] + }, + "maximum": { + "description": "Maximum value (inclusive)", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "minimum": { + "description": "Minimum value (inclusive)", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Type discriminator", + "allOf": [ + { + "$ref": "#/definitions/IntegerTypeConst" + } + ] + } + }, + "required": [ + "type" + ] + }, + "IntegerTypeConst": { + "type": "string", + "format": "const", + "const": "integer" + }, + "JsonRpcError": { + "type": "object", + "properties": { + "error": { + "$ref": "#/definitions/ErrorData" + }, + "id": { + "anyOf": [ + { + "$ref": "#/definitions/NumberOrString" + }, + { + "type": "null" + } + ] + }, + "jsonrpc": { + "$ref": "#/definitions/JsonRpcVersion2_0" + } + }, + "required": [ + "jsonrpc", + "error" + ] + }, + "JsonRpcNotification": { + "type": "object", + "properties": { + "jsonrpc": { + "$ref": "#/definitions/JsonRpcVersion2_0" + } + }, + "anyOf": [ + { + "$ref": "#/definitions/Notification" + }, + { + "$ref": "#/definitions/Notification2" + }, + { + "$ref": "#/definitions/Notification3" + }, + { + "$ref": "#/definitions/Notification4" + }, + { + "$ref": "#/definitions/NotificationNoParam" + }, + { + "$ref": "#/definitions/NotificationNoParam2" + }, + { + "$ref": "#/definitions/NotificationNoParam3" + }, + { + "$ref": "#/definitions/Notification5" + }, + { + "$ref": "#/definitions/Notification6" + }, + { + "$ref": "#/definitions/CustomNotification" + } + ], + "required": [ + "jsonrpc" + ] + }, + "JsonRpcRequest": { + "type": "object", + "properties": { + "id": { + "$ref": "#/definitions/NumberOrString" + }, + "jsonrpc": { + "$ref": "#/definitions/JsonRpcVersion2_0" + } + }, + "anyOf": [ + { + "$ref": "#/definitions/RequestNoParam" + }, + { + "$ref": "#/definitions/Request" + }, + { + "$ref": "#/definitions/RequestNoParam2" + }, + { + "$ref": "#/definitions/Request2" + }, + { + "$ref": "#/definitions/CustomRequest" + } + ], + "required": [ + "jsonrpc", + "id" + ] + }, + "JsonRpcResponse": { + "type": "object", + "properties": { + "id": { + "$ref": "#/definitions/NumberOrString" + }, + "jsonrpc": { + "$ref": "#/definitions/JsonRpcVersion2_0" + }, + "result": { + "$ref": "#/definitions/ServerResult" + } + }, + "required": [ + "jsonrpc", + "id", + "result" + ] + }, + "JsonRpcVersion2_0": { + "type": "string", + "format": "const", + "const": "2.0" + }, + "LegacyEnumSchema": { + "description": "Legacy enum schema, keep for backward compatibility", + "type": "object", + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "enumNames": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/StringTypeConst" + } + }, + "required": [ + "type", + "enum" + ] + }, + "ListPromptsResult": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + }, + "prompts": { + "type": "array", + "items": { + "$ref": "#/definitions/Prompt" + } + }, + "resultType": { + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "prompts" + ] + }, + "ListResourceTemplatesResult": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + }, + "resourceTemplates": { + "type": "array", + "items": { + "$ref": "#/definitions/ResourceTemplate" + } + }, + "resultType": { + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "resourceTemplates" + ] + }, + "ListResourcesResult": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + }, + "resources": { + "type": "array", + "items": { + "$ref": "#/definitions/Resource" + } + }, + "resultType": { + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "resources" + ] + }, + "ListRootsRequestMethod": { + "type": "string", + "format": "const", + "const": "roots/list" + }, + "ListToolsResult": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + }, + "resultType": { + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + }, + "tools": { + "type": "array", + "items": { + "$ref": "#/definitions/Tool" + } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "tools" + ] + }, + "LoggingLevel": { + "description": "Logging levels supported by the MCP protocol", + "type": "string", + "deprecated": true, + "enum": [ + "debug", + "info", + "notice", + "warning", + "error", + "critical", + "alert", + "emergency" + ] + }, + "LoggingMessageNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/message" + }, + "LoggingMessageNotificationParam": { + "description": "Parameters for a logging message notification", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] + }, + "data": { + "description": "The actual log data" + }, + "level": { + "description": "The severity level of this log message", + "allOf": [ + { + "$ref": "#/definitions/LoggingLevel" + } + ] + }, + "logger": { + "description": "Optional logger name that generated this message", + "type": [ + "string", + "null" + ] + } + }, + "deprecated": true, + "required": [ + "level", + "data" + ] + }, + "MetaObject": { + "description": "See [MCP general fields](https://modelcontextprotocol.io/specification/2026-07-28/basic#general-fields) for notes on _meta usage.", + "type": "object", + "additionalProperties": true + }, + "ModelHint": { + "description": "A hint suggesting a preferred model name or family.\n\nModel hints are advisory suggestions that help clients choose appropriate\nmodels. They can be specific model names or general families like \"claude\" or \"gpt\".", + "type": "object", + "properties": { + "name": { + "description": "The suggested model name or family identifier", + "type": [ + "string", + "null" + ] + } + }, + "deprecated": true + }, + "ModelPreferences": { + "description": "Preferences for model selection and behavior in sampling requests.\n\nThis allows servers to express their preferences for which model to use\nand how to balance different priorities when the client has multiple\nmodel options available.", + "type": "object", + "properties": { + "costPriority": { + "description": "Priority for cost optimization (0.0 to 1.0, higher = prefer cheaper models)", + "type": [ + "number", + "null" + ], + "format": "float" + }, + "hints": { + "description": "Specific model names or families to prefer (e.g., \"claude\", \"gpt\")", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/ModelHint" + } + }, + "intelligencePriority": { + "description": "Priority for intelligence/capability (0.0 to 1.0, higher = prefer more capable models)", + "type": [ + "number", + "null" + ], + "format": "float" + }, + "speedPriority": { + "description": "Priority for speed/latency (0.0 to 1.0, higher = prefer faster models)", + "type": [ + "number", + "null" + ], + "format": "float" + } + }, + "deprecated": true + }, + "MultiSelectEnumSchema": { + "description": "Multi-select enum options", + "anyOf": [ + { + "$ref": "#/definitions/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/definitions/TitledMultiSelectEnumSchema" + } + ] + }, + "Notification": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/CancelledNotificationMethod" + }, + "params": { + "$ref": "#/definitions/CancelledNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, + "Notification2": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ProgressNotificationMethod" + }, + "params": { + "$ref": "#/definitions/ProgressNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, + "Notification3": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/LoggingMessageNotificationMethod" + }, + "params": { + "$ref": "#/definitions/LoggingMessageNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, + "Notification4": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ResourceUpdatedNotificationMethod" + }, + "params": { + "$ref": "#/definitions/ResourceUpdatedNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, + "Notification5": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/SubscriptionsAcknowledgedNotificationMethod" + }, + "params": { + "$ref": "#/definitions/SubscriptionsAcknowledgedNotificationParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Notification6": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/TaskStatusNotificationMethod" + }, + "params": { + "$ref": "#/definitions/TaskStatusNotificationParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "NotificationMetaObject": { + "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", + "type": "object", + "properties": { + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/definitions/NumberOrString" + } + }, + "additionalProperties": true + }, + "NotificationNoParam": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ResourceListChangedNotificationMethod" + } + }, + "required": [ + "method" + ] + }, + "NotificationNoParam2": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ToolListChangedNotificationMethod" + } + }, + "required": [ + "method" + ] + }, + "NotificationNoParam3": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/PromptListChangedNotificationMethod" + } + }, + "required": [ + "method" + ] + }, + "NumberOrString": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "NumberSchema": { + "description": "Schema definition for number properties (floating-point).\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", + "type": "object", + "properties": { + "default": { + "description": "Default value", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "description": { + "description": "Human-readable description", + "type": [ + "string", + "null" + ] + }, + "maximum": { + "description": "Maximum value (inclusive)", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "minimum": { + "description": "Minimum value (inclusive)", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Type discriminator", + "allOf": [ + { + "$ref": "#/definitions/NumberTypeConst" + } + ] + } + }, + "required": [ + "type" + ] + }, + "NumberTypeConst": { + "type": "string", + "format": "const", + "const": "number" + }, + "ObjectTypeConst": { + "type": "string", + "format": "const", + "const": "object" + }, + "PingRequestMethod": { + "type": "string", + "format": "const", + "const": "ping" + }, + "PrimitiveSchemaDefinition": { + "description": "Primitive schema definition for elicitation properties.\n\nAccording to MCP 2025-06-18 specification, elicitation schemas must have\nproperties of primitive types only (string, number, integer, boolean, enum).\n\nNote: Put Enum as the first variant to avoid ambiguity during deserialization.\nThis is due to the fact that EnumSchema can contain StringSchema internally and serde\nuses first match wins strategy when deserializing untagged enums.", + "anyOf": [ + { + "description": "Enum property (explicit enum schema)", + "allOf": [ + { + "$ref": "#/definitions/EnumSchema" + } + ] + }, + { + "description": "String property (with optional enum constraint)", + "allOf": [ + { + "$ref": "#/definitions/StringSchema" + } + ] + }, + { + "description": "Number property (with optional enum constraint)", + "allOf": [ + { + "$ref": "#/definitions/NumberSchema" + } + ] + }, + { + "description": "Integer property (with optional enum constraint)", + "allOf": [ + { + "$ref": "#/definitions/IntegerSchema" + } + ] + }, + { + "description": "Boolean property", + "allOf": [ + { + "$ref": "#/definitions/BooleanSchema" + } + ] + } + ] + }, + "ProgressNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/progress" + }, + "ProgressNotificationParam": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] + }, + "message": { + "description": "An optional message describing the current progress.", + "type": [ + "string", + "null" + ] + }, + "progress": { + "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", + "type": "number", + "format": "double" + }, + "progressToken": { + "$ref": "#/definitions/ProgressToken" + }, + "total": { + "description": "Total number of items to process (or total progress required), if known", + "type": [ + "number", + "null" + ], + "format": "double" + } + }, + "required": [ + "progressToken", + "progress" + ] + }, + "ProgressToken": { + "description": "A token used to track the progress of long-running operations.\n\nProgress tokens allow clients and servers to associate progress notifications\nwith specific requests, enabling real-time updates on operation status.", + "allOf": [ + { + "$ref": "#/definitions/NumberOrString" + } + ] + }, + "Prompt": { + "description": "A prompt or prompt template that the server offers (spec `Prompt`).", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "arguments": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/PromptArgument" + } + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name" + ] + }, + "PromptArgument": { + "description": "Describes an argument that a prompt can accept (spec `PromptArgument`).", + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "required": { + "type": [ + "boolean", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name" + ] + }, + "PromptListChangedNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/prompts/list_changed" + }, + "PromptMessage": { + "description": "A message returned as part of a prompt (spec `PromptMessage`).\n\nUses the unified `ContentBlock` for its content (text | image | audio | resource_link | resource).", + "type": "object", + "properties": { + "content": { + "$ref": "#/definitions/ContentBlock" + }, + "role": { + "$ref": "#/definitions/Role" + } + }, + "required": [ + "role", + "content" + ] + }, + "PromptsCapability": { + "type": "object", + "properties": { + "listChanged": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "ProtocolVersion": { + "description": "Represents the MCP protocol version used for communication.\n\nThis ensures compatibility between clients and servers by specifying\nwhich version of the Model Context Protocol is being used.", + "type": "string" + }, + "ReadResourceResult": { + "description": "Result containing the contents of a read resource", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, + "contents": { + "description": "The actual content of the resource", + "type": "array", + "items": { + "$ref": "#/definitions/ResourceContents" + } + }, + "resultType": { + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "contents" + ] + }, + "Request": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/CreateMessageRequestMethod" + }, + "params": { + "$ref": "#/definitions/CreateMessageRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request2": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ElicitationCreateRequestMethod" + }, + "params": { + "$ref": "#/definitions/ElicitRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "RequestMetaObject": { + "description": "Metadata reserved by MCP on requests. Extension keys are also allowed.", + "type": "object", + "properties": { + "io.modelcontextprotocol/clientCapabilities": { + "$ref": "#/definitions/ClientCapabilities" + }, + "io.modelcontextprotocol/clientInfo": { + "$ref": "#/definitions/Implementation" + }, + "io.modelcontextprotocol/logLevel": { + "$ref": "#/definitions/LoggingLevel" + }, + "io.modelcontextprotocol/protocolVersion": { + "type": "string" + }, + "progressToken": { + "$ref": "#/definitions/ProgressToken" + } + }, + "additionalProperties": true + }, + "RequestNoParam": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/PingRequestMethod" + } + }, + "required": [ + "method" + ] + }, + "RequestNoParam2": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ListRootsRequestMethod" + } + }, + "required": [ + "method" + ] + }, + "Resource": { + "description": "A known resource that the server is capable of reading (spec `Resource`).\n\nAlso used as the inner type of `ContentBlock::ResourceLink` (spec `ResourceLink extends Resource`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this resource.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this resource.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "description": "Optional description of what this resource represents.", + "type": [ + "string", + "null" + ] + }, + "icons": { + "description": "Optional set of icons the client may display for this resource.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "The programmatic name of the resource.", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content in bytes (before base64/tokenization), if known.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "title": { + "description": "Optional human-readable display title.", + "type": [ + "string", + "null" + ] + }, + "uri": { + "description": "The URI of this resource (e.g. `file:///path/to/file`).", + "type": "string" + } + }, + "required": [ + "uri", + "name" + ] + }, + "ResourceContents": { + "description": "The contents of a specific resource or sub-resource.", + "anyOf": [ + { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "uri", + "text" + ] + }, + { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "blob": { + "type": "string" + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "uri", + "blob" + ] + } + ] + }, + "ResourceListChangedNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/resources/list_changed" + }, + "ResourceTemplate": { + "description": "A template description for resources available on the server (spec `ResourceTemplate`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this resource template.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this template.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "description": "Optional description of what this template is for.", + "type": [ + "string", + "null" + ] + }, + "icons": { + "description": "Optional set of icons the client may display for this template.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "mimeType": { + "description": "The MIME type for resources matching this template, if uniform.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "The programmatic name of the resource template.", + "type": "string" + }, + "title": { + "description": "Optional human-readable display title.", + "type": [ + "string", + "null" + ] + }, + "uriTemplate": { + "description": "An RFC 6570 URI template for constructing resource URIs.", + "type": "string" + } + }, + "required": [ + "uriTemplate", + "name" + ] + }, + "ResourceUpdatedNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/resources/updated" + }, + "ResourceUpdatedNotificationParam": { + "description": "Parameters for a resource update notification", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] + }, + "uri": { + "description": "The URI of the resource that was updated", + "type": "string" + } + }, + "required": [ + "uri" + ] + }, + "ResourcesCapability": { + "type": "object", + "properties": { + "listChanged": { + "type": [ + "boolean", + "null" + ] + }, + "subscribe": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "ResultType": { + "description": "Indicates the type of a result object, allowing the client to\ndetermine how to parse the response.\n\nThe spec defines this as an open string (`\"complete\" | \"input_required\" | string`),\nso unknown values are preserved rather than rejected. Servers implementing this\nprotocol version MUST include `resultType` in every result. For backward\ncompatibility, clients MUST treat an absent field as `\"complete\"`.\n\nOrdinary results model the field as `Option`: `None` means the\nfield is absent on the wire. Constructors default to `Some(COMPLETE)`, and\nthe server handler strips the `\"complete\"` discriminator before responding\nto peers that negotiated a protocol version older than `2026-07-28`, so\nlegacy sessions keep their historical wire shape (see\n[`ServerResult::strip_result_type_for_legacy_peer`]).", + "type": "string" + }, + "Role": { + "description": "Represents the role of a participant in a conversation or message exchange.\n\nUsed in sampling and chat contexts to distinguish between different\ntypes of message senders in the conversation flow.", + "oneOf": [ + { + "description": "A human user or client making a request", + "type": "string", + "const": "user" + }, + { + "description": "An AI assistant or server providing a response", + "type": "string", + "const": "assistant" + } + ] + }, + "RootsCapabilities": { + "description": "Roots capability. Deprecated by SEP-2577; remains functional and will be\nremoved in a future release.\nSee .", + "type": "object", + "properties": { + "listChanged": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "SamplingCapability": { + "description": "Sampling capability with optional sub-capabilities (SEP-1577).\n\nDeprecated by SEP-2577; remains functional and will be removed in a future\nrelease.\nSee .", + "type": "object", + "properties": { + "context": { + "description": "Support for `includeContext` (soft-deprecated)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "tools": { + "description": "Support for `tools` and `toolChoice` parameters", + "type": [ + "object", + "null" + ], + "additionalProperties": true + } + } + }, + "SamplingContent": { + "description": "Single or array content wrapper (SEP-1577).", + "anyOf": [ + { + "$ref": "#/definitions/SamplingMessageContentBlock" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/SamplingMessageContentBlock" + } + } + ] + }, + "SamplingMessage": { + "description": "A message in a sampling conversation, containing a role and content.\n\nThis represents a single message in a conversation flow, used primarily\nin LLM sampling requests where the conversation history is important\nfor generating appropriate responses.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "content": { + "description": "The actual content of the message (text, image, audio, tool use, or tool result)", + "allOf": [ + { + "$ref": "#/definitions/SamplingContent" + } + ] + }, + "role": { + "description": "The role of the message sender (User or Assistant)", + "allOf": [ + { + "$ref": "#/definitions/Role" + } + ] + } + }, + "deprecated": true, + "required": [ + "role", + "content" + ] + }, + "SamplingMessageContentBlock": { + "description": "Content types for sampling messages (SEP-1577).", + "deprecated": true, + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "allOf": [ + { + "$ref": "#/definitions/TextContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ImageContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "audio" + } + }, + "allOf": [ + { + "$ref": "#/definitions/AudioContent" + } + ], + "required": [ + "type" + ] + }, + { + "description": "Assistant only", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_use" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ToolUseContent" + } + ], + "required": [ + "type" + ] + }, + { + "description": "User only", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_result" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ToolResultContent" + } + ], + "required": [ + "type" + ] + } + ] + }, + "ServerCapabilities": { + "title": "Builder", + "description": "```rust\n# use rmcp::model::ServerCapabilities;\nlet cap = ServerCapabilities::builder()\n .enable_experimental()\n .enable_prompts()\n .enable_resources()\n .enable_tools()\n .enable_tool_list_changed()\n .build();\n```", + "type": "object", + "properties": { + "completions": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "experimental": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "extensions": { + "description": "Optional MCP extensions that the server supports (SEP-1724).\nKeys are extension identifiers (e.g., `\"io.modelcontextprotocol/apps\"`),\nvalues are per-extension settings objects. An empty object indicates\nsupport with no settings.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "logging": { + "description": "Capability for server log message notifications (deprecated by SEP-2577).", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "prompts": { + "anyOf": [ + { + "$ref": "#/definitions/PromptsCapability" + }, + { + "type": "null" + } + ] + }, + "resources": { + "anyOf": [ + { + "$ref": "#/definitions/ResourcesCapability" + }, + { + "type": "null" + } + ] + }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/ToolsCapability" + }, + { + "type": "null" + } + ] + } + } + }, + "ServerResult": { + "anyOf": [ + { + "$ref": "#/definitions/DiscoverResult" + }, + { + "$ref": "#/definitions/InitializeResult" + }, + { + "$ref": "#/definitions/CompleteResult" + }, + { + "$ref": "#/definitions/GetPromptResult" + }, + { + "$ref": "#/definitions/ListPromptsResult" + }, + { + "$ref": "#/definitions/ListResourcesResult" + }, + { + "$ref": "#/definitions/ListResourceTemplatesResult" + }, + { + "$ref": "#/definitions/ReadResourceResult" + }, + { + "$ref": "#/definitions/SubscriptionsListenResult" + }, + { + "$ref": "#/definitions/ListToolsResult" + }, + { + "$ref": "#/definitions/ElicitResult" + }, + { + "$ref": "#/definitions/CreateTaskResult" + }, + { + "$ref": "#/definitions/GetTaskResult" + }, + { + "$ref": "#/definitions/CallToolResult" + }, + { + "$ref": "#/definitions/InputRequiredResult" + }, + { + "$ref": "#/definitions/TaskAckResult" + }, + { + "$ref": "#/definitions/EmptyObject" + }, + { + "$ref": "#/definitions/CustomResult" + } + ] + }, + "SingleSelectEnumSchema": { + "description": "Combined single-select", + "anyOf": [ + { + "$ref": "#/definitions/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/definitions/TitledSingleSelectEnumSchema" + } + ] + }, + "StringFormat": { + "description": "String format types allowed by the MCP specification.", + "oneOf": [ + { + "description": "Email address format", + "type": "string", + "const": "email" + }, + { + "description": "URI format", + "type": "string", + "const": "uri" + }, + { + "description": "Date format (YYYY-MM-DD)", + "type": "string", + "const": "date" + }, + { + "description": "Date-time format (ISO 8601)", + "type": "string", + "const": "date-time" + } + ] + }, + "StringSchema": { + "description": "Schema definition for string properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec:\n- format limited to: \"email\", \"uri\", \"date\", \"date-time\"", + "type": "object", + "properties": { + "default": { + "description": "Default value", + "type": [ + "string", + "null" + ] + }, + "description": { + "description": "Human-readable description", + "type": [ + "string", + "null" + ] + }, + "format": { + "description": "String format - limited to: \"email\", \"uri\", \"date\", \"date-time\"", + "anyOf": [ + { + "$ref": "#/definitions/StringFormat" + }, + { + "type": "null" + } + ] + }, + "maxLength": { + "description": "Maximum string length", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "minLength": { + "description": "Minimum string length", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Type discriminator", + "allOf": [ + { + "$ref": "#/definitions/StringTypeConst" + } + ] + } + }, + "required": [ + "type" + ] + }, + "StringTypeConst": { + "type": "string", + "format": "const", + "const": "string" + }, + "SubscriptionFilter": { + "description": "Notification categories a client opts in to on a `subscriptions/listen` stream.", + "type": "object", + "properties": { + "promptsListChanged": { + "type": "boolean" + }, + "resourceSubscriptions": { + "type": "array", + "items": { + "type": "string" + } + }, + "resourcesListChanged": { + "type": "boolean" + }, + "toolsListChanged": { + "type": "boolean" + } + } + }, + "SubscriptionsAcknowledgedNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/subscriptions/acknowledged" + }, + "SubscriptionsAcknowledgedNotificationParams": { + "description": "Parameters reporting the accepted subset of a subscription filter.", + "type": "object", + "properties": { + "_meta": { + "$ref": "#/definitions/NotificationMetaObject" + }, + "notifications": { + "$ref": "#/definitions/SubscriptionFilter" + } + }, + "required": [ + "notifications" + ] + }, + "SubscriptionsListenResult": { + "description": "Final response indicating that a subscription ended gracefully.", + "type": "object", + "properties": { + "_meta": { + "$ref": "#/definitions/SubscriptionsListenResultMeta" + }, + "resultType": { + "$ref": "#/definitions/ResultType" + } + }, + "required": [ + "resultType", + "_meta" + ] + }, + "SubscriptionsListenResultMeta": { + "type": "object", + "properties": { + "io.modelcontextprotocol/serverInfo": { + "description": "Identifies the server software producing the response. Servers SHOULD include this field on every response unless specifically configured not to do so.", + "allOf": [ + { + "$ref": "#/definitions/Implementation" + } + ] + }, + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/definitions/NumberOrString" + } + }, + "additionalProperties": true, + "required": [ + "io.modelcontextprotocol/subscriptionId" + ] + }, + "TaskAckResult": { + "description": "Empty acknowledgement for `tasks/update` and `tasks/cancel` (SEP-2663).\n\nThe spec requires these acks to be empty results carrying the SEP-2322\n`resultType: \"complete\"` discriminator; task state changes are observed\nvia the next `tasks/get`.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "resultType": { + "description": "Always `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + } + }, + "required": [ + "resultType" + ] + }, + "TaskStatus": { + "description": "Canonical task lifecycle status (SEP-2663).", + "oneOf": [ + { + "description": "The request is currently being processed.", + "type": "string", + "const": "working" + }, + { + "description": "The server needs input from the client before the task can proceed.", + "type": "string", + "const": "input_required" + }, + { + "description": "The request completed successfully and the result is available.\nThis includes tool calls that returned results with `isError: true`.", + "type": "string", + "const": "completed" + }, + { + "description": "The request failed due to a JSON-RPC error during execution.", + "type": "string", + "const": "failed" + }, + { + "description": "The request was cancelled before completion.", + "type": "string", + "const": "cancelled" + } + ] + }, + "TaskStatusNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/tasks" + }, + "TaskStatusNotificationParams": { + "description": "Parameters for a task status notification (spec `TaskStatusNotificationParams`).\n\nCarries a complete [`DetailedTask`] for the current status, identical to\nwhat `tasks/get` would have returned at that moment. The task fields are\nflattened at the top level: `NotificationParams & Task`.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "description": "ISO 8601 timestamp when the task was created.", + "type": "string" + }, + "error": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "inputRequests": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputRequest" + } + }, + "lastUpdatedAt": { + "description": "ISO 8601 timestamp when the task was last updated.", + "type": "string" + }, + "pollIntervalMs": { + "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "result": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "status": { + "description": "Current task status.", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Stable identifier for this task, generated by the server.", + "type": "string" + }, + "ttlMs": { + "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, + "TextContent": { + "description": "Text content block (spec `TextContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "text": { + "description": "The text content of the message.", + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "TitledItems": { + "description": "Items for titled multi-select options", + "type": "object", + "properties": { + "anyOf": { + "type": "array", + "items": { + "$ref": "#/definitions/ConstTitle" + } + } + }, + "required": [ + "anyOf" + ] + }, + "TitledMultiSelectEnumSchema": { + "description": "Multi-select titled options", + "type": "object", + "properties": { + "default": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/TitledItems" + }, + "maxItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "minItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/ArrayTypeConst" + } + }, + "required": [ + "type", + "items" + ] + }, + "TitledSingleSelectEnumSchema": { + "description": "Titled single-select", + "type": "object", + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "oneOf": { + "type": "array", + "items": { + "$ref": "#/definitions/ConstTitle" + } + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/StringTypeConst" + } + }, + "required": [ + "type", + "oneOf" + ] + }, + "Tool": { + "description": "A tool that can be used by a model.", + "type": "object", + "properties": { + "_meta": { + "description": "Optional additional metadata for this tool", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional additional tool information.", + "anyOf": [ + { + "$ref": "#/definitions/ToolAnnotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "description": "A description of what the tool does", + "type": [ + "string", + "null" + ] + }, + "icons": { + "description": "Optional list of icons for the tool", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "inputSchema": { + "description": "A JSON Schema object defining the expected parameters for the tool", + "type": "object", + "additionalProperties": true + }, + "name": { + "description": "The name of the tool", + "type": "string" + }, + "outputSchema": { + "description": "An optional JSON Schema object defining the structure of the tool's output", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "title": { + "description": "A human-readable title for the tool", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "inputSchema" + ] + }, + "ToolAnnotations": { + "description": "Additional properties describing a Tool to clients.\n\nNOTE: all properties in ToolAnnotations are **hints**.\nThey are not guaranteed to provide a faithful description of\ntool behavior (including descriptive properties like `title`).\n\nClients should never make tool use decisions based on ToolAnnotations\nreceived from untrusted servers.", + "type": "object", + "properties": { + "destructiveHint": { + "description": "If true, the tool may perform destructive updates to its environment.\nIf false, the tool performs only additive updates.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: true\nA human-readable description of the tool's purpose.", + "type": [ + "boolean", + "null" + ] + }, + "idempotentHint": { + "description": "If true, calling the tool repeatedly with the same arguments\nwill have no additional effect on the its environment.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: false.", + "type": [ + "boolean", + "null" + ] + }, + "openWorldHint": { + "description": "If true, this tool may interact with an \"open world\" of external\nentities. If false, the tool's domain of interaction is closed.\nFor example, the world of a web search tool is open, whereas that\nof a memory tool is not.\n\nDefault: true", + "type": [ + "boolean", + "null" + ] + }, + "readOnlyHint": { + "description": "If true, the tool does not modify its environment.\n\nDefault: false", + "type": [ + "boolean", + "null" + ] + }, + "title": { + "description": "A human-readable title for the tool.", + "type": [ + "string", + "null" + ] + } + } + }, + "ToolChoice": { + "description": "Tool choice configuration (SEP-1577).", + "type": "object", + "properties": { + "mode": { + "anyOf": [ + { + "$ref": "#/definitions/ToolChoiceMode" + }, + { + "type": "null" + } + ] + } + }, + "deprecated": true + }, + "ToolChoiceMode": { + "description": "Tool selection mode (SEP-1577).", + "oneOf": [ + { + "description": "Model decides whether to use tools", + "type": "string", + "const": "auto" + }, + { + "description": "Model must use at least one tool", + "type": "string", + "const": "required" + }, + { + "description": "Model must not use tools", + "type": "string", + "const": "none" + } + ] + }, + "ToolListChangedNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/tools/list_changed" + }, + "ToolResultContent": { + "description": "Tool execution result in user message (SEP-1577).", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/ContentBlock" + } + }, + "isError": { + "type": [ + "boolean", + "null" + ] + }, + "structuredContent": true, + "toolUseId": { + "type": "string" + } + }, + "deprecated": true, + "required": [ + "toolUseId", + "content" + ] + }, + "ToolUseContent": { + "description": "Tool call request from assistant (SEP-1577).", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "input": { + "type": "object", + "additionalProperties": true + }, + "name": { + "type": "string" + } + }, + "deprecated": true, + "required": [ + "id", + "name", + "input" + ] + }, + "ToolsCapability": { + "type": "object", + "properties": { + "listChanged": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "UntitledItems": { + "description": "Items for untitled multi-select options", + "type": "object", + "properties": { + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "$ref": "#/definitions/StringTypeConst" + } + }, + "required": [ + "type", + "enum" + ] + }, + "UntitledMultiSelectEnumSchema": { + "description": "Multi-select untitled options", + "type": "object", + "properties": { + "default": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/UntitledItems" + }, + "maxItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "minItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/ArrayTypeConst" + } + }, + "required": [ + "type", + "items" + ] + }, + "UntitledSingleSelectEnumSchema": { + "description": "Untitled single-select", + "type": "object", + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/StringTypeConst" + } + }, + "additionalProperties": false, + "required": [ + "type", + "enum" + ] + }, + "UrlElicitationCapability": { + "description": "Capability for URL mode elicitation.", + "type": "object" + } + } +} \ No newline at end of file diff --git a/vendor/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/vendor/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json new file mode 100644 index 000000000..ca0ef5611 --- /dev/null +++ b/vendor/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -0,0 +1,4113 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "JsonRpcMessage", + "description": "Represents any JSON-RPC message that can be sent or received.\n\nThis enum covers all possible message types in the JSON-RPC protocol:\nindividual requests/responses, notifications, and errors.\nIt serves as the top-level message container for MCP communication.", + "anyOf": [ + { + "description": "A single request expecting a response", + "allOf": [ + { + "$ref": "#/definitions/JsonRpcRequest" + } + ] + }, + { + "description": "A response to a previous request", + "allOf": [ + { + "$ref": "#/definitions/JsonRpcResponse" + } + ] + }, + { + "description": "A one-way notification (no response expected)", + "allOf": [ + { + "$ref": "#/definitions/JsonRpcNotification" + } + ] + }, + { + "description": "An error response", + "allOf": [ + { + "$ref": "#/definitions/JsonRpcError" + } + ] + } + ], + "definitions": { + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are\nused or displayed.", + "type": "object", + "properties": { + "audience": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Role" + } + }, + "lastModified": { + "type": [ + "string", + "null" + ] + }, + "priority": { + "type": [ + "number", + "null" + ], + "format": "float" + } + } + }, + "ArrayTypeConst": { + "type": "string", + "format": "const", + "const": "array" + }, + "AudioContent": { + "description": "Audio content with base64-encoded data (spec `AudioContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "description": "The base64-encoded audio data.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the audio (e.g. `audio/wav`).", + "type": "string" + } + }, + "required": [ + "data", + "mimeType" + ] + }, + "BooleanSchema": { + "description": "Schema definition for boolean properties.", + "type": "object", + "properties": { + "default": { + "description": "Default value", + "type": [ + "boolean", + "null" + ] + }, + "description": { + "description": "Human-readable description", + "type": [ + "string", + "null" + ] + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Type discriminator", + "allOf": [ + { + "$ref": "#/definitions/BooleanTypeConst" + } + ] + } + }, + "required": [ + "type" + ] + }, + "BooleanTypeConst": { + "type": "string", + "format": "const", + "const": "boolean" + }, + "CacheScope": { + "description": "Scope describing who may cache cacheable list/read results (SEP-2549).\n\nDefaults to [`CacheScope::Public`] when absent from the wire.", + "oneOf": [ + { + "description": "Any client or intermediary may cache and serve the response to any user.", + "type": "string", + "const": "public" + }, + { + "description": "Only the requesting user's client may cache the response.", + "type": "string", + "const": "private" + } + ] + }, + "CallToolResult": { + "description": "The result of a tool call operation.\n\nContains the content returned by the tool execution and an optional\nflag indicating whether the operation resulted in an error.", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this result", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "content": { + "description": "The content returned by the tool (text, images, etc.)", + "type": "array", + "default": [], + "items": { + "$ref": "#/definitions/ContentBlock" + } + }, + "isError": { + "description": "Whether this result represents an error condition", + "type": [ + "boolean", + "null" + ] + }, + "resultType": { + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + }, + "structuredContent": { + "description": "An optional JSON object that represents the structured result of the tool call" + } + } + }, + "CancelledNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/cancelled" + }, + "CancelledNotificationParam": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "requestId": { + "anyOf": [ + { + "$ref": "#/definitions/NumberOrString" + }, + { + "type": "null" + } + ] + } + } + }, + "ClientCapabilities": { + "title": "Builder", + "description": "```rust\n# use rmcp::model::ClientCapabilities;\nlet cap = ClientCapabilities::builder()\n .enable_experimental()\n .build();\n```", + "type": "object", + "properties": { + "elicitation": { + "description": "Capability to handle elicitation requests from servers for interactive user input", + "anyOf": [ + { + "$ref": "#/definitions/ElicitationCapability" + }, + { + "type": "null" + } + ] + }, + "experimental": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "extensions": { + "description": "Optional MCP extensions that the client supports (SEP-1724).\nKeys are extension identifiers (e.g., `\"io.modelcontextprotocol/ui\"`),\nvalues are per-extension settings objects. An empty object indicates\nsupport with no settings.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "roots": { + "description": "Capability for filesystem roots (deprecated by SEP-2577).", + "anyOf": [ + { + "$ref": "#/definitions/RootsCapabilities" + }, + { + "type": "null" + } + ] + }, + "sampling": { + "description": "Capability for LLM sampling requests (SEP-1577, deprecated by SEP-2577).", + "anyOf": [ + { + "$ref": "#/definitions/SamplingCapability" + }, + { + "type": "null" + } + ] + } + } + }, + "CompleteResult": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "completion": { + "$ref": "#/definitions/CompletionInfo" + }, + "resultType": { + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "completion" + ] + }, + "CompletionInfo": { + "type": "object", + "properties": { + "hasMore": { + "type": [ + "boolean", + "null" + ] + }, + "total": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "values" + ] + }, + "ConstTitle": { + "description": "Schema definition for enum properties.\n\nRepresent single entry for titled item", + "type": "object", + "properties": { + "const": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "const", + "title" + ] + }, + "ContentBlock": { + "description": "Unified content block union (spec `ContentBlock`).\n\n`text | image | audio | resource_link | resource`", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "allOf": [ + { + "$ref": "#/definitions/TextContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ImageContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "audio" + } + }, + "allOf": [ + { + "$ref": "#/definitions/AudioContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource" + } + }, + "allOf": [ + { + "$ref": "#/definitions/EmbeddedResource" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource_link" + } + }, + "allOf": [ + { + "$ref": "#/definitions/Resource" + } + ], + "required": [ + "type" + ] + } + ] + }, + "ContextInclusion": { + "description": "Specifies how much context should be included in sampling requests.\n\nThis allows clients to control what additional context information\nshould be provided to the LLM when processing sampling requests.", + "oneOf": [ + { + "description": "Include context from all connected MCP servers", + "type": "string", + "const": "allServers" + }, + { + "description": "Include no additional context", + "type": "string", + "const": "none" + }, + { + "description": "Include context only from the requesting server", + "type": "string", + "const": "thisServer" + } + ] + }, + "CreateMessageRequestMethod": { + "type": "string", + "format": "const", + "const": "sampling/createMessage" + }, + "CreateMessageRequestParams": { + "description": "Parameters for creating a message through LLM sampling.\n\nThis structure contains all the necessary information for a client to\ngenerate an LLM response, including conversation history, model preferences,\nand generation parameters.\n\nThis implements `TaskAugmentedRequestParamsMeta` as sampling requests can be\nlong-running and may benefit from task-based execution.", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "includeContext": { + "description": "How much context to include from MCP servers", + "anyOf": [ + { + "$ref": "#/definitions/ContextInclusion" + }, + { + "type": "null" + } + ] + }, + "maxTokens": { + "description": "Maximum number of tokens to generate", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "messages": { + "description": "The conversation history and current messages", + "type": "array", + "items": { + "$ref": "#/definitions/SamplingMessage" + } + }, + "metadata": { + "description": "Additional metadata for the request" + }, + "modelPreferences": { + "description": "Preferences for model selection and behavior", + "anyOf": [ + { + "$ref": "#/definitions/ModelPreferences" + }, + { + "type": "null" + } + ] + }, + "stopSequences": { + "description": "Sequences that should stop generation", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "systemPrompt": { + "description": "System prompt to guide the model's behavior", + "type": [ + "string", + "null" + ] + }, + "temperature": { + "description": "Temperature for controlling randomness (0.0 to 1.0)", + "type": [ + "number", + "null" + ], + "format": "float" + }, + "toolChoice": { + "description": "Tool selection behavior (SEP-1577)", + "anyOf": [ + { + "$ref": "#/definitions/ToolChoice" + }, + { + "type": "null" + } + ] + }, + "tools": { + "description": "Tools available for the model to call (SEP-1577)", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Tool" + } + } + }, + "deprecated": true, + "required": [ + "messages", + "maxTokens" + ] + }, + "CreateTaskResult": { + "description": "Result returned in lieu of a standard result to indicate the request will\nbe processed asynchronously (spec `CreateTaskResult`, `resultType: \"task\"`).\n\nThe embedded task is the seed state for the task; the client uses\n`task.task_id` for all subsequent `tasks/get`, `tasks/update`, and\n`tasks/cancel` calls.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "description": "ISO 8601 timestamp when the task was created.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO 8601 timestamp when the task was last updated.", + "type": "string" + }, + "pollIntervalMs": { + "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "resultType": { + "description": "Always `\"task\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + }, + "status": { + "description": "Current task status.", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Stable identifier for this task, generated by the server.", + "type": "string" + }, + "ttlMs": { + "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "resultType", + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, + "CustomNotification": { + "description": "A catch-all notification either side can use to send custom messages to its peer.\n\nThis preserves the raw `method` name and `params` payload so handlers can\ndeserialize them into domain-specific types.", + "type": "object", + "properties": { + "method": { + "type": "string" + }, + "params": true + }, + "required": [ + "method" + ] + }, + "CustomRequest": { + "description": "A catch-all request either side can use to send custom messages to its peer.\n\nThis preserves the raw `method` name and `params` payload so handlers can\ndeserialize them into domain-specific types.", + "type": "object", + "properties": { + "method": { + "type": "string" + }, + "params": true + }, + "required": [ + "method" + ] + }, + "CustomResult": { + "description": "A catch-all response either side can use for custom requests." + }, + "DiscoverResult": { + "description": "The server's response to a [`DiscoverRequest`].", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level response metadata.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "cacheScope": { + "description": "Whether the cached result may be shared across authorization contexts.", + "allOf": [ + { + "$ref": "#/definitions/CacheScope" + } + ] + }, + "capabilities": { + "description": "Capabilities provided by this server.", + "allOf": [ + { + "$ref": "#/definitions/ServerCapabilities" + } + ] + }, + "instructions": { + "description": "Optional guidance for using the server.", + "type": [ + "string", + "null" + ] + }, + "resultType": { + "description": "Identifies how the result should be parsed.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + }, + "supportedVersions": { + "description": "Protocol versions implemented by this server.", + "type": "array", + "items": { + "$ref": "#/definitions/ProtocolVersion" + } + }, + "ttlMs": { + "description": "How long clients may consider this response fresh, in milliseconds.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "resultType", + "supportedVersions", + "capabilities", + "ttlMs", + "cacheScope" + ] + }, + "ElicitRequestParams": { + "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n1. Form-based elicitation request\n```rust\nuse rmcp::model::*;\n\nlet params = ElicitRequestParams::FormElicitationParams {\n meta: None,\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```\n2. URL-based elicitation request\n```rust\nuse rmcp::model::*;\nlet params = ElicitRequestParams::UrlElicitationParams {\n meta: None,\n message: \"Please provide your feedback at the following URL\".to_string(),\n url: \"https://example.com/feedback\".to_string(),\n elicitation_id: \"unique-id-123\".to_string(),\n};\n```", + "anyOf": [ + { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + }, + "mode": { + "type": "string", + "const": "form" + }, + "requestedSchema": { + "$ref": "#/definitions/ElicitationSchema" + } + }, + "required": [ + "mode", + "message", + "requestedSchema" + ] + }, + { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "elicitationId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "mode": { + "type": "string", + "const": "url" + }, + "url": { + "type": "string" + } + }, + "required": [ + "mode", + "message", + "url", + "elicitationId" + ] + }, + { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + }, + "requestedSchema": { + "$ref": "#/definitions/ElicitationSchema" + } + }, + "required": [ + "message", + "requestedSchema" + ] + } + ] + }, + "ElicitResult": { + "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this result.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "action": { + "description": "The user's decision on how to handle the elicitation request", + "allOf": [ + { + "$ref": "#/definitions/ElicitationAction" + } + ] + }, + "content": { + "description": "The actual data provided by the user, if they accepted the request.\nMust conform to the JSON schema specified in the original request.\nOnly present when action is Accept." + } + }, + "required": [ + "action" + ] + }, + "ElicitationAction": { + "description": "Represents the possible actions a user can take in response to an elicitation request.\n\nWhen a server requests user input through elicitation, the user can:\n- Accept: Provide the requested information and continue\n- Decline: Refuse to provide the information but continue the operation\n- Cancel: Stop the entire operation", + "oneOf": [ + { + "description": "User accepts the request and provides the requested information", + "type": "string", + "const": "accept" + }, + { + "description": "User declines to provide the information but allows the operation to continue", + "type": "string", + "const": "decline" + }, + { + "description": "User cancels the entire operation", + "type": "string", + "const": "cancel" + } + ] + }, + "ElicitationCapability": { + "description": "Elicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.", + "type": "object", + "properties": { + "form": { + "description": "Whether client supports form-based elicitation.", + "anyOf": [ + { + "$ref": "#/definitions/FormElicitationCapability" + }, + { + "type": "null" + } + ] + }, + "url": { + "description": "Whether client supports URL-based elicitation.", + "anyOf": [ + { + "$ref": "#/definitions/UrlElicitationCapability" + }, + { + "type": "null" + } + ] + } + } + }, + "ElicitationCreateRequestMethod": { + "type": "string", + "format": "const", + "const": "elicitation/create" + }, + "ElicitationSchema": { + "description": "Type-safe elicitation schema for requesting structured user input.\n\nThis enforces the MCP 2025-06-18 specification that elicitation schemas\nmust be objects with primitive-typed properties.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet schema = ElicitationSchema::builder()\n .required_email(\"email\")\n .required_integer(\"age\", 0, 150)\n .optional_bool(\"newsletter\", false)\n .build();\n```", + "type": "object", + "properties": { + "description": { + "description": "Optional description of what this schema represents", + "type": [ + "string", + "null" + ] + }, + "properties": { + "description": "Property definitions (must be primitive types)", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/PrimitiveSchemaDefinition" + } + }, + "required": { + "description": "List of required property names", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Always \"object\" for elicitation schemas", + "allOf": [ + { + "$ref": "#/definitions/ObjectTypeConst" + } + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "EmbeddedResource": { + "description": "Embedded resource content (spec `EmbeddedResource`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "resource": { + "description": "The embedded resource contents (text or blob).", + "allOf": [ + { + "$ref": "#/definitions/ResourceContents" + } + ] + } + }, + "required": [ + "resource" + ] + }, + "EmptyObject": { + "description": "This is commonly used for representing empty objects in MCP messages.\n\nwithout returning any specific data.", + "type": "object", + "additionalProperties": false + }, + "EnumSchema": { + "description": "Compliant with MCP 2025-06-18 specification for elicitation schemas.\nEnums must have string type for values and can optionally include human-readable names.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet enum_schema = EnumSchema::builder(vec![\"US\".to_string(), \"UK\".to_string()])\n .multiselect()\n .min_items(1u64).expect(\"Min items should be correct value\")\n .max_items(4u64).expect(\"Max items should be correct value\")\n .description(\"Country code\")\n .build();\n```", + "anyOf": [ + { + "$ref": "#/definitions/SingleSelectEnumSchema" + }, + { + "$ref": "#/definitions/MultiSelectEnumSchema" + }, + { + "$ref": "#/definitions/LegacyEnumSchema" + } + ] + }, + "ErrorCode": { + "description": "Standard JSON-RPC error codes used throughout the MCP protocol.\n\nThese codes follow the JSON-RPC 2.0 specification and provide\nstandardized error reporting across all MCP implementations.", + "type": "integer", + "format": "int32" + }, + "ErrorData": { + "description": "Error information for JSON-RPC error responses.\n\nThis structure follows the JSON-RPC 2.0 specification for error reporting,\nproviding a standardized way to communicate errors between clients and servers.", + "type": "object", + "properties": { + "code": { + "description": "The error type that occurred (using standard JSON-RPC error codes)", + "allOf": [ + { + "$ref": "#/definitions/ErrorCode" + } + ] + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the\nsender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ] + }, + "FormElicitationCapability": { + "description": "Capability for handling elicitation requests from servers.\nElicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.\n\nCapability for form mode elicitation.", + "type": "object", + "properties": { + "schemaValidation": { + "description": "Whether the client supports JSON Schema validation for elicitation responses.\nWhen true, the client will validate user input against the requested_schema\nbefore sending the response back to the server.", + "type": [ + "boolean", + "null" + ] + } + } + }, + "GetPromptResult": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/PromptMessage" + } + }, + "resultType": { + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "messages" + ] + }, + "GetTaskResult": { + "description": "Response to a `tasks/get` request (spec `GetTaskResult = Result & DetailedTask`).\n\n`resultType` is `\"complete\"` — this is the standard result shape for\n`tasks/get`, not a task handle.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "description": "ISO 8601 timestamp when the task was created.", + "type": "string" + }, + "error": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "inputRequests": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputRequest" + } + }, + "lastUpdatedAt": { + "description": "ISO 8601 timestamp when the task was last updated.", + "type": "string" + }, + "pollIntervalMs": { + "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "result": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "resultType": { + "description": "Result type discriminator. `tasks/get` responses are standard results:\n`\"complete\"` (SEP-2322). Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" + }, + "status": { + "description": "Current task status.", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Stable identifier for this task, generated by the server.", + "type": "string" + }, + "ttlMs": { + "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, + "Icon": { + "description": "A URL pointing to an icon resource or a base64-encoded data URI.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- image/png - PNG images (safe, universal compatibility)\n- image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- image/svg+xml - SVG images (scalable but requires security precautions)\n- image/webp - WebP images (modern, efficient format)", + "type": "object", + "properties": { + "mimeType": { + "description": "Optional override if the server's MIME type is missing or generic", + "type": [ + "string", + "null" + ] + }, + "sizes": { + "description": "Size specification, each string should be in WxH format (e.g., `\\\"48x48\\\"`, `\\\"96x96\\\"`) or `\\\"any\\\"` for scalable formats like SVG", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "src": { + "description": "A standard URI pointing to an icon resource", + "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for\nIf not provided, the client should assume the icon can be used with any theme.", + "anyOf": [ + { + "$ref": "#/definitions/IconTheme" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "src" + ] + }, + "IconTheme": { + "description": "Icon themes supported by the MCP specification", + "oneOf": [ + { + "description": "Indicates the icon is designed to be used with a light background", + "type": "string", + "const": "light" + }, + { + "description": "Indicates the icon is designed to be used with a dark background", + "type": "string", + "const": "dark" + } + ] + }, + "ImageContent": { + "description": "Image content with base64-encoded data (spec `ImageContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "description": "The base64-encoded image data.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the image (e.g. `image/png`).", + "type": "string" + } + }, + "required": [ + "data", + "mimeType" + ] + }, + "Implementation": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "version" + ] + }, + "InitializeResult": { + "description": "The server's response to an initialization request.\n\nContains the server's protocol version, capabilities, and implementation\ninformation, along with optional instructions for the client.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "capabilities": { + "description": "The capabilities this server provides (tools, resources, prompts, etc.)", + "allOf": [ + { + "$ref": "#/definitions/ServerCapabilities" + } + ] + }, + "instructions": { + "description": "Optional human-readable instructions about using this server", + "type": [ + "string", + "null" + ] + }, + "protocolVersion": { + "description": "The MCP protocol version this server supports", + "allOf": [ + { + "$ref": "#/definitions/ProtocolVersion" + } + ] + }, + "serverInfo": { + "description": "Information about the server implementation", + "allOf": [ + { + "$ref": "#/definitions/Implementation" + } + ] + } + }, + "required": [ + "protocolVersion", + "capabilities", + "serverInfo" + ] + }, + "InputRequest": { + "description": "A server-initiated request that can appear inside [`InputRequests`].\n\nPer the MCP spec, only `CreateMessageRequest` (sampling),\n`ElicitRequest` (elicitation), and `ListRootsRequest` (roots)\nare allowed. This is modeled as an untagged enum rather than a\n`ServerRequest` alias to prevent `PingRequest` or `CustomRequest` from\nbeing included.", + "anyOf": [ + { + "description": "A `sampling/createMessage` request.", + "allOf": [ + { + "$ref": "#/definitions/Request" + } + ] + }, + { + "description": "An `elicitation/create` request.", + "allOf": [ + { + "$ref": "#/definitions/Request2" + } + ] + }, + { + "description": "A `roots/list` request.", + "allOf": [ + { + "$ref": "#/definitions/RequestNoParam2" + } + ] + } + ] + }, + "InputRequiredResult": { + "description": "A result indicating that additional input is needed before the request\ncan be completed.\n\nAt least one of [`input_requests`](Self::input_requests) or\n[`request_state`](Self::request_state) MUST be present.\n\nServers MAY send this in response to `tools/call`, `prompts/get`, or\n`resources/read`. Servers MUST NOT send this for any other request.\n\n# Examples\n\n```\nuse rmcp::model::InputRequiredResult;\n\nlet result = InputRequiredResult::from_request_state(\"opaque-server-state\");\nassert!(result.input_requests.is_none());\nassert_eq!(result.request_state.as_deref(), Some(\"opaque-server-state\"));\n```", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "inputRequests": { + "description": "Server-initiated requests that the client must fulfill before retrying.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputRequest" + } + }, + "requestState": { + "description": "Opaque request state to be echoed back by the client on retry.\nClients MUST NOT inspect, parse, modify, or make any assumptions\nabout the contents.", + "type": [ + "string", + "null" + ] + }, + "resultType": { + "description": "Always `\"input_required\"` for this result type.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + } + }, + "required": [ + "resultType" + ] + }, + "IntegerSchema": { + "description": "Schema definition for integer properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", + "type": "object", + "properties": { + "default": { + "description": "Default value", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "description": { + "description": "Human-readable description", + "type": [ + "string", + "null" + ] + }, + "maximum": { + "description": "Maximum value (inclusive)", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "minimum": { + "description": "Minimum value (inclusive)", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Type discriminator", + "allOf": [ + { + "$ref": "#/definitions/IntegerTypeConst" + } + ] + } + }, + "required": [ + "type" + ] + }, + "IntegerTypeConst": { + "type": "string", + "format": "const", + "const": "integer" + }, + "JsonRpcError": { + "type": "object", + "properties": { + "error": { + "$ref": "#/definitions/ErrorData" + }, + "id": { + "anyOf": [ + { + "$ref": "#/definitions/NumberOrString" + }, + { + "type": "null" + } + ] + }, + "jsonrpc": { + "$ref": "#/definitions/JsonRpcVersion2_0" + } + }, + "required": [ + "jsonrpc", + "error" + ] + }, + "JsonRpcNotification": { + "type": "object", + "properties": { + "jsonrpc": { + "$ref": "#/definitions/JsonRpcVersion2_0" + } + }, + "anyOf": [ + { + "$ref": "#/definitions/Notification" + }, + { + "$ref": "#/definitions/Notification2" + }, + { + "$ref": "#/definitions/Notification3" + }, + { + "$ref": "#/definitions/Notification4" + }, + { + "$ref": "#/definitions/NotificationNoParam" + }, + { + "$ref": "#/definitions/NotificationNoParam2" + }, + { + "$ref": "#/definitions/NotificationNoParam3" + }, + { + "$ref": "#/definitions/Notification5" + }, + { + "$ref": "#/definitions/Notification6" + }, + { + "$ref": "#/definitions/CustomNotification" + } + ], + "required": [ + "jsonrpc" + ] + }, + "JsonRpcRequest": { + "type": "object", + "properties": { + "id": { + "$ref": "#/definitions/NumberOrString" + }, + "jsonrpc": { + "$ref": "#/definitions/JsonRpcVersion2_0" + } + }, + "anyOf": [ + { + "$ref": "#/definitions/RequestNoParam" + }, + { + "$ref": "#/definitions/Request" + }, + { + "$ref": "#/definitions/RequestNoParam2" + }, + { + "$ref": "#/definitions/Request2" + }, + { + "$ref": "#/definitions/CustomRequest" + } + ], + "required": [ + "jsonrpc", + "id" + ] + }, + "JsonRpcResponse": { + "type": "object", + "properties": { + "id": { + "$ref": "#/definitions/NumberOrString" + }, + "jsonrpc": { + "$ref": "#/definitions/JsonRpcVersion2_0" + }, + "result": { + "$ref": "#/definitions/ServerResult" + } + }, + "required": [ + "jsonrpc", + "id", + "result" + ] + }, + "JsonRpcVersion2_0": { + "type": "string", + "format": "const", + "const": "2.0" + }, + "LegacyEnumSchema": { + "description": "Legacy enum schema, keep for backward compatibility", + "type": "object", + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "enumNames": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/StringTypeConst" + } + }, + "required": [ + "type", + "enum" + ] + }, + "ListPromptsResult": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + }, + "prompts": { + "type": "array", + "items": { + "$ref": "#/definitions/Prompt" + } + }, + "resultType": { + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "prompts" + ] + }, + "ListResourceTemplatesResult": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + }, + "resourceTemplates": { + "type": "array", + "items": { + "$ref": "#/definitions/ResourceTemplate" + } + }, + "resultType": { + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "resourceTemplates" + ] + }, + "ListResourcesResult": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + }, + "resources": { + "type": "array", + "items": { + "$ref": "#/definitions/Resource" + } + }, + "resultType": { + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "resources" + ] + }, + "ListRootsRequestMethod": { + "type": "string", + "format": "const", + "const": "roots/list" + }, + "ListToolsResult": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + }, + "resultType": { + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + }, + "tools": { + "type": "array", + "items": { + "$ref": "#/definitions/Tool" + } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "tools" + ] + }, + "LoggingLevel": { + "description": "Logging levels supported by the MCP protocol", + "type": "string", + "deprecated": true, + "enum": [ + "debug", + "info", + "notice", + "warning", + "error", + "critical", + "alert", + "emergency" + ] + }, + "LoggingMessageNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/message" + }, + "LoggingMessageNotificationParam": { + "description": "Parameters for a logging message notification", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] + }, + "data": { + "description": "The actual log data" + }, + "level": { + "description": "The severity level of this log message", + "allOf": [ + { + "$ref": "#/definitions/LoggingLevel" + } + ] + }, + "logger": { + "description": "Optional logger name that generated this message", + "type": [ + "string", + "null" + ] + } + }, + "deprecated": true, + "required": [ + "level", + "data" + ] + }, + "MetaObject": { + "description": "See [MCP general fields](https://modelcontextprotocol.io/specification/2026-07-28/basic#general-fields) for notes on _meta usage.", + "type": "object", + "additionalProperties": true + }, + "ModelHint": { + "description": "A hint suggesting a preferred model name or family.\n\nModel hints are advisory suggestions that help clients choose appropriate\nmodels. They can be specific model names or general families like \"claude\" or \"gpt\".", + "type": "object", + "properties": { + "name": { + "description": "The suggested model name or family identifier", + "type": [ + "string", + "null" + ] + } + }, + "deprecated": true + }, + "ModelPreferences": { + "description": "Preferences for model selection and behavior in sampling requests.\n\nThis allows servers to express their preferences for which model to use\nand how to balance different priorities when the client has multiple\nmodel options available.", + "type": "object", + "properties": { + "costPriority": { + "description": "Priority for cost optimization (0.0 to 1.0, higher = prefer cheaper models)", + "type": [ + "number", + "null" + ], + "format": "float" + }, + "hints": { + "description": "Specific model names or families to prefer (e.g., \"claude\", \"gpt\")", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/ModelHint" + } + }, + "intelligencePriority": { + "description": "Priority for intelligence/capability (0.0 to 1.0, higher = prefer more capable models)", + "type": [ + "number", + "null" + ], + "format": "float" + }, + "speedPriority": { + "description": "Priority for speed/latency (0.0 to 1.0, higher = prefer faster models)", + "type": [ + "number", + "null" + ], + "format": "float" + } + }, + "deprecated": true + }, + "MultiSelectEnumSchema": { + "description": "Multi-select enum options", + "anyOf": [ + { + "$ref": "#/definitions/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/definitions/TitledMultiSelectEnumSchema" + } + ] + }, + "Notification": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/CancelledNotificationMethod" + }, + "params": { + "$ref": "#/definitions/CancelledNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, + "Notification2": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ProgressNotificationMethod" + }, + "params": { + "$ref": "#/definitions/ProgressNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, + "Notification3": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/LoggingMessageNotificationMethod" + }, + "params": { + "$ref": "#/definitions/LoggingMessageNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, + "Notification4": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ResourceUpdatedNotificationMethod" + }, + "params": { + "$ref": "#/definitions/ResourceUpdatedNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, + "Notification5": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/SubscriptionsAcknowledgedNotificationMethod" + }, + "params": { + "$ref": "#/definitions/SubscriptionsAcknowledgedNotificationParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Notification6": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/TaskStatusNotificationMethod" + }, + "params": { + "$ref": "#/definitions/TaskStatusNotificationParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "NotificationMetaObject": { + "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", + "type": "object", + "properties": { + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/definitions/NumberOrString" + } + }, + "additionalProperties": true + }, + "NotificationNoParam": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ResourceListChangedNotificationMethod" + } + }, + "required": [ + "method" + ] + }, + "NotificationNoParam2": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ToolListChangedNotificationMethod" + } + }, + "required": [ + "method" + ] + }, + "NotificationNoParam3": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/PromptListChangedNotificationMethod" + } + }, + "required": [ + "method" + ] + }, + "NumberOrString": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "NumberSchema": { + "description": "Schema definition for number properties (floating-point).\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", + "type": "object", + "properties": { + "default": { + "description": "Default value", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "description": { + "description": "Human-readable description", + "type": [ + "string", + "null" + ] + }, + "maximum": { + "description": "Maximum value (inclusive)", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "minimum": { + "description": "Minimum value (inclusive)", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Type discriminator", + "allOf": [ + { + "$ref": "#/definitions/NumberTypeConst" + } + ] + } + }, + "required": [ + "type" + ] + }, + "NumberTypeConst": { + "type": "string", + "format": "const", + "const": "number" + }, + "ObjectTypeConst": { + "type": "string", + "format": "const", + "const": "object" + }, + "PingRequestMethod": { + "type": "string", + "format": "const", + "const": "ping" + }, + "PrimitiveSchemaDefinition": { + "description": "Primitive schema definition for elicitation properties.\n\nAccording to MCP 2025-06-18 specification, elicitation schemas must have\nproperties of primitive types only (string, number, integer, boolean, enum).\n\nNote: Put Enum as the first variant to avoid ambiguity during deserialization.\nThis is due to the fact that EnumSchema can contain StringSchema internally and serde\nuses first match wins strategy when deserializing untagged enums.", + "anyOf": [ + { + "description": "Enum property (explicit enum schema)", + "allOf": [ + { + "$ref": "#/definitions/EnumSchema" + } + ] + }, + { + "description": "String property (with optional enum constraint)", + "allOf": [ + { + "$ref": "#/definitions/StringSchema" + } + ] + }, + { + "description": "Number property (with optional enum constraint)", + "allOf": [ + { + "$ref": "#/definitions/NumberSchema" + } + ] + }, + { + "description": "Integer property (with optional enum constraint)", + "allOf": [ + { + "$ref": "#/definitions/IntegerSchema" + } + ] + }, + { + "description": "Boolean property", + "allOf": [ + { + "$ref": "#/definitions/BooleanSchema" + } + ] + } + ] + }, + "ProgressNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/progress" + }, + "ProgressNotificationParam": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] + }, + "message": { + "description": "An optional message describing the current progress.", + "type": [ + "string", + "null" + ] + }, + "progress": { + "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", + "type": "number", + "format": "double" + }, + "progressToken": { + "$ref": "#/definitions/ProgressToken" + }, + "total": { + "description": "Total number of items to process (or total progress required), if known", + "type": [ + "number", + "null" + ], + "format": "double" + } + }, + "required": [ + "progressToken", + "progress" + ] + }, + "ProgressToken": { + "description": "A token used to track the progress of long-running operations.\n\nProgress tokens allow clients and servers to associate progress notifications\nwith specific requests, enabling real-time updates on operation status.", + "allOf": [ + { + "$ref": "#/definitions/NumberOrString" + } + ] + }, + "Prompt": { + "description": "A prompt or prompt template that the server offers (spec `Prompt`).", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "arguments": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/PromptArgument" + } + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name" + ] + }, + "PromptArgument": { + "description": "Describes an argument that a prompt can accept (spec `PromptArgument`).", + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "required": { + "type": [ + "boolean", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name" + ] + }, + "PromptListChangedNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/prompts/list_changed" + }, + "PromptMessage": { + "description": "A message returned as part of a prompt (spec `PromptMessage`).\n\nUses the unified `ContentBlock` for its content (text | image | audio | resource_link | resource).", + "type": "object", + "properties": { + "content": { + "$ref": "#/definitions/ContentBlock" + }, + "role": { + "$ref": "#/definitions/Role" + } + }, + "required": [ + "role", + "content" + ] + }, + "PromptsCapability": { + "type": "object", + "properties": { + "listChanged": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "ProtocolVersion": { + "description": "Represents the MCP protocol version used for communication.\n\nThis ensures compatibility between clients and servers by specifying\nwhich version of the Model Context Protocol is being used.", + "type": "string" + }, + "ReadResourceResult": { + "description": "Result containing the contents of a read resource", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, + "contents": { + "description": "The actual content of the resource", + "type": "array", + "items": { + "$ref": "#/definitions/ResourceContents" + } + }, + "resultType": { + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", + "anyOf": [ + { + "$ref": "#/definitions/ResultType" + }, + { + "type": "null" + } + ] + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "contents" + ] + }, + "Request": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/CreateMessageRequestMethod" + }, + "params": { + "$ref": "#/definitions/CreateMessageRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request2": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ElicitationCreateRequestMethod" + }, + "params": { + "$ref": "#/definitions/ElicitRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "RequestMetaObject": { + "description": "Metadata reserved by MCP on requests. Extension keys are also allowed.", + "type": "object", + "properties": { + "io.modelcontextprotocol/clientCapabilities": { + "$ref": "#/definitions/ClientCapabilities" + }, + "io.modelcontextprotocol/clientInfo": { + "$ref": "#/definitions/Implementation" + }, + "io.modelcontextprotocol/logLevel": { + "$ref": "#/definitions/LoggingLevel" + }, + "io.modelcontextprotocol/protocolVersion": { + "type": "string" + }, + "progressToken": { + "$ref": "#/definitions/ProgressToken" + } + }, + "additionalProperties": true + }, + "RequestNoParam": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/PingRequestMethod" + } + }, + "required": [ + "method" + ] + }, + "RequestNoParam2": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ListRootsRequestMethod" + } + }, + "required": [ + "method" + ] + }, + "Resource": { + "description": "A known resource that the server is capable of reading (spec `Resource`).\n\nAlso used as the inner type of `ContentBlock::ResourceLink` (spec `ResourceLink extends Resource`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this resource.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this resource.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "description": "Optional description of what this resource represents.", + "type": [ + "string", + "null" + ] + }, + "icons": { + "description": "Optional set of icons the client may display for this resource.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "The programmatic name of the resource.", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content in bytes (before base64/tokenization), if known.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "title": { + "description": "Optional human-readable display title.", + "type": [ + "string", + "null" + ] + }, + "uri": { + "description": "The URI of this resource (e.g. `file:///path/to/file`).", + "type": "string" + } + }, + "required": [ + "uri", + "name" + ] + }, + "ResourceContents": { + "description": "The contents of a specific resource or sub-resource.", + "anyOf": [ + { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "uri", + "text" + ] + }, + { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "blob": { + "type": "string" + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "uri", + "blob" + ] + } + ] + }, + "ResourceListChangedNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/resources/list_changed" + }, + "ResourceTemplate": { + "description": "A template description for resources available on the server (spec `ResourceTemplate`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this resource template.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this template.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "description": "Optional description of what this template is for.", + "type": [ + "string", + "null" + ] + }, + "icons": { + "description": "Optional set of icons the client may display for this template.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "mimeType": { + "description": "The MIME type for resources matching this template, if uniform.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "The programmatic name of the resource template.", + "type": "string" + }, + "title": { + "description": "Optional human-readable display title.", + "type": [ + "string", + "null" + ] + }, + "uriTemplate": { + "description": "An RFC 6570 URI template for constructing resource URIs.", + "type": "string" + } + }, + "required": [ + "uriTemplate", + "name" + ] + }, + "ResourceUpdatedNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/resources/updated" + }, + "ResourceUpdatedNotificationParam": { + "description": "Parameters for a resource update notification", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] + }, + "uri": { + "description": "The URI of the resource that was updated", + "type": "string" + } + }, + "required": [ + "uri" + ] + }, + "ResourcesCapability": { + "type": "object", + "properties": { + "listChanged": { + "type": [ + "boolean", + "null" + ] + }, + "subscribe": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "ResultType": { + "description": "Indicates the type of a result object, allowing the client to\ndetermine how to parse the response.\n\nThe spec defines this as an open string (`\"complete\" | \"input_required\" | string`),\nso unknown values are preserved rather than rejected. Servers implementing this\nprotocol version MUST include `resultType` in every result. For backward\ncompatibility, clients MUST treat an absent field as `\"complete\"`.\n\nOrdinary results model the field as `Option`: `None` means the\nfield is absent on the wire. Constructors default to `Some(COMPLETE)`, and\nthe server handler strips the `\"complete\"` discriminator before responding\nto peers that negotiated a protocol version older than `2026-07-28`, so\nlegacy sessions keep their historical wire shape (see\n[`ServerResult::strip_result_type_for_legacy_peer`]).", + "type": "string" + }, + "Role": { + "description": "Represents the role of a participant in a conversation or message exchange.\n\nUsed in sampling and chat contexts to distinguish between different\ntypes of message senders in the conversation flow.", + "oneOf": [ + { + "description": "A human user or client making a request", + "type": "string", + "const": "user" + }, + { + "description": "An AI assistant or server providing a response", + "type": "string", + "const": "assistant" + } + ] + }, + "RootsCapabilities": { + "description": "Roots capability. Deprecated by SEP-2577; remains functional and will be\nremoved in a future release.\nSee .", + "type": "object", + "properties": { + "listChanged": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "SamplingCapability": { + "description": "Sampling capability with optional sub-capabilities (SEP-1577).\n\nDeprecated by SEP-2577; remains functional and will be removed in a future\nrelease.\nSee .", + "type": "object", + "properties": { + "context": { + "description": "Support for `includeContext` (soft-deprecated)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "tools": { + "description": "Support for `tools` and `toolChoice` parameters", + "type": [ + "object", + "null" + ], + "additionalProperties": true + } + } + }, + "SamplingContent": { + "description": "Single or array content wrapper (SEP-1577).", + "anyOf": [ + { + "$ref": "#/definitions/SamplingMessageContentBlock" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/SamplingMessageContentBlock" + } + } + ] + }, + "SamplingMessage": { + "description": "A message in a sampling conversation, containing a role and content.\n\nThis represents a single message in a conversation flow, used primarily\nin LLM sampling requests where the conversation history is important\nfor generating appropriate responses.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "content": { + "description": "The actual content of the message (text, image, audio, tool use, or tool result)", + "allOf": [ + { + "$ref": "#/definitions/SamplingContent" + } + ] + }, + "role": { + "description": "The role of the message sender (User or Assistant)", + "allOf": [ + { + "$ref": "#/definitions/Role" + } + ] + } + }, + "deprecated": true, + "required": [ + "role", + "content" + ] + }, + "SamplingMessageContentBlock": { + "description": "Content types for sampling messages (SEP-1577).", + "deprecated": true, + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "allOf": [ + { + "$ref": "#/definitions/TextContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ImageContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "audio" + } + }, + "allOf": [ + { + "$ref": "#/definitions/AudioContent" + } + ], + "required": [ + "type" + ] + }, + { + "description": "Assistant only", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_use" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ToolUseContent" + } + ], + "required": [ + "type" + ] + }, + { + "description": "User only", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_result" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ToolResultContent" + } + ], + "required": [ + "type" + ] + } + ] + }, + "ServerCapabilities": { + "title": "Builder", + "description": "```rust\n# use rmcp::model::ServerCapabilities;\nlet cap = ServerCapabilities::builder()\n .enable_experimental()\n .enable_prompts()\n .enable_resources()\n .enable_tools()\n .enable_tool_list_changed()\n .build();\n```", + "type": "object", + "properties": { + "completions": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "experimental": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "extensions": { + "description": "Optional MCP extensions that the server supports (SEP-1724).\nKeys are extension identifiers (e.g., `\"io.modelcontextprotocol/apps\"`),\nvalues are per-extension settings objects. An empty object indicates\nsupport with no settings.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "logging": { + "description": "Capability for server log message notifications (deprecated by SEP-2577).", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "prompts": { + "anyOf": [ + { + "$ref": "#/definitions/PromptsCapability" + }, + { + "type": "null" + } + ] + }, + "resources": { + "anyOf": [ + { + "$ref": "#/definitions/ResourcesCapability" + }, + { + "type": "null" + } + ] + }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/ToolsCapability" + }, + { + "type": "null" + } + ] + } + } + }, + "ServerResult": { + "anyOf": [ + { + "$ref": "#/definitions/DiscoverResult" + }, + { + "$ref": "#/definitions/InitializeResult" + }, + { + "$ref": "#/definitions/CompleteResult" + }, + { + "$ref": "#/definitions/GetPromptResult" + }, + { + "$ref": "#/definitions/ListPromptsResult" + }, + { + "$ref": "#/definitions/ListResourcesResult" + }, + { + "$ref": "#/definitions/ListResourceTemplatesResult" + }, + { + "$ref": "#/definitions/ReadResourceResult" + }, + { + "$ref": "#/definitions/SubscriptionsListenResult" + }, + { + "$ref": "#/definitions/ListToolsResult" + }, + { + "$ref": "#/definitions/ElicitResult" + }, + { + "$ref": "#/definitions/CreateTaskResult" + }, + { + "$ref": "#/definitions/GetTaskResult" + }, + { + "$ref": "#/definitions/CallToolResult" + }, + { + "$ref": "#/definitions/InputRequiredResult" + }, + { + "$ref": "#/definitions/TaskAckResult" + }, + { + "$ref": "#/definitions/EmptyObject" + }, + { + "$ref": "#/definitions/CustomResult" + } + ] + }, + "SingleSelectEnumSchema": { + "description": "Combined single-select", + "anyOf": [ + { + "$ref": "#/definitions/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/definitions/TitledSingleSelectEnumSchema" + } + ] + }, + "StringFormat": { + "description": "String format types allowed by the MCP specification.", + "oneOf": [ + { + "description": "Email address format", + "type": "string", + "const": "email" + }, + { + "description": "URI format", + "type": "string", + "const": "uri" + }, + { + "description": "Date format (YYYY-MM-DD)", + "type": "string", + "const": "date" + }, + { + "description": "Date-time format (ISO 8601)", + "type": "string", + "const": "date-time" + } + ] + }, + "StringSchema": { + "description": "Schema definition for string properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec:\n- format limited to: \"email\", \"uri\", \"date\", \"date-time\"", + "type": "object", + "properties": { + "default": { + "description": "Default value", + "type": [ + "string", + "null" + ] + }, + "description": { + "description": "Human-readable description", + "type": [ + "string", + "null" + ] + }, + "format": { + "description": "String format - limited to: \"email\", \"uri\", \"date\", \"date-time\"", + "anyOf": [ + { + "$ref": "#/definitions/StringFormat" + }, + { + "type": "null" + } + ] + }, + "maxLength": { + "description": "Maximum string length", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "minLength": { + "description": "Minimum string length", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Type discriminator", + "allOf": [ + { + "$ref": "#/definitions/StringTypeConst" + } + ] + } + }, + "required": [ + "type" + ] + }, + "StringTypeConst": { + "type": "string", + "format": "const", + "const": "string" + }, + "SubscriptionFilter": { + "description": "Notification categories a client opts in to on a `subscriptions/listen` stream.", + "type": "object", + "properties": { + "promptsListChanged": { + "type": "boolean" + }, + "resourceSubscriptions": { + "type": "array", + "items": { + "type": "string" + } + }, + "resourcesListChanged": { + "type": "boolean" + }, + "toolsListChanged": { + "type": "boolean" + } + } + }, + "SubscriptionsAcknowledgedNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/subscriptions/acknowledged" + }, + "SubscriptionsAcknowledgedNotificationParams": { + "description": "Parameters reporting the accepted subset of a subscription filter.", + "type": "object", + "properties": { + "_meta": { + "$ref": "#/definitions/NotificationMetaObject" + }, + "notifications": { + "$ref": "#/definitions/SubscriptionFilter" + } + }, + "required": [ + "notifications" + ] + }, + "SubscriptionsListenResult": { + "description": "Final response indicating that a subscription ended gracefully.", + "type": "object", + "properties": { + "_meta": { + "$ref": "#/definitions/SubscriptionsListenResultMeta" + }, + "resultType": { + "$ref": "#/definitions/ResultType" + } + }, + "required": [ + "resultType", + "_meta" + ] + }, + "SubscriptionsListenResultMeta": { + "type": "object", + "properties": { + "io.modelcontextprotocol/serverInfo": { + "description": "Identifies the server software producing the response. Servers SHOULD include this field on every response unless specifically configured not to do so.", + "allOf": [ + { + "$ref": "#/definitions/Implementation" + } + ] + }, + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/definitions/NumberOrString" + } + }, + "additionalProperties": true, + "required": [ + "io.modelcontextprotocol/subscriptionId" + ] + }, + "TaskAckResult": { + "description": "Empty acknowledgement for `tasks/update` and `tasks/cancel` (SEP-2663).\n\nThe spec requires these acks to be empty results carrying the SEP-2322\n`resultType: \"complete\"` discriminator; task state changes are observed\nvia the next `tasks/get`.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "resultType": { + "description": "Always `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + } + }, + "required": [ + "resultType" + ] + }, + "TaskStatus": { + "description": "Canonical task lifecycle status (SEP-2663).", + "oneOf": [ + { + "description": "The request is currently being processed.", + "type": "string", + "const": "working" + }, + { + "description": "The server needs input from the client before the task can proceed.", + "type": "string", + "const": "input_required" + }, + { + "description": "The request completed successfully and the result is available.\nThis includes tool calls that returned results with `isError: true`.", + "type": "string", + "const": "completed" + }, + { + "description": "The request failed due to a JSON-RPC error during execution.", + "type": "string", + "const": "failed" + }, + { + "description": "The request was cancelled before completion.", + "type": "string", + "const": "cancelled" + } + ] + }, + "TaskStatusNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/tasks" + }, + "TaskStatusNotificationParams": { + "description": "Parameters for a task status notification (spec `TaskStatusNotificationParams`).\n\nCarries a complete [`DetailedTask`] for the current status, identical to\nwhat `tasks/get` would have returned at that moment. The task fields are\nflattened at the top level: `NotificationParams & Task`.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "description": "ISO 8601 timestamp when the task was created.", + "type": "string" + }, + "error": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "inputRequests": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputRequest" + } + }, + "lastUpdatedAt": { + "description": "ISO 8601 timestamp when the task was last updated.", + "type": "string" + }, + "pollIntervalMs": { + "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "result": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "status": { + "description": "Current task status.", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Stable identifier for this task, generated by the server.", + "type": "string" + }, + "ttlMs": { + "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, + "TextContent": { + "description": "Text content block (spec `TextContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "text": { + "description": "The text content of the message.", + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "TitledItems": { + "description": "Items for titled multi-select options", + "type": "object", + "properties": { + "anyOf": { + "type": "array", + "items": { + "$ref": "#/definitions/ConstTitle" + } + } + }, + "required": [ + "anyOf" + ] + }, + "TitledMultiSelectEnumSchema": { + "description": "Multi-select titled options", + "type": "object", + "properties": { + "default": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/TitledItems" + }, + "maxItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "minItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/ArrayTypeConst" + } + }, + "required": [ + "type", + "items" + ] + }, + "TitledSingleSelectEnumSchema": { + "description": "Titled single-select", + "type": "object", + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "oneOf": { + "type": "array", + "items": { + "$ref": "#/definitions/ConstTitle" + } + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/StringTypeConst" + } + }, + "required": [ + "type", + "oneOf" + ] + }, + "Tool": { + "description": "A tool that can be used by a model.", + "type": "object", + "properties": { + "_meta": { + "description": "Optional additional metadata for this tool", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "annotations": { + "description": "Optional additional tool information.", + "anyOf": [ + { + "$ref": "#/definitions/ToolAnnotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "description": "A description of what the tool does", + "type": [ + "string", + "null" + ] + }, + "icons": { + "description": "Optional list of icons for the tool", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "inputSchema": { + "description": "A JSON Schema object defining the expected parameters for the tool", + "type": "object", + "additionalProperties": true + }, + "name": { + "description": "The name of the tool", + "type": "string" + }, + "outputSchema": { + "description": "An optional JSON Schema object defining the structure of the tool's output", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "title": { + "description": "A human-readable title for the tool", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "inputSchema" + ] + }, + "ToolAnnotations": { + "description": "Additional properties describing a Tool to clients.\n\nNOTE: all properties in ToolAnnotations are **hints**.\nThey are not guaranteed to provide a faithful description of\ntool behavior (including descriptive properties like `title`).\n\nClients should never make tool use decisions based on ToolAnnotations\nreceived from untrusted servers.", + "type": "object", + "properties": { + "destructiveHint": { + "description": "If true, the tool may perform destructive updates to its environment.\nIf false, the tool performs only additive updates.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: true\nA human-readable description of the tool's purpose.", + "type": [ + "boolean", + "null" + ] + }, + "idempotentHint": { + "description": "If true, calling the tool repeatedly with the same arguments\nwill have no additional effect on the its environment.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: false.", + "type": [ + "boolean", + "null" + ] + }, + "openWorldHint": { + "description": "If true, this tool may interact with an \"open world\" of external\nentities. If false, the tool's domain of interaction is closed.\nFor example, the world of a web search tool is open, whereas that\nof a memory tool is not.\n\nDefault: true", + "type": [ + "boolean", + "null" + ] + }, + "readOnlyHint": { + "description": "If true, the tool does not modify its environment.\n\nDefault: false", + "type": [ + "boolean", + "null" + ] + }, + "title": { + "description": "A human-readable title for the tool.", + "type": [ + "string", + "null" + ] + } + } + }, + "ToolChoice": { + "description": "Tool choice configuration (SEP-1577).", + "type": "object", + "properties": { + "mode": { + "anyOf": [ + { + "$ref": "#/definitions/ToolChoiceMode" + }, + { + "type": "null" + } + ] + } + }, + "deprecated": true + }, + "ToolChoiceMode": { + "description": "Tool selection mode (SEP-1577).", + "oneOf": [ + { + "description": "Model decides whether to use tools", + "type": "string", + "const": "auto" + }, + { + "description": "Model must use at least one tool", + "type": "string", + "const": "required" + }, + { + "description": "Model must not use tools", + "type": "string", + "const": "none" + } + ] + }, + "ToolListChangedNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/tools/list_changed" + }, + "ToolResultContent": { + "description": "Tool execution result in user message (SEP-1577).", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/ContentBlock" + } + }, + "isError": { + "type": [ + "boolean", + "null" + ] + }, + "structuredContent": true, + "toolUseId": { + "type": "string" + } + }, + "deprecated": true, + "required": [ + "toolUseId", + "content" + ] + }, + "ToolUseContent": { + "description": "Tool call request from assistant (SEP-1577).", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "input": { + "type": "object", + "additionalProperties": true + }, + "name": { + "type": "string" + } + }, + "deprecated": true, + "required": [ + "id", + "name", + "input" + ] + }, + "ToolsCapability": { + "type": "object", + "properties": { + "listChanged": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "UntitledItems": { + "description": "Items for untitled multi-select options", + "type": "object", + "properties": { + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "$ref": "#/definitions/StringTypeConst" + } + }, + "required": [ + "type", + "enum" + ] + }, + "UntitledMultiSelectEnumSchema": { + "description": "Multi-select untitled options", + "type": "object", + "properties": { + "default": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/UntitledItems" + }, + "maxItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "minItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/ArrayTypeConst" + } + }, + "required": [ + "type", + "items" + ] + }, + "UntitledSingleSelectEnumSchema": { + "description": "Untitled single-select", + "type": "object", + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/StringTypeConst" + } + }, + "additionalProperties": false, + "required": [ + "type", + "enum" + ] + }, + "UrlElicitationCapability": { + "description": "Capability for URL mode elicitation.", + "type": "object" + } + } +} \ No newline at end of file diff --git a/vendor/rmcp/tests/test_meta_helpers.rs b/vendor/rmcp/tests/test_meta_helpers.rs new file mode 100644 index 000000000..5f9128a42 --- /dev/null +++ b/vendor/rmcp/tests/test_meta_helpers.rs @@ -0,0 +1,98 @@ +#![allow(deprecated)] + +use rmcp::model::{ + ClientCapabilities, Implementation, LoggingLevel, ProtocolVersion, RequestMetaObject, +}; +use serde_json::json; + +const META_KEY_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion"; +const META_KEY_CLIENT_INFO: &str = "io.modelcontextprotocol/clientInfo"; +const META_KEY_CLIENT_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities"; +const META_KEY_LOG_LEVEL: &str = "io.modelcontextprotocol/logLevel"; + +#[test] +fn meta_setters_store_sep_2575_values() { + let mut meta = RequestMetaObject::new(); + meta.set_protocol_version(ProtocolVersion::V_2026_07_28); + meta.set_client_info(Implementation::new("test-client", "1.0.0")); + meta.set_client_capabilities(ClientCapabilities::default()); + meta.set_log_level(LoggingLevel::Warning); + + assert_eq!( + meta.get(META_KEY_PROTOCOL_VERSION), + Some(&json!("2026-07-28")) + ); + assert_eq!( + meta.get(META_KEY_CLIENT_INFO), + Some(&json!({ "name": "test-client", "version": "1.0.0" })) + ); + assert_eq!(meta.get(META_KEY_CLIENT_CAPABILITIES), Some(&json!({}))); + assert_eq!(meta.get(META_KEY_LOG_LEVEL), Some(&json!("warning"))); +} + +#[test] +fn with_client_context_sets_all_required_sep_2575_fields() { + let meta = RequestMetaObject::with_client_context( + ProtocolVersion::V_2026_07_28, + Implementation::new("test-client", "1.0.0"), + ClientCapabilities::default(), + ); + + assert!( + meta.missing_required_keys(&ProtocolVersion::V_2026_07_28) + .is_empty() + ); + assert_eq!(meta.protocol_version(), Some(ProtocolVersion::V_2026_07_28)); + assert_eq!( + meta.client_info(), + Some(Implementation::new("test-client", "1.0.0")) + ); + assert_eq!( + meta.client_capabilities(), + Some(ClientCapabilities::default()) + ); +} + +#[test] +fn meta_accessors_decode_wire_values() { + let meta: RequestMetaObject = serde_json::from_value(json!({ + "progressToken": "progress-1", + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "wire-client", + "version": "9.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": { + "sampling": {} + }, + "io.modelcontextprotocol/logLevel": "error" + })) + .unwrap(); + + assert_eq!(meta.protocol_version(), Some(ProtocolVersion::V_2026_07_28)); + assert_eq!( + meta.client_info(), + Some(Implementation::new("wire-client", "9.0.0")) + ); + assert!( + meta.client_capabilities() + .is_some_and(|capabilities| capabilities.sampling.is_some()) + ); + assert_eq!(meta.log_level(), Some(LoggingLevel::Error)); +} + +#[test] +fn meta_accessors_ignore_missing_or_malformed_values() { + let meta: RequestMetaObject = serde_json::from_value(json!({ + "io.modelcontextprotocol/protocolVersion": 20260728, + "io.modelcontextprotocol/clientInfo": "not an implementation", + "io.modelcontextprotocol/clientCapabilities": "not capabilities", + "io.modelcontextprotocol/logLevel": "loud" + })) + .unwrap(); + + assert_eq!(meta.protocol_version(), None); + assert_eq!(meta.client_info(), None); + assert_eq!(meta.client_capabilities(), None); + assert_eq!(meta.log_level(), None); +} diff --git a/vendor/rmcp/tests/test_mrtr_behavior.rs b/vendor/rmcp/tests/test_mrtr_behavior.rs new file mode 100644 index 000000000..33332c2b7 --- /dev/null +++ b/vendor/rmcp/tests/test_mrtr_behavior.rs @@ -0,0 +1,595 @@ +//! Behavior and edge-case coverage for SEP-2322 multi round-trip requests (MRTR). +//! +//! These tests drive a real client/server pair over an in-memory duplex stream +//! and exercise the auto fulfill/retry loop, the manual `*_once` escape hatch, +//! and the server-side version gating. + +// Sampling/Roots are SEP-2577-deprecated but still used to model MRTR input requests. +#![allow(deprecated)] +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use rmcp::{ + ClientHandler, ServerHandler, + model::*, + service::{RequestContext, RoleClient, RoleServer, ServiceError, serve_directly}, +}; +use serde_json::json; + +/// A `requestState` value with characters that must survive a byte-exact echo: +/// dots (the codec delimiter), base64 punctuation, whitespace, and quotes. +const TRICKY_STATE: &str = "st.ate/with+special=chars and spaces \"quotes\"\n\ttab"; + +// ============================================================================= +// Test handlers +// ============================================================================= + +/// A stateless MRTR server whose behavior is selected by the tool/prompt/resource +/// name. Round progression is derived entirely from `request_state` and +/// `input_responses`, as required by the stateless MRTR pattern. +#[derive(Clone, Default)] +struct MrtrServer { + calls: Arc, +} + +fn elicitation_request(message: &str) -> InputRequest { + InputRequest::Elicitation(ElicitRequest::new( + ElicitRequestParams::FormElicitationParams { + meta: None, + message: message.into(), + requested_schema: serde_json::from_value(json!({ + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"] + })) + .unwrap(), + }, + )) +} + +fn sampling_request() -> InputRequest { + InputRequest::CreateMessage(CreateMessageRequest::new(CreateMessageRequestParams::new( + vec![SamplingMessage::user_text("What is the capital of France?")], + 100, + ))) +} + +fn roots_request() -> InputRequest { + InputRequest::ListRoots(ListRootsRequest::default()) +} + +fn single_elicitation(state: &str) -> InputRequiredResult { + let mut requests = InputRequests::new(); + requests.insert("answer".to_string(), elicitation_request("Name?")); + InputRequiredResult::new(Some(requests), Some(state.into())) +} + +impl MrtrServer { + fn call_tool_impl( + &self, + request: CallToolRequestParams, + ) -> Result { + let responses = request.input_responses.as_ref(); + let state = request.request_state.as_deref(); + match request.name.as_ref() { + // Single round: one elicitation, then complete. + "single" => match responses { + None => Ok(single_elicitation("state-single").into()), + Some(map) => { + if state != Some("state-single") { + return Err(ErrorData::internal_error("request_state not echoed", None)); + } + let answer = map + .get("answer") + .ok_or_else(|| ErrorData::internal_error("missing answer", None))?; + if answer["action"] != "accept" || answer["content"]["name"] != "Ferris" { + return Err(ErrorData::internal_error("unexpected elicit result", None)); + } + Ok(CallToolResult::success(vec![ContentBlock::text("done")]).into()) + } + }, + // Two elicitation rounds before completing. + "multi_round" => match state { + None => Ok(single_elicitation("round-1").into()), + Some("round-1") => Ok(single_elicitation("round-2").into()), + Some("round-2") => { + Ok(CallToolResult::success(vec![ContentBlock::text("multi-done")]).into()) + } + Some(other) => Err(ErrorData::internal_error( + format!("unexpected round state {other:?}"), + None, + )), + }, + // Several input requests fulfilled concurrently in a single round. + "multi_request" => match responses { + None => { + let mut requests = InputRequests::new(); + requests.insert("form".to_string(), elicitation_request("Name?")); + requests.insert("sample".to_string(), sampling_request()); + requests.insert("roots".to_string(), roots_request()); + Ok(InputRequiredResult::new(Some(requests), Some("multi-req".into())).into()) + } + Some(map) => { + for key in ["form", "sample", "roots"] { + if !map.contains_key(key) { + return Err(ErrorData::internal_error( + format!("missing response for {key}"), + None, + )); + } + } + Ok(CallToolResult::success(vec![ContentBlock::text("multi-req-done")]).into()) + } + }, + // State-only load shedding: two state-only rounds, then complete. + "state_only" => match state { + None => Ok(InputRequiredResult::from_request_state("so-1").into()), + Some("so-1") => Ok(InputRequiredResult::from_request_state("so-2").into()), + Some("so-2") => { + Ok(CallToolResult::success(vec![ContentBlock::text("state-done")]).into()) + } + Some(other) => Err(ErrorData::internal_error( + format!("unexpected state {other:?}"), + None, + )), + }, + // Never completes: used to exercise the max-rounds cap. + "loops" => Ok(single_elicitation("loop").into()), + // Triggers a failure inside the client's elicitation handler. + "handler_error" => { + let mut requests = InputRequests::new(); + requests.insert("answer".to_string(), elicitation_request("FAIL")); + Ok(InputRequiredResult::new(Some(requests), Some("state".into())).into()) + } + // Verifies the client echoes `request_state` byte-for-byte. + "echo_state" => match responses { + None => { + let mut requests = InputRequests::new(); + requests.insert("answer".to_string(), elicitation_request("Name?")); + Ok(InputRequiredResult::new(Some(requests), Some(TRICKY_STATE.into())).into()) + } + Some(_) => { + if state != Some(TRICKY_STATE) { + return Err(ErrorData::internal_error( + "request_state was not echoed byte-exact", + None, + )); + } + Ok(CallToolResult::success(vec![ContentBlock::text("echo-ok")]).into()) + } + }, + _ => Ok(CallToolResult::success(vec![ContentBlock::text("noop")]).into()), + } + } +} + +impl ServerHandler for MrtrServer { + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_prompts() + .build(), + ); + info.protocol_version = ProtocolVersion::V_2026_07_28; + info + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + self.call_tool_impl(request) + } + + async fn get_prompt( + &self, + request: GetPromptRequestParams, + _context: RequestContext, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + match request.request_state.as_deref() { + None => Ok(single_elicitation("prompt-1").into()), + Some("prompt-1") => Ok(GetPromptResult::new(vec![PromptMessage::new_text( + Role::Assistant, + "prompt-done", + )]) + .into()), + Some(other) => Err(ErrorData::internal_error( + format!("unexpected prompt state {other:?}"), + None, + )), + } + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + match request.request_state.as_deref() { + None => Ok(single_elicitation("res-1").into()), + Some("res-1") => Ok(ReadResourceResult::new(vec![ResourceContents::text( + "resource-done", + request.uri, + )]) + .into()), + Some(other) => Err(ErrorData::internal_error( + format!("unexpected resource state {other:?}"), + None, + )), + } + } +} + +/// A client that fulfills every kind of MRTR input request. Elicitation fails +/// deliberately when the prompt message is `"FAIL"`. +#[derive(Clone, Default)] +struct MrtrClient; + +impl ClientHandler for MrtrClient { + async fn create_elicitation( + &self, + request: ElicitRequestParams, + _context: RequestContext, + ) -> Result { + if let ElicitRequestParams::FormElicitationParams { message, .. } = &request + && message == "FAIL" + { + return Err(ErrorData::internal_error( + "elicitation handler failed", + None, + )); + } + Ok(ElicitResult::new(ElicitationAction::Accept).with_content(json!({ "name": "Ferris" }))) + } + + async fn create_message( + &self, + _request: CreateMessageRequestParams, + _context: RequestContext, + ) -> Result { + Ok(CreateMessageResult::new( + SamplingMessage::assistant_text("Paris."), + "test-model".into(), + ) + .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN)) + } + + async fn list_roots( + &self, + _context: RequestContext, + ) -> Result { + Ok(ListRootsResult::new(vec![Root::new("file:///workspace")])) + } +} + +// ============================================================================= +// Harness +// ============================================================================= + +fn client_info(protocol_version: ProtocolVersion) -> ClientInfo { + ClientInfo::new( + ClientCapabilities::builder().enable_elicitation().build(), + Implementation::new("mrtr-test-client", "0.0.0"), + ) + .with_protocol_version(protocol_version) +} + +fn server_info(protocol_version: ProtocolVersion) -> ServerInfo { + let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build()); + info.protocol_version = protocol_version; + info +} + +/// Runs `body` inside a `LocalSet` so `spawn_local` (used when the `local` +/// feature is active) is available, wiring up a connected client/server pair. +async fn with_pair( + server: MrtrServer, + client_protocol: ProtocolVersion, + body: F, +) -> anyhow::Result<()> +where + F: FnOnce(rmcp::service::RunningService) -> Fut, + Fut: std::future::Future>, +{ + tokio::task::LocalSet::new() + .run_until(async move { + let (server_transport, client_transport) = tokio::io::duplex(8192); + let server_peer_info = client_info(client_protocol); + let client_peer_info = server_info(server_peer_info.protocol_version.clone()); + let server_task = tokio::task::spawn_local(async move { + let running = serve_directly::( + server, + server_transport, + Some(server_peer_info), + ); + running.waiting().await?; + anyhow::Ok(()) + }); + + let client = serve_directly::( + MrtrClient, + client_transport, + Some(client_peer_info.into()), + ); + + let result = body(client).await; + + server_task.abort(); + result + }) + .await +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[tokio::test(flavor = "current_thread")] +async fn client_auto_fulfills_input_required_tool_call() -> anyhow::Result<()> { + let server = MrtrServer::default(); + let calls = server.calls.clone(); + with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { + let result = client + .call_tool(CallToolRequestParams::new("single")) + .await?; + assert_eq!(result.content[0].as_text().unwrap().text, "done"); + assert_eq!(calls.load(Ordering::SeqCst), 2); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "current_thread")] +async fn manual_once_returns_input_required_without_retry() -> anyhow::Result<()> { + let server = MrtrServer::default(); + let calls = server.calls.clone(); + with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { + let result = client + .call_tool_once(CallToolRequestParams::new("single")) + .await?; + assert!(matches!(result, CallToolResponse::InputRequired(_))); + // A manual round makes exactly one server call and never retries. + assert_eq!(calls.load(Ordering::SeqCst), 1); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "current_thread")] +async fn multi_round_input_required_completes() -> anyhow::Result<()> { + let server = MrtrServer::default(); + let calls = server.calls.clone(); + with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { + let result = client + .call_tool(CallToolRequestParams::new("multi_round")) + .await?; + assert_eq!(result.content[0].as_text().unwrap().text, "multi-done"); + // round 0 + two retries = 3 server calls. + assert_eq!(calls.load(Ordering::SeqCst), 3); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "current_thread")] +async fn multiple_input_requests_fulfilled_in_one_round() -> anyhow::Result<()> { + let server = MrtrServer::default(); + let calls = server.calls.clone(); + with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { + let result = client + .call_tool(CallToolRequestParams::new("multi_request")) + .await?; + assert_eq!(result.content[0].as_text().unwrap().text, "multi-req-done"); + assert_eq!(calls.load(Ordering::SeqCst), 2); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "current_thread")] +async fn state_only_input_required_completes() -> anyhow::Result<()> { + let server = MrtrServer::default(); + let calls = server.calls.clone(); + with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { + let result = client + .call_tool(CallToolRequestParams::new("state_only")) + .await?; + assert_eq!(result.content[0].as_text().unwrap().text, "state-done"); + // round 0 + two state-only retries = 3 server calls. + assert_eq!(calls.load(Ordering::SeqCst), 3); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "current_thread")] +async fn max_rounds_exceeded_returns_error() -> anyhow::Result<()> { + let server = MrtrServer::default(); + let calls = server.calls.clone(); + with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { + let err = client + .call_tool_with_mrtr_max_rounds(CallToolRequestParams::new("loops"), 3) + .await + .expect_err("a tool that never completes must exhaust the round cap"); + assert!(matches!( + err, + ServiceError::InputRequiredRoundsExceeded { max_rounds: 3 } + )); + assert_eq!(calls.load(Ordering::SeqCst), 3); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "current_thread")] +async fn client_handler_error_propagates() -> anyhow::Result<()> { + let server = MrtrServer::default(); + with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { + let err = client + .call_tool(CallToolRequestParams::new("handler_error")) + .await + .expect_err("a failing input handler must fail the whole call"); + assert!(matches!(err, ServiceError::McpError(_))); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "current_thread")] +async fn request_state_is_echoed_byte_exact() -> anyhow::Result<()> { + let server = MrtrServer::default(); + with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { + // The server returns an error result unless it sees TRICKY_STATE echoed + // back unchanged, so a successful completion proves the byte-exact echo. + let result = client + .call_tool(CallToolRequestParams::new("echo_state")) + .await?; + assert_eq!(result.content[0].as_text().unwrap().text, "echo-ok"); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "current_thread")] +async fn get_prompt_auto_fulfills_input_required() -> anyhow::Result<()> { + let server = MrtrServer::default(); + with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { + let result = client.get_prompt(GetPromptRequestParams::new("p")).await?; + assert_eq!( + result.messages[0].content.as_text().unwrap().text, + "prompt-done" + ); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "current_thread")] +async fn read_resource_auto_fulfills_input_required() -> anyhow::Result<()> { + let server = MrtrServer::default(); + with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { + let result = client + .read_resource(ReadResourceRequestParams::new("res://x")) + .await?; + let text = match &result.contents[0] { + ResourceContents::TextResourceContents { text, .. } => text.clone(), + _ => panic!("expected text resource"), + }; + assert_eq!(text, "resource-done"); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "current_thread")] +async fn old_protocol_rejects_input_required() -> anyhow::Result<()> { + let server = MrtrServer::default(); + // The client negotiated 2025-11-25, so the server must refuse to emit an + // InputRequiredResult and return a protocol error instead. + with_pair(server, ProtocolVersion::V_2025_11_25, |client| async move { + let err = client + .call_tool_once(CallToolRequestParams::new("single")) + .await + .expect_err("MRTR must be rejected for pre-2026 peers"); + match err { + ServiceError::McpError(error) => { + assert!( + error.message.contains("2026-07-28"), + "unexpected error message: {}", + error.message + ); + } + other => panic!("expected an McpError, got {other:?}"), + } + Ok(()) + }) + .await +} + +#[cfg(feature = "request-state")] +#[tokio::test(flavor = "current_thread")] +async fn request_state_codec_seals_and_verifies_through_the_loop() -> anyhow::Result<()> { + use std::sync::OnceLock; + + use rmcp::model::RequestStateCodec; + + // A shared per-process signing key, mirroring how a real server would derive one. + static KEY: &[u8] = b"integration-signing-key-32-bytes!"; + + fn codec() -> &'static RequestStateCodec { + static CODEC: OnceLock = OnceLock::new(); + CODEC.get_or_init(|| RequestStateCodec::new(KEY)) + } + + #[derive(Clone, Default)] + struct SealingServer; + + impl ServerHandler for SealingServer { + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build()); + info.protocol_version = ProtocolVersion::V_2026_07_28; + info + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + match request.request_state { + None => { + let sealed = codec() + .seal_json(&json!({ "step": 1, "tool": request.name })) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let mut requests = InputRequests::new(); + requests.insert("answer".to_string(), elicitation_request("Name?")); + Ok(InputRequiredResult::new(Some(requests), Some(sealed)).into()) + } + Some(sealed) => { + // The echoed state is untrusted; verify it before use. + let state: serde_json::Value = codec() + .open_json(&sealed) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + assert_eq!(state["step"], 1); + Ok(CallToolResult::success(vec![ContentBlock::text("sealed-done")]).into()) + } + } + } + } + + tokio::task::LocalSet::new() + .run_until(async move { + let (server_transport, client_transport) = tokio::io::duplex(8192); + let server_task = tokio::task::spawn_local(async move { + let running = serve_directly::( + SealingServer, + server_transport, + Some(client_info(ProtocolVersion::V_2026_07_28)), + ); + running.waiting().await?; + anyhow::Ok(()) + }); + + let client = serve_directly::( + MrtrClient, + client_transport, + Some(server_info(ProtocolVersion::V_2026_07_28).into()), + ); + + let result = client + .call_tool(CallToolRequestParams::new("sealed")) + .await?; + assert_eq!(result.content[0].as_text().unwrap().text, "sealed-done"); + + server_task.abort(); + anyhow::Ok(()) + }) + .await +} diff --git a/vendor/rmcp/tests/test_notification.rs b/vendor/rmcp/tests/test_notification.rs new file mode 100644 index 000000000..9aafd3ccd --- /dev/null +++ b/vendor/rmcp/tests/test_notification.rs @@ -0,0 +1,234 @@ +#![cfg(not(feature = "local"))] +#![allow(deprecated)] +use std::sync::Arc; + +use rmcp::{ + ClientHandler, ServerHandler, ServiceExt, + model::{ + ClientNotification, CustomNotification, ResourceUpdatedNotificationParam, + ServerCapabilities, ServerInfo, ServerNotification, SubscribeRequestParams, + }, +}; +use serde_json::json; +use tokio::sync::{Mutex, Notify}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +struct Server {} + +impl ServerHandler for Server { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_resources() + .enable_resources_subscribe() + .enable_resources_list_changed() + .build(), + ) + } + + async fn subscribe( + &self, + request: rmcp::model::SubscribeRequestParams, + context: rmcp::service::RequestContext, + ) -> Result<(), rmcp::ErrorData> { + let uri = request.uri; + let peer = context.peer; + + tokio::spawn(async move { + let span = tracing::info_span!("subscribe", uri = %uri); + let _enter = span.enter(); + + if let Err(e) = peer + .notify_resource_updated(ResourceUpdatedNotificationParam::new(uri.clone())) + .await + { + panic!("Failed to send notification: {}", e); + } + }); + + Ok(()) + } +} + +pub struct Client { + receive_signal: Arc, +} + +impl ClientHandler for Client { + async fn on_resource_updated( + &self, + params: rmcp::model::ResourceUpdatedNotificationParam, + _context: rmcp::service::NotificationContext, + ) { + let uri = params.uri; + tracing::info!("Resource updated: {}", uri); + self.receive_signal.notify_one(); + } +} + +#[tokio::test] +async fn test_server_notification() -> anyhow::Result<()> { + let _ = tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "debug".to_string().into()), + ) + .with(tracing_subscriber::fmt::layer()) + .try_init(); + let (server_transport, client_transport) = tokio::io::duplex(4096); + tokio::spawn(async move { + let server = Server {}.serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + let receive_signal = Arc::new(Notify::new()); + let client = Client { + receive_signal: receive_signal.clone(), + } + .serve(client_transport) + .await?; + client + .subscribe(SubscribeRequestParams::new("test://test-resource")) + .await?; + receive_signal.notified().await; + client.cancel().await?; + Ok(()) +} + +type CustomNotificationPayload = (String, Option); + +struct CustomServer { + receive_signal: Arc, + payload: Arc>>, +} + +impl ServerHandler for CustomServer { + async fn on_custom_notification( + &self, + notification: CustomNotification, + _context: rmcp::service::NotificationContext, + ) { + let CustomNotification { method, params, .. } = notification; + *self.payload.lock().await = Some((method, params)); + self.receive_signal.notify_one(); + } +} + +#[tokio::test] +async fn test_custom_client_notification_reaches_server() -> anyhow::Result<()> { + let _ = tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "debug".to_string().into()), + ) + .with(tracing_subscriber::fmt::layer()) + .try_init(); + + let (server_transport, client_transport) = tokio::io::duplex(4096); + let receive_signal = Arc::new(Notify::new()); + let payload = Arc::new(Mutex::new(None)); + + { + let receive_signal = receive_signal.clone(); + let payload = payload.clone(); + tokio::spawn(async move { + let server = CustomServer { + receive_signal, + payload, + } + .serve(server_transport) + .await?; + server.waiting().await?; + anyhow::Ok(()) + }); + } + + let client = ().serve(client_transport).await?; + + client + .send_notification(ClientNotification::CustomNotification( + CustomNotification::new("notifications/custom-test", Some(json!({ "foo": "bar" }))), + )) + .await?; + + tokio::time::timeout(std::time::Duration::from_secs(5), receive_signal.notified()).await?; + + let (method, params) = payload.lock().await.take().expect("payload set"); + assert_eq!("notifications/custom-test", method); + assert_eq!(Some(json!({ "foo": "bar" })), params); + + client.cancel().await?; + Ok(()) +} + +struct CustomServerNotifier; + +impl ServerHandler for CustomServerNotifier { + async fn on_initialized(&self, context: rmcp::service::NotificationContext) { + let peer = context.peer.clone(); + tokio::spawn(async move { + peer.send_notification(ServerNotification::CustomNotification( + CustomNotification::new( + "notifications/custom-test", + Some(json!({ "hello": "world" })), + ), + )) + .await + .expect("send custom notification"); + }); + } +} + +struct CustomClient { + receive_signal: Arc, + payload: Arc>>, +} + +impl ClientHandler for CustomClient { + async fn on_custom_notification( + &self, + notification: CustomNotification, + _context: rmcp::service::NotificationContext, + ) { + let CustomNotification { method, params, .. } = notification; + *self.payload.lock().await = Some((method, params)); + self.receive_signal.notify_one(); + } +} + +#[tokio::test] +async fn test_custom_server_notification_reaches_client() -> anyhow::Result<()> { + let _ = tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "debug".to_string().into()), + ) + .with(tracing_subscriber::fmt::layer()) + .try_init(); + + let (server_transport, client_transport) = tokio::io::duplex(4096); + tokio::spawn(async move { + let server = CustomServerNotifier {}.serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + + let receive_signal = Arc::new(Notify::new()); + let payload = Arc::new(Mutex::new(None)); + + let client = CustomClient { + receive_signal: receive_signal.clone(), + payload: payload.clone(), + } + .serve(client_transport) + .await?; + + tokio::time::timeout(std::time::Duration::from_secs(5), receive_signal.notified()).await?; + + let (method, params) = payload.lock().await.take().expect("payload set"); + assert_eq!("notifications/custom-test", method); + assert_eq!(Some(json!({ "hello": "world" })), params); + + client.cancel().await?; + Ok(()) +} diff --git a/vendor/rmcp/tests/test_progress_subscriber.rs b/vendor/rmcp/tests/test_progress_subscriber.rs new file mode 100644 index 000000000..8df1b7cad --- /dev/null +++ b/vendor/rmcp/tests/test_progress_subscriber.rs @@ -0,0 +1,134 @@ +#![cfg(not(feature = "local"))] +use futures::StreamExt; +use rmcp::{ + ClientHandler, Peer, RoleServer, ServerHandler, ServiceExt, + handler::{client::progress::ProgressDispatcher, server::tool::ToolRouter}, + model::{ + CallToolRequestParams, ClientRequest, ProgressNotificationParam, Request, RequestMetaObject, + }, + service::PeerRequestOptions, + tool, tool_handler, tool_router, +}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +pub struct MyClient { + progress_handler: ProgressDispatcher, +} + +impl MyClient { + pub fn new() -> Self { + Self { + progress_handler: ProgressDispatcher::new(), + } + } +} + +impl Default for MyClient { + fn default() -> Self { + Self::new() + } +} + +impl ClientHandler for MyClient { + async fn on_progress( + &self, + params: rmcp::model::ProgressNotificationParam, + _context: rmcp::service::NotificationContext, + ) { + tracing::info!("Received progress notification: {:?}", params); + self.progress_handler.handle_notification(params).await; + } +} + +pub struct MyServer { + #[expect(dead_code, reason = "tool_handler macro accesses this router field")] + tool_router: ToolRouter, +} + +impl MyServer { + pub fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } +} + +impl Default for MyServer { + fn default() -> Self { + Self::new() + } +} + +#[tool_router] +impl MyServer { + #[tool] + pub async fn some_progress( + meta: RequestMetaObject, + client: Peer, + ) -> Result<(), rmcp::ErrorData> { + let progress_token = meta + .get_progress_token() + .ok_or(rmcp::ErrorData::invalid_params( + "Progress token is required for this tool", + None, + ))?; + for step in 0..10 { + let _ = client + .notify_progress( + ProgressNotificationParam::new(progress_token.clone(), step as f64) + .with_total(10.0) + .with_message("Some message"), + ) + .await; + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + Ok(()) + } +} + +#[tool_handler] +impl ServerHandler for MyServer {} + +#[tokio::test] +async fn test_progress_subscriber() -> anyhow::Result<()> { + let _ = tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "debug".to_string().into()), + ) + .with(tracing_subscriber::fmt::layer()) + .try_init(); + let client = MyClient::new(); + + let server = MyServer::new(); + let (transport_server, transport_client) = tokio::io::duplex(4096); + tokio::spawn(async move { + let service = server.serve(transport_server).await?; + service.waiting().await?; + anyhow::Ok(()) + }); + let client_service = client.serve(transport_client).await?; + let handle = client_service + .send_cancellable_request( + ClientRequest::CallToolRequest(Request::new(CallToolRequestParams::new( + "some_progress", + ))), + PeerRequestOptions::no_options(), + ) + .await?; + let mut progress_subscriber = client_service + .service() + .progress_handler + .subscribe(handle.progress_token.clone()) + .await; + tokio::spawn(async move { + while let Some(notification) = progress_subscriber.next().await { + tracing::info!("Progress notification: {:?}", notification); + } + }); + let _response = handle.await_response().await?; + + // Simulate some delay to allow the async task to complete + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + Ok(()) +} diff --git a/vendor/rmcp/tests/test_prompt_handler.rs b/vendor/rmcp/tests/test_prompt_handler.rs new file mode 100644 index 000000000..97018a55a --- /dev/null +++ b/vendor/rmcp/tests/test_prompt_handler.rs @@ -0,0 +1,155 @@ +//cargo test --test test_prompt_handler --features "client server" +// Tests for verifying that the #[prompt_handler] macro correctly generates +// the ServerHandler trait implementation methods. +#![allow(dead_code)] + +use rmcp::{ServerHandler, handler::server::router::prompt::PromptRouter, prompt_handler}; + +#[derive(Debug, Clone)] +pub struct TestPromptServer { + prompt_router: PromptRouter, +} + +impl Default for TestPromptServer { + fn default() -> Self { + Self::new() + } +} + +impl TestPromptServer { + pub fn new() -> Self { + Self { + prompt_router: PromptRouter::new(), + } + } +} + +#[prompt_handler(router = self.prompt_router)] +impl ServerHandler for TestPromptServer {} + +#[derive(Debug, Clone)] +pub struct CustomRouterServer { + custom_router: PromptRouter, +} + +impl Default for CustomRouterServer { + fn default() -> Self { + Self::new() + } +} + +impl CustomRouterServer { + pub fn new() -> Self { + Self { + custom_router: PromptRouter::new(), + } + } + + pub fn get_custom_router(&self) -> &PromptRouter { + &self.custom_router + } +} + +#[prompt_handler(router = self.custom_router)] +impl ServerHandler for CustomRouterServer {} + +#[derive(Debug, Clone)] +pub struct GenericPromptServer { + prompt_router: PromptRouter, + _marker: std::marker::PhantomData, +} + +impl Default for GenericPromptServer { + fn default() -> Self { + Self::new() + } +} + +impl GenericPromptServer { + pub fn new() -> Self { + Self { + prompt_router: PromptRouter::new(), + _marker: std::marker::PhantomData, + } + } +} + +#[prompt_handler(router = self.prompt_router)] +impl ServerHandler for GenericPromptServer {} + +#[test] +fn test_prompt_handler_basic() { + let server = TestPromptServer::new(); + + // Test that the server implements ServerHandler + fn assert_server_handler(_: &T) {} + assert_server_handler(&server); + + // Test that the prompt router is accessible + assert_eq!(server.prompt_router.list_all().len(), 0); +} + +#[test] +fn test_prompt_handler_custom_router() { + let server = CustomRouterServer::new(); + + // Test that the server implements ServerHandler + fn assert_server_handler(_: &T) {} + assert_server_handler(&server); + + // Test that the custom router is used + assert_eq!(server.custom_router.list_all().len(), 0); +} + +#[test] +fn test_prompt_handler_with_generics() { + let server = GenericPromptServer::::new(); + + // Test that generic server implements ServerHandler + fn assert_server_handler(_: &T) {} + assert_server_handler(&server); + + // Test with a different generic type + let server2 = GenericPromptServer::::new(); + assert_server_handler(&server2); +} + +#[test] +fn test_prompt_handler_trait_implementation() { + // This test verifies that the prompt_handler macro generates proper ServerHandler implementation + // The actual method signatures are tested through the ServerHandler trait bound + fn compile_time_check() {} + + compile_time_check::(); + compile_time_check::(); + compile_time_check::>(); +} + +// Test that the macro works with different server configurations +mod nested { + use super::*; + + #[derive(Debug, Clone)] + pub struct NestedServer { + prompt_router: PromptRouter, + } + + impl NestedServer { + pub fn new() -> Self { + Self { + prompt_router: PromptRouter::new(), + } + } + } + + #[prompt_handler(router = self.prompt_router)] + impl ServerHandler for NestedServer {} + + #[test] + fn test_nested_prompt_handler() { + let server = NestedServer::new(); + // Verify it implements ServerHandler + fn assert_server_handler(_: &T) {} + assert_server_handler(&server); + } +} diff --git a/vendor/rmcp/tests/test_prompt_macro_annotations.rs b/vendor/rmcp/tests/test_prompt_macro_annotations.rs new file mode 100644 index 000000000..b5b6ef6af --- /dev/null +++ b/vendor/rmcp/tests/test_prompt_macro_annotations.rs @@ -0,0 +1,274 @@ +//cargo test --test test_prompt_macro_annotations --features "client server" +#![allow(dead_code)] + +use rmcp::{ + ServerHandler, + handler::server::wrapper::Parameters, + model::{GetPromptResult, Prompt, PromptMessage, Role}, + prompt, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone)] +struct TestServer; + +impl ServerHandler for TestServer {} + +#[derive(Serialize, Deserialize, JsonSchema)] +struct TestArgs { + /// The input text to process + input: String, + /// Optional configuration + #[serde(skip_serializing_if = "Option::is_none")] + config: Option, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +struct ComplexArgs { + /// Required field + required_field: String, + /// Optional string field + #[schemars(description = "An optional string parameter")] + optional_string: Option, + /// Optional number field + optional_number: Option, + /// Array field + items: Vec, +} + +// Test basic prompt attribute generation +#[prompt] +async fn basic_prompt(_server: &TestServer) -> Vec { + vec![PromptMessage::new_text(Role::Assistant, "Basic response")] +} + +// Test prompt with custom name +#[prompt(name = "custom_name")] +async fn named_prompt(_server: &TestServer) -> Vec { + vec![PromptMessage::new_text(Role::Assistant, "Named response")] +} + +// Test prompt with custom description +#[prompt(description = "This is a custom description")] +async fn described_prompt(_server: &TestServer) -> Vec { + vec![PromptMessage::new_text( + Role::Assistant, + "Described response", + )] +} + +// Test prompt with both name and description +#[prompt(name = "full_custom", description = "Fully customized prompt")] +async fn fully_custom_prompt(_server: &TestServer) -> Vec { + vec![PromptMessage::new_text( + Role::Assistant, + "Fully custom response", + )] +} + +// Test prompt with doc comments +/// This is a doc comment description +/// that spans multiple lines +#[prompt] +async fn doc_comment_prompt(_server: &TestServer) -> Vec { + vec![PromptMessage::new_text( + Role::Assistant, + "Doc comment response", + )] +} + +// Test prompt with doc comments and explicit description (explicit wins) +/// This is a doc comment +#[prompt(description = "This overrides the doc comment")] +async fn override_doc_prompt(_server: &TestServer) -> Vec { + vec![PromptMessage::new_text( + Role::Assistant, + "Override response", + )] +} + +// Test prompt with arguments +#[prompt] +async fn args_prompt(_server: &TestServer, _args: Parameters) -> Vec { + vec![PromptMessage::new_text(Role::Assistant, "Args response")] +} + +// Test prompt with complex arguments +#[prompt] +async fn complex_args_prompt( + _server: &TestServer, + _args: Parameters, +) -> GetPromptResult { + GetPromptResult::new(vec![PromptMessage::new_text( + Role::Assistant, + "Complex response", + )]) + .with_description("Complex args result") +} + +// Test sync prompt +#[prompt] +fn sync_prompt(_server: &TestServer) -> Vec { + vec![PromptMessage::new_text(Role::Assistant, "Sync response")] +} + +#[test] +fn test_basic_prompt_attr() { + let attr = basic_prompt_prompt_attr(); + assert_eq!(attr.name, "basic_prompt"); + assert_eq!(attr.description, None); + assert!(attr.arguments.is_none()); +} + +#[test] +fn test_named_prompt_attr() { + let attr = named_prompt_prompt_attr(); + assert_eq!(attr.name, "custom_name"); + assert_eq!(attr.description, None); + assert!(attr.arguments.is_none()); +} + +#[test] +fn test_described_prompt_attr() { + let attr = described_prompt_prompt_attr(); + assert_eq!(attr.name, "described_prompt"); + assert_eq!( + attr.description.as_deref(), + Some("This is a custom description") + ); + assert!(attr.arguments.is_none()); +} + +#[test] +fn test_fully_custom_prompt_attr() { + let attr = fully_custom_prompt_prompt_attr(); + assert_eq!(attr.name, "full_custom"); + assert_eq!(attr.description.as_deref(), Some("Fully customized prompt")); + assert!(attr.arguments.is_none()); +} + +#[test] +fn test_doc_comment_prompt_attr() { + let attr = doc_comment_prompt_prompt_attr(); + assert_eq!(attr.name, "doc_comment_prompt"); + assert!(attr.description.is_some()); + let desc = attr.description.unwrap(); + assert!(desc.contains("This is a doc comment description")); + assert!(desc.contains("that spans multiple lines")); +} + +#[test] +fn test_override_doc_prompt_attr() { + let attr = override_doc_prompt_prompt_attr(); + assert_eq!(attr.name, "override_doc_prompt"); + assert_eq!( + attr.description.as_deref(), + Some("This overrides the doc comment") + ); +} + +#[test] +fn test_args_prompt_attr() { + let attr = args_prompt_prompt_attr(); + assert_eq!(attr.name, "args_prompt"); + + let args = attr.arguments.as_ref().unwrap(); + assert_eq!(args.len(), 2); + + // Check input field + let input_arg = args.iter().find(|a| a.name == "input").unwrap(); + assert_eq!(input_arg.required, Some(true)); + assert_eq!( + input_arg.description.as_deref(), + Some("The input text to process") + ); + + // Check config field + let config_arg = args.iter().find(|a| a.name == "config").unwrap(); + assert_eq!(config_arg.required, Some(false)); + assert_eq!( + config_arg.description.as_deref(), + Some("Optional configuration") + ); +} + +#[test] +fn test_complex_args_prompt_attr() { + let attr = complex_args_prompt_prompt_attr(); + assert_eq!(attr.name, "complex_args_prompt"); + + let args = attr.arguments.as_ref().unwrap(); + assert_eq!(args.len(), 4); + + // Check required_field + let required_arg = args.iter().find(|a| a.name == "required_field").unwrap(); + assert_eq!(required_arg.required, Some(true)); + assert_eq!(required_arg.description.as_deref(), Some("Required field")); + + // Check optional_string + let optional_string_arg = args.iter().find(|a| a.name == "optional_string").unwrap(); + assert_eq!(optional_string_arg.required, Some(false)); + assert_eq!( + optional_string_arg.description.as_deref(), + Some("An optional string parameter") + ); + + // Check optional_number + let optional_number_arg = args.iter().find(|a| a.name == "optional_number").unwrap(); + assert_eq!(optional_number_arg.required, Some(false)); + assert_eq!( + optional_number_arg.description.as_deref(), + Some("Optional number field") + ); + + // Check items + let items_arg = args.iter().find(|a| a.name == "items").unwrap(); + assert_eq!(items_arg.required, Some(true)); + assert_eq!(items_arg.description.as_deref(), Some("Array field")); +} + +#[test] +fn test_sync_prompt_attr() { + let attr = sync_prompt_prompt_attr(); + assert_eq!(attr.name, "sync_prompt"); + assert!(attr.arguments.is_none()); +} + +#[test] +fn test_prompt_attr_function_type() { + // Test that the generated function returns the correct type + fn assert_prompt_attr_fn(_: impl Fn() -> Prompt) {} + + assert_prompt_attr_fn(basic_prompt_prompt_attr); + assert_prompt_attr_fn(named_prompt_prompt_attr); + assert_prompt_attr_fn(described_prompt_prompt_attr); + assert_prompt_attr_fn(fully_custom_prompt_prompt_attr); + assert_prompt_attr_fn(doc_comment_prompt_prompt_attr); + assert_prompt_attr_fn(override_doc_prompt_prompt_attr); + assert_prompt_attr_fn(args_prompt_prompt_attr); + assert_prompt_attr_fn(complex_args_prompt_prompt_attr); + assert_prompt_attr_fn(sync_prompt_prompt_attr); +} + +// Test generic prompts +#[derive(Debug, Clone)] +struct GenericServer { + _marker: std::marker::PhantomData, +} + +impl ServerHandler for GenericServer {} + +#[prompt] +async fn generic_prompt( + _server: &GenericServer, +) -> Vec { + vec![PromptMessage::new_text(Role::Assistant, "Generic response")] +} + +#[test] +fn test_generic_prompt_attr() { + let attr = generic_prompt_prompt_attr(); + assert_eq!(attr.name, "generic_prompt"); + assert!(attr.arguments.is_none()); +} diff --git a/vendor/rmcp/tests/test_prompt_macros.rs b/vendor/rmcp/tests/test_prompt_macros.rs new file mode 100644 index 000000000..7a00249a4 --- /dev/null +++ b/vendor/rmcp/tests/test_prompt_macros.rs @@ -0,0 +1,375 @@ +#![cfg(not(feature = "local"))] +//cargo test --test test_prompt_macros --features "client server" +#![allow(dead_code)] +use std::sync::Arc; + +use rmcp::{ + ClientHandler, RoleServer, ServerHandler, ServiceExt, + handler::server::{router::prompt::PromptRouter, wrapper::Parameters}, + model::{ + ClientInfo, ContentBlock, GetPromptRequestParams, GetPromptResult, ListPromptsResult, + PaginatedRequestParams, PromptMessage, Role, + }, + prompt, prompt_handler, prompt_router, + service::RequestContext, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, JsonSchema)] +struct CodeReviewRequest { + file_path: String, + language: String, +} + +#[prompt_handler(router = self.prompt_router)] +impl ServerHandler for Server {} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct Server { + prompt_router: PromptRouter, +} + +impl Default for Server { + fn default() -> Self { + Self::new() + } +} + +#[prompt_router] +impl Server { + pub fn new() -> Self { + Self { + prompt_router: Self::prompt_router(), + } + } + + /// This prompt is used to review code for best practices. + #[prompt( + name = "code-review", + description = "Review code for best practices and issues." + )] + pub async fn code_review(&self, params: Parameters) -> Vec { + vec![ + PromptMessage::new_text( + Role::User, + format!( + "Please review the {} code in: {}", + params.0.language, params.0.file_path + ), + ), + PromptMessage::new_text( + Role::Assistant, + "I'll review this code for best practices and potential issues.".to_string(), + ), + ] + } + + #[prompt] + async fn empty_param(&self) -> Vec { + vec![PromptMessage::new_text( + Role::Assistant, + "This is a prompt with no parameters.".to_string(), + )] + } +} + +// define generic service trait +pub trait DataService: Send + Sync + 'static { + fn get_context(&self) -> String; +} + +// mock service for test +#[derive(Clone)] +struct MockDataService; +impl DataService for MockDataService { + fn get_context(&self) -> String { + "mock context data".to_string() + } +} + +// define generic server +#[derive(Debug, Clone)] +pub struct GenericServer { + data_service: Arc, + prompt_router: PromptRouter, +} + +#[prompt_router] +impl GenericServer { + pub fn new(data_service: DS) -> Self { + Self { + data_service: Arc::new(data_service), + prompt_router: Self::prompt_router(), + } + } + + #[prompt(description = "Get contextual help from the service")] + async fn get_help(&self) -> GetPromptResult { + let context = self.data_service.get_context(); + GetPromptResult::new(vec![ + PromptMessage::new_text( + Role::User, + "I need help with the current context.".to_string(), + ), + PromptMessage::new_text( + Role::Assistant, + format!( + "Based on the context '{}', here's how I can help...", + context + ), + ), + ]) + .with_description("Contextual help based on service data") + } +} + +#[prompt_handler] +impl ServerHandler for GenericServer {} + +#[tokio::test] +async fn test_prompt_macros() { + let server = Server::new(); + let _attr = Server::code_review_prompt_attr(); + let _code_review_prompt_attr_fn = Server::code_review_prompt_attr; + let _code_review_fn = Server::code_review; + let result = server + .code_review(Parameters(CodeReviewRequest { + file_path: "/src/main.rs".into(), + language: "rust".into(), + })) + .await; + assert_eq!(result.len(), 2); + assert_eq!(result[0].role, Role::User); + assert_eq!(result[1].role, Role::Assistant); +} + +#[tokio::test] +async fn test_prompt_macros_with_empty_param() { + let _attr = Server::empty_param_prompt_attr(); + println!("{_attr:?}"); + assert!( + _attr.arguments.is_none(), + "Empty param prompt should have no arguments" + ); +} + +#[tokio::test] +async fn test_prompt_macros_with_generics() { + let mock_service = MockDataService; + let server = GenericServer::new(mock_service); + let _attr = GenericServer::::get_help_prompt_attr(); + let _get_help_call_fn = GenericServer::::get_help; + let _get_help_fn = GenericServer::::get_help; + let result = server.get_help().await; + assert!(result.description.is_some()); + assert_eq!(result.messages.len(), 2); + match &result.messages[1].content { + ContentBlock::Text(text_content) => { + assert!(text_content.text.contains("mock context data")); + } + _ => panic!("Expected text content"), + } +} + +#[tokio::test] +async fn test_prompt_macros_with_optional_param() { + let _attr = Server::code_review_prompt_attr(); + let arguments = _attr.arguments.as_ref().unwrap(); + + // Check that we have the expected number of arguments + assert_eq!(arguments.len(), 2); + + // Verify file_path is required + let file_path_arg = arguments.iter().find(|a| a.name == "file_path").unwrap(); + assert_eq!(file_path_arg.required, Some(true)); + + // Verify language is required + let language_arg = arguments.iter().find(|a| a.name == "language").unwrap(); + assert_eq!(language_arg.required, Some(true)); +} + +impl CodeReviewRequest {} + +// Struct defined for testing optional field schema generation +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +struct OptionalFieldTestSchema { + #[schemars(description = "An optional description field")] + description: Option, +} + +// Struct defined for testing optional i64 field schema generation and null handling +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +struct OptionalI64TestSchema { + #[schemars(description = "An optional i64 field")] + count: Option, + mandatory_field: String, // Added to ensure non-empty object schema +} + +// Dummy struct to host the test prompt method +#[derive(Debug, Clone)] +pub struct OptionalSchemaTester { + prompt_router: PromptRouter, +} + +impl Default for OptionalSchemaTester { + fn default() -> Self { + Self::new() + } +} + +impl OptionalSchemaTester { + pub fn new() -> Self { + Self { + prompt_router: Self::prompt_router(), + } + } +} + +#[prompt_router] +impl OptionalSchemaTester { + // Dummy prompt function using the test schema as an aggregated parameter + #[prompt(description = "A prompt to test optional schema generation")] + async fn test_optional(&self, _req: Parameters) -> Vec { + vec![PromptMessage::new_text( + Role::Assistant, + "Testing optional fields".to_string(), + )] + } + + // Prompt function to test optional i64 handling + #[prompt(description = "A prompt to test optional i64 schema generation")] + async fn test_optional_i64( + &self, + Parameters(req): Parameters, + ) -> GetPromptResult { + let message = match req.count { + Some(c) => format!("Received count: {}", c), + None => "Received null count".to_string(), + }; + + GetPromptResult::new(vec![PromptMessage::new_text(Role::Assistant, message)]) + .with_description("Test result for optional i64") + } +} + +#[prompt_handler] +// Implement ServerHandler to route prompt calls for OptionalSchemaTester +impl ServerHandler for OptionalSchemaTester {} + +#[test] +fn test_optional_field_schema_generation_via_macro() { + // tests https://github.com/modelcontextprotocol/rust-sdk/issues/135 + + // Get the attributes generated by the #[prompt] macro helper + let prompt_attr = OptionalSchemaTester::test_optional_prompt_attr(); + + // Print the actual generated schema for debugging + println!( + "Actual arguments generated by macro: {:#?}", + prompt_attr.arguments + ); + + // Verify the schema generated for the aggregated OptionalFieldTestSchema + let arguments = prompt_attr.arguments.expect("Should have arguments"); + + // Check that we have an argument for the optional description field + let description_arg = arguments + .iter() + .find(|arg| arg.name == "description") + .expect("Should have description argument"); + + // Assert that optional fields are marked as not required + assert_eq!( + description_arg.required, + Some(false), + "Optional fields should be marked as not required" + ); + + // Check the description is correct + assert_eq!( + description_arg.description.as_deref(), + Some("An optional description field") + ); +} + +// Define a dummy client handler +#[derive(Debug, Clone, Default)] +struct DummyClientHandler {} + +impl ClientHandler for DummyClientHandler { + fn get_info(&self) -> ClientInfo { + ClientInfo::default() + } +} + +#[tokio::test] +async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + // Server setup + let server = OptionalSchemaTester::new(); + let server_handle = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + + // Create a simple client handler that just forwards prompt calls + let client_handler = DummyClientHandler::default(); + let client = client_handler.serve(client_transport).await?; + + // Test null case + let result = client + .get_prompt( + GetPromptRequestParams::new("test_optional_i64").with_arguments( + serde_json::json!({ + "count": null, + "mandatory_field": "test_null" + }) + .as_object() + .unwrap() + .clone(), + ), + ) + .await?; + + let result_text = match &result.messages.first().unwrap().content { + ContentBlock::Text(text_content) => text_content.text.as_str(), + _ => panic!("Expected text content"), + }; + + assert_eq!( + result_text, "Received null count", + "Null case should return expected message" + ); + + // Test Some case + let some_result = client + .get_prompt( + GetPromptRequestParams::new("test_optional_i64").with_arguments( + serde_json::json!({ + "count": 42, + "mandatory_field": "test_some" + }) + .as_object() + .unwrap() + .clone(), + ), + ) + .await?; + + let some_result_text = match &some_result.messages.first().unwrap().content { + ContentBlock::Text(text_content) => text_content.text.as_str(), + _ => panic!("Expected text content"), + }; + + assert_eq!( + some_result_text, "Received count: 42", + "Some case should return expected message" + ); + + client.cancel().await?; + server_handle.await??; + Ok(()) +} diff --git a/vendor/rmcp/tests/test_prompt_routers.rs b/vendor/rmcp/tests/test_prompt_routers.rs new file mode 100644 index 000000000..eecdb1e4a --- /dev/null +++ b/vendor/rmcp/tests/test_prompt_routers.rs @@ -0,0 +1,140 @@ +#![cfg(not(feature = "local"))] +use std::collections::HashMap; + +use futures::future::BoxFuture; +use rmcp::{ + ServerHandler, + handler::server::wrapper::Parameters, + model::{GetPromptResult, PromptMessage, Role}, +}; + +#[derive(Debug, Default)] +struct TestHandler { + _marker: std::marker::PhantomData, +} + +impl ServerHandler for TestHandler {} + +#[derive(Debug, schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +struct Request { + fields: HashMap, +} + +#[derive(Debug, schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +struct Sum { + a: i32, + b: i32, +} + +#[rmcp::prompt_router(router = "test_router")] +impl TestHandler { + #[rmcp::prompt] + async fn async_method( + &self, + Parameters(Request { fields }): Parameters, + ) -> Vec { + drop(fields); + vec![PromptMessage::new_text( + Role::Assistant, + "Async method response", + )] + } + + #[rmcp::prompt] + fn sync_method( + &self, + Parameters(Request { fields }): Parameters, + ) -> Vec { + drop(fields); + vec![PromptMessage::new_text( + Role::Assistant, + "Sync method response", + )] + } +} + +#[rmcp::prompt] +async fn async_function(Parameters(Request { fields }): Parameters) -> Vec { + drop(fields); + vec![PromptMessage::new_text( + Role::Assistant, + "Async function response", + )] +} + +#[rmcp::prompt] +fn async_function2(_callee: &TestHandler) -> BoxFuture<'_, GetPromptResult> { + Box::pin(async move { + GetPromptResult::new(vec![PromptMessage::new_text( + Role::Assistant, + "Async function 2 response", + )]) + .with_description("Async function 2") + }) +} + +#[test] +fn test_prompt_router() { + let test_prompt_router = TestHandler::<()>::test_router() + .with_route(rmcp::handler::server::router::prompt::PromptRoute::new_dyn( + async_function_prompt_attr(), + |mut context| { + Box::pin(async move { + use rmcp::handler::server::{ + common::FromContextPart, prompt::IntoGetPromptResult, + }; + let params = Parameters::::from_context_part(&mut context)?; + let result = async_function(params).await; + result.into_get_prompt_result() + }) + }, + )) + .with_route(rmcp::handler::server::router::prompt::PromptRoute::new_dyn( + async_function2_prompt_attr(), + |context| { + Box::pin(async move { + use rmcp::handler::server::prompt::IntoGetPromptResult; + let result = async_function2(context.server).await; + result.into_get_prompt_result() + }) + }, + )); + let prompts = test_prompt_router.list_all(); + assert_eq!(prompts.len(), 4); +} + +#[test] +fn test_prompt_router_list_all_is_sorted() { + let router = TestHandler::<()>::test_router() + .with_route(rmcp::handler::server::router::prompt::PromptRoute::new_dyn( + async_function_prompt_attr(), + |mut context| { + Box::pin(async move { + use rmcp::handler::server::{ + common::FromContextPart, prompt::IntoGetPromptResult, + }; + let params = Parameters::::from_context_part(&mut context)?; + let result = async_function(params).await; + result.into_get_prompt_result() + }) + }, + )) + .with_route(rmcp::handler::server::router::prompt::PromptRoute::new_dyn( + async_function2_prompt_attr(), + |context| { + Box::pin(async move { + use rmcp::handler::server::prompt::IntoGetPromptResult; + let result = async_function2(context.server).await; + result.into_get_prompt_result() + }) + }, + )); + let prompts = router.list_all(); + let names: Vec<&str> = prompts.iter().map(|p| p.name.as_ref()).collect(); + let mut sorted = names.clone(); + sorted.sort(); + assert_eq!( + names, sorted, + "list_all() should return prompts sorted alphabetically by name" + ); +} diff --git a/vendor/rmcp/tests/test_protocol_version_negotiation.rs b/vendor/rmcp/tests/test_protocol_version_negotiation.rs new file mode 100644 index 000000000..e91ecf97a --- /dev/null +++ b/vendor/rmcp/tests/test_protocol_version_negotiation.rs @@ -0,0 +1,171 @@ +//! Tests for protocol version negotiation in the default ServerHandler::initialize impl. +//! +//! Known versions are echoed back; unknown versions fall back to LATEST. +#![cfg(not(feature = "local"))] +#![cfg(feature = "client")] + +use std::borrow::Cow; + +use rmcp::{ + ClientHandler, ErrorData, RoleServer, ServerHandler, ServiceExt, + model::{ClientInfo, InitializeRequestParams, InitializeResult, ProtocolVersion, ServerInfo}, + service::RequestContext, +}; + +#[derive(Debug, Clone, Default)] +struct EchoServer; + +impl ServerHandler for EchoServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::default() + } +} + +/// Every known version except `2026-07-28`, standing in for a server that has +/// not implemented that revision. +const NARROWED_VERSIONS: &[ProtocolVersion] = &[ + ProtocolVersion::V_2024_11_05, + ProtocolVersion::V_2025_03_26, + ProtocolVersion::V_2025_06_18, + ProtocolVersion::V_2025_11_25, +]; + +#[derive(Debug, Clone, Default)] +struct NarrowedServer; + +impl ServerHandler for NarrowedServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::default() + } + + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(NARROWED_VERSIONS) + } +} + +/// Narrows the supported versions *and* overrides `initialize`, so the +/// handler's own answer never runs the default negotiation. The handshake layer +/// must still honor the narrowed list. +#[derive(Debug, Clone, Default)] +struct NarrowedOverridingServer; + +impl ServerHandler for NarrowedOverridingServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::default() + } + + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(NARROWED_VERSIONS) + } + + async fn initialize( + &self, + _request: InitializeRequestParams, + _context: RequestContext, + ) -> Result { + Ok(self.get_info()) + } +} + +#[derive(Debug, Clone)] +struct VersionedClient { + protocol_version: ProtocolVersion, +} + +impl ClientHandler for VersionedClient { + fn get_info(&self) -> ClientInfo { + let mut info = ClientInfo::default(); + info.protocol_version = self.protocol_version.clone(); + info + } +} + +async fn negotiated_version(client_version: ProtocolVersion) -> ProtocolVersion { + negotiated_version_with(EchoServer, client_version).await +} + +async fn negotiated_version_with( + server: S, + client_version: ProtocolVersion, +) -> ProtocolVersion { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + tokio::spawn(async move { + let _ = server + .serve(server_transport) + .await + .expect("server should start") + .waiting() + .await; + }); + + let client = VersionedClient { + protocol_version: client_version, + } + .serve(client_transport) + .await + .expect("client should connect"); + + let version = client + .peer_info() + .expect("peer_info should be set") + .protocol_version + .clone(); + + client.cancel().await.expect("client should cancel"); + version +} + +#[tokio::test] +async fn known_version_echoed_back() { + for version in ProtocolVersion::KNOWN_VERSIONS { + let negotiated = negotiated_version(version.clone()).await; + assert_eq!( + negotiated, *version, + "known version {version} should be echoed back" + ); + } +} + +#[tokio::test] +async fn unknown_version_falls_back_to_latest() { + let unknown: ProtocolVersion = serde_json::from_str(r#""1999-01-01""#).unwrap(); + let negotiated = negotiated_version(unknown).await; + assert_eq!( + negotiated, + ProtocolVersion::LATEST, + "unknown version should fall back to LATEST" + ); +} + +#[tokio::test] +async fn narrowed_server_still_echoes_versions_it_supports() { + for version in NARROWED_VERSIONS { + let negotiated = negotiated_version_with(NarrowedServer, version.clone()).await; + assert_eq!( + negotiated, *version, + "supported version {version} should be echoed back" + ); + } +} + +#[tokio::test] +async fn narrowed_server_does_not_agree_to_version_it_excludes() { + let negotiated = negotiated_version_with(NarrowedServer, ProtocolVersion::V_2026_07_28).await; + assert_eq!( + negotiated, + ProtocolVersion::V_2025_11_25, + "a version outside supported_protocol_versions should not be echoed back" + ); +} + +#[tokio::test] +async fn narrowed_server_caps_even_when_it_overrides_initialize() { + let negotiated = + negotiated_version_with(NarrowedOverridingServer, ProtocolVersion::V_2026_07_28).await; + assert_eq!( + negotiated, + ProtocolVersion::V_2025_11_25, + "the handshake layer should not raise the version above what the server supports" + ); +} diff --git a/vendor/rmcp/tests/test_request_timeout_progress.rs b/vendor/rmcp/tests/test_request_timeout_progress.rs new file mode 100644 index 000000000..f37101f8e --- /dev/null +++ b/vendor/rmcp/tests/test_request_timeout_progress.rs @@ -0,0 +1,214 @@ +#![cfg(not(feature = "local"))] + +use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use rmcp::{ + ClientHandler, Peer, RoleServer, ServiceError, ServiceExt, + model::{ + CallToolRequestParams, ClientRequest, NumberOrString, ProgressNotificationParam, + ProgressToken, Request, RequestMetaObject, + }, + service::PeerRequestOptions, + tool, tool_router, +}; + +#[derive(Clone, Default)] +struct ProgressCountingClient { + progress_count: Arc, +} + +impl ClientHandler for ProgressCountingClient { + async fn on_progress( + &self, + _params: ProgressNotificationParam, + _context: rmcp::service::NotificationContext, + ) { + self.progress_count.fetch_add(1, Ordering::SeqCst); + } +} + +struct ProgressTimeoutServer; + +#[tool_router(server_handler)] +impl ProgressTimeoutServer { + #[tool] + async fn delayed_without_progress(&self) -> Result<(), rmcp::ErrorData> { + tokio::time::sleep(Duration::from_millis(250)).await; + Ok(()) + } + + #[tool] + async fn delayed_with_progress( + &self, + meta: RequestMetaObject, + client: Peer, + ) -> Result<(), rmcp::ErrorData> { + let progress_token = meta + .get_progress_token() + .ok_or(rmcp::ErrorData::invalid_params( + "Progress token is required", + None, + ))?; + + for step in 0..4 { + tokio::time::sleep(Duration::from_millis(50)).await; + let _ = client + .notify_progress( + ProgressNotificationParam::new(progress_token.clone(), step as f64) + .with_total(4.0) + .with_message("working"), + ) + .await; + } + + Ok(()) + } + + #[tool] + async fn delayed_with_unrelated_progress( + &self, + client: Peer, + ) -> Result<(), rmcp::ErrorData> { + for step in 0..4 { + tokio::time::sleep(Duration::from_millis(50)).await; + let _ = client + .notify_progress( + ProgressNotificationParam::new( + ProgressToken(NumberOrString::Number(999_999)), + step as f64, + ) + .with_total(4.0) + .with_message("unrelated"), + ) + .await; + } + + Ok(()) + } +} + +async fn start_pair() +-> anyhow::Result> { + let server = ProgressTimeoutServer; + let client = ProgressCountingClient::default(); + let (transport_server, transport_client) = tokio::io::duplex(4096); + + tokio::spawn(async move { + let service = server.serve(transport_server).await?; + service.waiting().await?; + anyhow::Ok(()) + }); + + Ok(client.serve(transport_client).await?) +} + +async fn call_tool_with_options( + client: &rmcp::service::RunningService, + name: &str, + options: PeerRequestOptions, +) -> Result { + client + .send_request_with_option( + ClientRequest::CallToolRequest(Request::new(CallToolRequestParams::new( + name.to_owned(), + ))), + options, + ) + .await? + .await_response() + .await +} + +#[tokio::test] +async fn request_timeout_still_expires_without_progress() -> anyhow::Result<()> { + let client = start_pair().await?; + let result = call_tool_with_options( + &client, + "delayed_without_progress", + PeerRequestOptions::with_timeout(Duration::from_millis(75)), + ) + .await; + + assert!(matches!(result, Err(ServiceError::Timeout { .. }))); + Ok(()) +} + +#[tokio::test] +async fn progress_does_not_reset_timeout_by_default() -> anyhow::Result<()> { + let client = start_pair().await?; + let result = call_tool_with_options( + &client, + "delayed_with_progress", + PeerRequestOptions::with_timeout(Duration::from_millis(75)), + ) + .await; + + assert!(matches!(result, Err(ServiceError::Timeout { .. }))); + Ok(()) +} + +#[tokio::test] +async fn matching_progress_resets_timeout_when_enabled() -> anyhow::Result<()> { + let client = start_pair().await?; + let result = call_tool_with_options( + &client, + "delayed_with_progress", + PeerRequestOptions::with_timeout(Duration::from_millis(75)).reset_timeout_on_progress(), + ) + .await; + + assert!(result.is_ok()); + assert!(client.service().progress_count.load(Ordering::SeqCst) > 0); + Ok(()) +} + +#[tokio::test] +async fn generated_progress_token_overrides_option_meta_token() -> anyhow::Result<()> { + let client = start_pair().await?; + let mut options = + PeerRequestOptions::with_timeout(Duration::from_millis(75)).reset_timeout_on_progress(); + options.meta = Some(RequestMetaObject::with_progress_token(ProgressToken( + NumberOrString::Number(999_999), + ))); + + let result = call_tool_with_options(&client, "delayed_with_progress", options).await; + + assert!(result.is_ok()); + Ok(()) +} + +#[tokio::test] +async fn max_total_timeout_wins_over_progress_reset() -> anyhow::Result<()> { + let client = start_pair().await?; + let result = call_tool_with_options( + &client, + "delayed_with_progress", + PeerRequestOptions::with_timeout(Duration::from_millis(75)) + .reset_timeout_on_progress() + .with_max_total_timeout(Duration::from_millis(125)), + ) + .await; + + assert!(matches!(result, Err(ServiceError::Timeout { .. }))); + Ok(()) +} + +#[tokio::test] +async fn unrelated_progress_does_not_reset_timeout() -> anyhow::Result<()> { + let client = start_pair().await?; + let result = call_tool_with_options( + &client, + "delayed_with_unrelated_progress", + PeerRequestOptions::with_timeout(Duration::from_millis(75)).reset_timeout_on_progress(), + ) + .await; + + assert!(matches!(result, Err(ServiceError::Timeout { .. }))); + Ok(()) +} diff --git a/vendor/rmcp/tests/test_resource_link.rs b/vendor/rmcp/tests/test_resource_link.rs new file mode 100644 index 000000000..7db5b06b7 --- /dev/null +++ b/vendor/rmcp/tests/test_resource_link.rs @@ -0,0 +1,86 @@ +use rmcp::model::{CallToolResult, ContentBlock, Resource}; + +#[test] +fn test_resource_link_in_tool_result() { + // Test creating a tool result with resource links + let resource = Resource::new("file:///test/file.txt", "test.txt"); + + // Create a tool result with a resource link + let result = CallToolResult::success(vec![ + ContentBlock::text("Found a file"), + ContentBlock::resource_link(resource), + ]); + + // Serialize to JSON to verify format + let json = serde_json::to_string_pretty(&result).unwrap(); + println!("Tool result with resource link:\n{}", json); + + // Verify JSON contains expected structure + assert!( + json.contains("\"type\":\"resource_link\"") || json.contains("\"type\": \"resource_link\"") + ); + assert!( + json.contains("\"uri\":\"file:///test/file.txt\"") + || json.contains("\"uri\": \"file:///test/file.txt\"") + ); + assert!(json.contains("\"name\":\"test.txt\"") || json.contains("\"name\": \"test.txt\"")); + + // Test deserialization + let deserialized: CallToolResult = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.content.len(), 2); + + // Check the text content + assert!(deserialized.content[0].as_text().is_some()); + + // Check the resource link + let resource_link = deserialized.content[1] + .as_resource_link() + .expect("Expected resource link in content[1]"); + assert_eq!(resource_link.uri, "file:///test/file.txt"); + assert_eq!(resource_link.name, "test.txt"); +} + +#[test] +fn test_resource_link_with_full_metadata() { + let resource = Resource::new("https://example.com/data.json", "API Data") + .with_description("JSON data from external API") + .with_mime_type("application/json") + .with_size(1024); + + let result = CallToolResult::success(vec![ContentBlock::resource_link(resource)]); + + let json = serde_json::to_string(&result).unwrap(); + let deserialized: CallToolResult = serde_json::from_str(&json).unwrap(); + + let resource_link = deserialized.content[0] + .as_resource_link() + .expect("Expected resource link"); + assert_eq!(resource_link.uri, "https://example.com/data.json"); + assert_eq!(resource_link.name, "API Data"); + assert_eq!( + resource_link.description, + Some("JSON data from external API".to_string()) + ); + assert_eq!( + resource_link.mime_type, + Some("application/json".to_string()) + ); + assert_eq!(resource_link.size, Some(1024)); +} + +#[test] +fn test_mixed_content_types() { + // Test that resource links can be mixed with other content types + let resource = Resource::new("file:///doc.pdf", "Document"); + + let result = CallToolResult::success(vec![ + ContentBlock::text("Processing complete"), + ContentBlock::resource_link(resource), + ContentBlock::embedded_text("memo://result", "Analysis results here"), + ]); + + assert_eq!(result.content.len(), 3); + assert!(result.content[0].as_text().is_some()); + assert!(result.content[1].as_resource_link().is_some()); + assert!(result.content[2].as_resource().is_some()); +} diff --git a/vendor/rmcp/tests/test_resource_link_integration.rs b/vendor/rmcp/tests/test_resource_link_integration.rs new file mode 100644 index 000000000..2cc687285 --- /dev/null +++ b/vendor/rmcp/tests/test_resource_link_integration.rs @@ -0,0 +1,114 @@ +/// Integration tests for resource_link support in both tools and prompts +use rmcp::model::{CallToolResult, ContentBlock, PromptMessage, Resource, Role}; + +#[test] +fn test_tool_and_prompt_resource_link_compatibility() { + let resource = Resource::new("file:///shared/data.json", "Shared Data"); + + // Test 1: Tool returning a resource link + let tool_result = CallToolResult::success(vec![ + ContentBlock::text("Found shared data"), + ContentBlock::resource_link(resource.clone()), + ]); + + let tool_json = serde_json::to_string(&tool_result).unwrap(); + assert!(tool_json.contains("\"type\":\"resource_link\"")); + + // Test 2: Prompt returning a resource link + let prompt_message = PromptMessage::new_resource_link(Role::Assistant, resource.clone()); + + let prompt_json = serde_json::to_string(&prompt_message).unwrap(); + assert!(prompt_json.contains("\"type\":\"resource_link\"")); + + // Test 3: Verify both serialize to the same resource link structure + let tool_content = &tool_result.content[1]; + let prompt_content = &prompt_message.content; + + let tool_resource_json = serde_json::to_value(tool_content).unwrap(); + let prompt_resource_json = serde_json::to_value(prompt_content).unwrap(); + + assert_eq!( + tool_resource_json.get("type").unwrap(), + prompt_resource_json.get("type").unwrap() + ); + assert_eq!( + tool_resource_json.get("uri").unwrap(), + prompt_resource_json.get("uri").unwrap() + ); + assert_eq!( + tool_resource_json.get("name").unwrap(), + prompt_resource_json.get("name").unwrap() + ); +} + +#[test] +fn test_resource_link_roundtrip() { + let resource = Resource::new("https://api.example.com/resource", "API Resource") + .with_description("External API resource") + .with_mime_type("application/json") + .with_size(2048); + + // Test with tool result + let tool_result = CallToolResult::success(vec![ContentBlock::resource_link(resource.clone())]); + + let tool_json = serde_json::to_string(&tool_result).unwrap(); + let tool_deserialized: CallToolResult = serde_json::from_str(&tool_json).unwrap(); + + if let Some(resource_link) = tool_deserialized.content[0].as_resource_link() { + assert_eq!(resource_link.uri, "https://api.example.com/resource"); + assert_eq!(resource_link.name, "API Resource"); + assert_eq!( + resource_link.description, + Some("External API resource".to_string()) + ); + assert_eq!( + resource_link.mime_type, + Some("application/json".to_string()) + ); + assert_eq!(resource_link.size, Some(2048)); + } else { + panic!("Expected resource link in tool result"); + } + + // Test with prompt message + let prompt_message = PromptMessage::new(Role::User, ContentBlock::resource_link(resource)); + + let prompt_json = serde_json::to_string(&prompt_message).unwrap(); + let prompt_deserialized: PromptMessage = serde_json::from_str(&prompt_json).unwrap(); + + if let ContentBlock::ResourceLink(link) = &prompt_deserialized.content { + assert_eq!(link.uri, "https://api.example.com/resource"); + assert_eq!(link.name, "API Resource"); + assert_eq!(link.description, Some("External API resource".to_string())); + assert_eq!(link.mime_type, Some("application/json".to_string())); + assert_eq!(link.size, Some(2048)); + } else { + panic!("Expected resource link in prompt message"); + } +} + +#[test] +fn test_mixed_content_in_prompts_and_tools() { + let resource1 = Resource::new("file:///doc1.md", "Document 1"); + let resource2 = Resource::new("file:///doc2.md", "Document 2"); + + // Tool with mixed content + let tool_result = CallToolResult::success(vec![ + ContentBlock::text("Processing complete. Found documents:"), + ContentBlock::resource_link(resource1), + ContentBlock::resource_link(resource2), + ContentBlock::embedded_text("summary://result", "Both documents processed successfully"), + ]); + + assert_eq!(tool_result.content.len(), 4); + assert!(tool_result.content[0].as_text().is_some()); + assert!(tool_result.content[1].as_resource_link().is_some()); + assert!(tool_result.content[2].as_resource_link().is_some()); + assert!(tool_result.content[3].as_resource().is_some()); + + // Verify serialization includes all types + let json = serde_json::to_string(&tool_result).unwrap(); + assert!(json.contains("\"type\":\"text\"")); + assert!(json.contains("\"type\":\"resource_link\"")); + assert!(json.contains("\"type\":\"resource\"")); +} diff --git a/vendor/rmcp/tests/test_resource_not_found_version.rs b/vendor/rmcp/tests/test_resource_not_found_version.rs new file mode 100644 index 000000000..44eb3631f --- /dev/null +++ b/vendor/rmcp/tests/test_resource_not_found_version.rs @@ -0,0 +1,91 @@ +//! SEP-2164: the resource-not-found error code follows the negotiated protocol version. +//! +//! `2026-07-28` and newer get the standard `INVALID_PARAMS` (-32602); older versions +//! keep the legacy `RESOURCE_NOT_FOUND` (-32002). +#![cfg(not(feature = "local"))] +#![cfg(feature = "client")] + +use rmcp::{ + ClientHandler, RoleServer, ServerHandler, ServiceError, ServiceExt, + model::{ + ClientInfo, ErrorCode, ErrorData, ProtocolVersion, ReadResourceRequestParams, + ReadResourceResponse, + }, + service::RequestContext, +}; + +#[derive(Debug, Clone, Default)] +struct ResourceServer; + +impl ServerHandler for ResourceServer { + async fn read_resource( + &self, + _request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + Err(ErrorData::resource_not_found("resource not found", None)) + } +} + +#[derive(Debug, Clone)] +struct VersionedClient { + protocol_version: ProtocolVersion, +} + +impl ClientHandler for VersionedClient { + fn get_info(&self) -> ClientInfo { + let mut info = ClientInfo::default(); + info.protocol_version = self.protocol_version.clone(); + info + } +} + +async fn not_found_code(client_version: ProtocolVersion) -> ErrorCode { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let server_handle = tokio::spawn(async move { + ResourceServer + .serve(server_transport) + .await? + .waiting() + .await?; + anyhow::Ok(()) + }); + + let client = VersionedClient { + protocol_version: client_version, + } + .serve(client_transport) + .await + .expect("client should connect"); + + let error = client + .read_resource(ReadResourceRequestParams::new("missing://resource")) + .await + .expect_err("missing resource should error"); + + let code = match error { + ServiceError::McpError(data) => data.code, + other => panic!("expected McpError, got: {other:?}"), + }; + + client.cancel().await.expect("client should cancel"); + server_handle.await.expect("server task").expect("server"); + code +} + +#[tokio::test] +async fn legacy_version_gets_resource_not_found_code() { + assert_eq!( + not_found_code(ProtocolVersion::V_2025_11_25).await, + ErrorCode::RESOURCE_NOT_FOUND, + ); +} + +#[tokio::test] +async fn sep_2164_version_gets_invalid_params_code() { + assert_eq!( + not_found_code(ProtocolVersion::V_2026_07_28).await, + ErrorCode::INVALID_PARAMS, + ); +} diff --git a/vendor/rmcp/tests/test_result_type_version.rs b/vendor/rmcp/tests/test_result_type_version.rs new file mode 100644 index 000000000..849a6610e --- /dev/null +++ b/vendor/rmcp/tests/test_result_type_version.rs @@ -0,0 +1,82 @@ +//! SEP-2322: the `resultType` discriminator follows the negotiated protocol version. +//! +//! Peers negotiating `2026-07-28` or newer receive `resultType: "complete"` on +//! ordinary results; older peers keep the legacy wire shape without the field. +#![cfg(not(feature = "local"))] +#![cfg(feature = "client")] + +use rmcp::{ + ClientHandler, RoleServer, ServerHandler, ServiceExt, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ClientInfo, ContentBlock, + ErrorData, ProtocolVersion, ResultType, + }, + service::RequestContext, +}; + +#[derive(Debug, Clone, Default)] +struct ToolServer; + +impl ServerHandler for ToolServer { + async fn call_tool( + &self, + _request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + Ok(CallToolResult::success(vec![ContentBlock::text("ok")]).into()) + } +} + +#[derive(Debug, Clone)] +struct VersionedClient { + protocol_version: ProtocolVersion, +} + +impl ClientHandler for VersionedClient { + fn get_info(&self) -> ClientInfo { + let mut info = ClientInfo::default(); + info.protocol_version = self.protocol_version.clone(); + info + } +} + +async fn call_tool_result_type(client_version: ProtocolVersion) -> Option { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let server_handle = tokio::spawn(async move { + ToolServer.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + + let client = VersionedClient { + protocol_version: client_version, + } + .serve(client_transport) + .await + .expect("client should connect"); + + let result = client + .call_tool(CallToolRequestParams::new("echo")) + .await + .expect("tool call should succeed"); + + client.cancel().await.expect("client should cancel"); + server_handle.await.expect("server task").expect("server"); + result.result_type +} + +#[tokio::test] +async fn legacy_version_omits_result_type() { + assert_eq!( + call_tool_result_type(ProtocolVersion::V_2025_11_25).await, + None + ); +} + +#[tokio::test] +async fn sep_2322_version_gets_complete_result_type() { + assert_eq!( + call_tool_result_type(ProtocolVersion::V_2026_07_28).await, + Some(ResultType::COMPLETE), + ); +} diff --git a/vendor/rmcp/tests/test_result_type_wire.rs b/vendor/rmcp/tests/test_result_type_wire.rs new file mode 100644 index 000000000..d5f201532 --- /dev/null +++ b/vendor/rmcp/tests/test_result_type_wire.rs @@ -0,0 +1,126 @@ +//! Wire-shape regression guards for the SEP-2322 `resultType` discriminator. +//! +//! These pin the behavior that keeps older/strict peers working: +//! - `EmptyResult` stays a bare `{}` (some peers strict-validate empty results +//! and reject extra keys), +//! - ordinary results carry `resultType: "complete"` by default, and +//! - [`ServerResult::strip_result_type_for_legacy_peer`] removes the +//! `"complete"` discriminator so legacy sessions keep their historical wire +//! shape (round-tripping a legacy result never adds the field). + +use rmcp::model::{ + CallToolResult, CompleteResult, ContentBlock, EmptyResult, GetPromptResult, ListToolsResult, + ReadResourceResult, ResultType, ServerResult, +}; +use serde_json::json; + +#[test] +fn empty_result_serializes_without_result_type() { + let value = serde_json::to_value(EmptyResult {}).expect("serialize EmptyResult"); + assert_eq!(value, json!({})); +} + +#[test] +fn call_tool_result_serializes_complete_result_type() { + let value = serde_json::to_value(CallToolResult::success(vec![ContentBlock::text("ok")])) + .expect("serialize CallToolResult"); + assert_eq!(value["resultType"], "complete"); +} + +#[test] +fn paginated_result_serializes_complete_result_type() { + let value = + serde_json::to_value(ListToolsResult::default()).expect("serialize ListToolsResult"); + assert_eq!(value["resultType"], "complete"); +} + +// Guards the convention the server handler relies on: every constructor and +// `Default` impl produces `Some(COMPLETE)`, so 2026-07-28 sessions always +// include the spec-required field unless a handler clears it explicitly. +#[test] +fn constructors_and_defaults_produce_complete_result_type() { + let complete = Some(ResultType::COMPLETE); + assert_eq!(CallToolResult::default().result_type, complete); + assert_eq!(CallToolResult::success(vec![]).result_type, complete); + assert_eq!(CallToolResult::error(vec![]).result_type, complete); + assert_eq!(CallToolResult::structured(json!({})).result_type, complete); + assert_eq!( + CallToolResult::structured_error(json!({})).result_type, + complete + ); + assert_eq!(ListToolsResult::default().result_type, complete); + assert_eq!( + ListToolsResult::with_all_items(vec![]).result_type, + complete + ); + assert_eq!(ReadResourceResult::new(vec![]).result_type, complete); + assert_eq!(GetPromptResult::default().result_type, complete); + assert_eq!(GetPromptResult::new(vec![]).result_type, complete); + assert_eq!(CompleteResult::default().result_type, complete); +} + +#[test] +fn absent_result_type_deserializes_as_none() { + let legacy = json!({ + "content": [], + "isError": false, + }); + + let result: CallToolResult = + serde_json::from_value(legacy).expect("deserialize legacy CallToolResult"); + + assert_eq!(result.result_type, None); +} + +#[test] +fn legacy_call_tool_result_round_trips_without_result_type() { + let legacy = json!({ + "content": [], + "isError": false, + }); + + let result: CallToolResult = + serde_json::from_value(legacy.clone()).expect("deserialize legacy CallToolResult"); + let reserialized = serde_json::to_value(result).expect("serialize CallToolResult"); + + assert_eq!(reserialized, legacy); +} + +#[test] +fn strip_removes_complete_result_type() { + let mut result = + ServerResult::CallToolResult(CallToolResult::success(vec![ContentBlock::text("ok")])); + result.strip_result_type_for_legacy_peer(); + + let value = serde_json::to_value(result).expect("serialize ServerResult"); + assert_eq!( + value, + json!({ + "content": [{ "type": "text", "text": "ok" }], + "isError": false, + }) + ); +} + +#[test] +fn strip_removes_complete_result_type_from_paginated_result() { + let mut result = ServerResult::ListToolsResult(ListToolsResult::default()); + result.strip_result_type_for_legacy_peer(); + + let value = serde_json::to_value(result).expect("serialize ServerResult"); + assert_eq!(value, json!({ "tools": [] })); +} + +#[test] +fn strip_preserves_custom_result_type() { + let streaming: ResultType = + serde_json::from_value(json!("streaming")).expect("deserialize ResultType"); + let mut tool_result = CallToolResult::success(vec![ContentBlock::text("ok")]); + tool_result.result_type = Some(streaming); + + let mut result = ServerResult::CallToolResult(tool_result); + result.strip_result_type_for_legacy_peer(); + + let value = serde_json::to_value(result).expect("serialize ServerResult"); + assert_eq!(value["resultType"], "streaming"); +} diff --git a/vendor/rmcp/tests/test_sampling.rs b/vendor/rmcp/tests/test_sampling.rs new file mode 100644 index 000000000..b108ff412 --- /dev/null +++ b/vendor/rmcp/tests/test_sampling.rs @@ -0,0 +1,744 @@ +#![cfg(not(feature = "local"))] +#![allow(deprecated)] +mod common; + +use anyhow::Result; +use common::handlers::{TestClientHandler, TestServer}; +use rmcp::{ + ServiceExt, + model::*, + service::{RequestContext, Service}, +}; +use rstest::rstest; + +#[tokio::test] +async fn test_basic_sampling_message_creation() -> Result<()> { + let message = SamplingMessage::user_text("What is the capital of France?"); + + let json = serde_json::to_string(&message)?; + let deserialized: SamplingMessage = serde_json::from_str(&json)?; + assert_eq!(message, deserialized); + assert_eq!(message.role, Role::User); + + Ok(()) +} + +#[tokio::test] +async fn test_sampling_request_params() -> Result<()> { + let params = + CreateMessageRequestParams::new(vec![SamplingMessage::user_text("Hello, world!")], 100) + .with_model_preferences( + ModelPreferences::new() + .with_hints(vec![ModelHint::new("claude")]) + .with_cost_priority(0.5) + .with_speed_priority(0.8) + .with_intelligence_priority(0.7), + ) + .with_system_prompt("You are a helpful assistant.") + .with_temperature(0.7) + .with_stop_sequences(vec!["STOP".to_string()]) + .with_include_context(ContextInclusion::None) + .with_metadata(serde_json::json!({"test": "value"})); + + let json = serde_json::to_string(¶ms)?; + let deserialized: CreateMessageRequestParams = serde_json::from_str(&json)?; + assert_eq!(params, deserialized); + + assert_eq!(params.messages.len(), 1); + assert_eq!(params.max_tokens, 100); + assert_eq!(params.temperature, Some(0.7)); + + Ok(()) +} + +#[tokio::test] +async fn test_sampling_result_structure() -> Result<()> { + let result = CreateMessageResult::new( + SamplingMessage::assistant_text("The capital of France is Paris."), + "test-model".to_string(), + ) + .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN); + + let json = serde_json::to_string(&result)?; + let deserialized: CreateMessageResult = serde_json::from_str(&json)?; + assert_eq!(result, deserialized); + + assert_eq!(result.message.role, Role::Assistant); + assert_eq!(result.model, "test-model"); + assert_eq!( + result.stop_reason, + Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()) + ); + + Ok(()) +} + +#[tokio::test] +async fn test_sampling_context_inclusion_enum() -> Result<()> { + let test_cases = vec![ + (ContextInclusion::None, "none"), + (ContextInclusion::ThisServer, "thisServer"), + (ContextInclusion::AllServers, "allServers"), + ]; + + for (context, expected_json) in test_cases { + let json = serde_json::to_string(&context)?; + assert_eq!(json, format!("\"{}\"", expected_json)); + + let deserialized: ContextInclusion = serde_json::from_str(&json)?; + assert_eq!(context, deserialized); + } + + Ok(()) +} + +#[tokio::test] +async fn test_sampling_integration_with_test_handlers() -> Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let server_handle = tokio::spawn(async move { + let server = TestServer::new().serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + + let handler = TestClientHandler::new(true, true); + let client = handler.clone().serve(client_transport).await?; + + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new( + vec![SamplingMessage::user_text("What is the capital of France?")], + 100, + ) + .with_include_context(ContextInclusion::ThisServer) + .with_model_preferences( + ModelPreferences::new() + .with_hints(vec![ModelHint::new("test-model")]) + .with_cost_priority(0.5) + .with_speed_priority(0.8) + .with_intelligence_priority(0.7), + ) + .with_system_prompt("You are a helpful assistant.") + .with_temperature(0.7), + )); + + let result = handler + .handle_request( + request.clone(), + RequestContext::new(NumberOrString::Number(1), client.peer().clone()), + ) + .await?; + + if let ClientResult::CreateMessageResult(result) = result { + assert_eq!(result.message.role, Role::Assistant); + assert_eq!(result.model, "test-model"); + assert_eq!( + result.stop_reason, + Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()) + ); + + let response_text = result + .message + .content + .first() + .unwrap() + .as_text() + .unwrap() + .text + .as_str(); + assert!( + response_text.contains("test context"), + "Response should include context for ThisServer inclusion" + ); + } else { + panic!("Expected CreateMessageResult"); + } + + client.cancel().await?; + server_handle.await??; + Ok(()) +} + +#[tokio::test] +async fn test_sampling_no_context_inclusion() -> Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let server_handle = tokio::spawn(async move { + let server = TestServer::new().serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + + let handler = TestClientHandler::new(true, true); + let client = handler.clone().serve(client_transport).await?; + + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new(vec![SamplingMessage::user_text("Hello")], 50) + .with_include_context(ContextInclusion::None), + )); + + let result = handler + .handle_request( + request.clone(), + RequestContext::new(NumberOrString::Number(2), client.peer().clone()), + ) + .await?; + + if let ClientResult::CreateMessageResult(result) = result { + assert_eq!(result.message.role, Role::Assistant); + assert_eq!(result.model, "test-model"); + + let response_text = result + .message + .content + .first() + .unwrap() + .as_text() + .unwrap() + .text + .as_str(); + assert!( + !response_text.contains("test context"), + "Response should not include context for None inclusion" + ); + } else { + panic!("Expected CreateMessageResult"); + } + + client.cancel().await?; + server_handle.await??; + Ok(()) +} + +#[tokio::test] +async fn test_sampling_error_invalid_message_sequence() -> Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let server_handle = tokio::spawn(async move { + let server = TestServer::new().serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + + let handler = TestClientHandler::new(true, true); + let client = handler.clone().serve(client_transport).await?; + + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new( + vec![SamplingMessage::assistant_text( + "I'm an assistant message without a user message", + )], + 50, + ) + .with_include_context(ContextInclusion::None), + )); + + let result = handler + .handle_request( + request.clone(), + RequestContext::new(NumberOrString::Number(3), client.peer().clone()), + ) + .await; + + assert!(result.is_err()); + + client.cancel().await?; + server_handle.await??; + Ok(()) +} + +#[tokio::test] +async fn test_tool_choice_serialization() -> Result<()> { + let auto = ToolChoice::auto(); + let json = serde_json::to_string(&auto)?; + assert!(json.contains("auto")); + let deserialized: ToolChoice = serde_json::from_str(&json)?; + assert_eq!(auto, deserialized); + + let required = ToolChoice::required(); + let json = serde_json::to_string(&required)?; + assert!(json.contains("required")); + let deserialized: ToolChoice = serde_json::from_str(&json)?; + assert_eq!(required, deserialized); + + let none = ToolChoice::none(); + let json = serde_json::to_string(&none)?; + assert!(json.contains("none")); + let deserialized: ToolChoice = serde_json::from_str(&json)?; + assert_eq!(none, deserialized); + + Ok(()) +} + +#[tokio::test] +async fn test_sampling_with_tools() -> Result<()> { + use std::sync::Arc; + + let tool = Tool::new( + "get_weather", + "Get the current weather for a location", + Arc::new( + serde_json::json!({ + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA" + } + }, + "required": ["location"] + }) + .as_object() + .unwrap() + .clone(), + ), + ); + + let params = CreateMessageRequestParams::new( + vec![SamplingMessage::user_text( + "What's the weather in San Francisco?", + )], + 100, + ) + .with_tools(vec![tool]) + .with_tool_choice(ToolChoice::auto()); + + let json = serde_json::to_string(¶ms)?; + let deserialized: CreateMessageRequestParams = serde_json::from_str(&json)?; + + assert!(deserialized.tools.is_some()); + assert_eq!(deserialized.tools.as_ref().unwrap().len(), 1); + assert_eq!(deserialized.tools.as_ref().unwrap()[0].name, "get_weather"); + assert!(deserialized.tool_choice.is_some()); + + Ok(()) +} + +#[tokio::test] +async fn test_tool_use_content_serialization() -> Result<()> { + let tool_use = ToolUseContent::new( + "call_123", + "get_weather", + serde_json::json!({ + "location": "San Francisco, CA" + }) + .as_object() + .unwrap() + .clone(), + ); + + let json = serde_json::to_string(&tool_use)?; + let deserialized: ToolUseContent = serde_json::from_str(&json)?; + assert_eq!(tool_use, deserialized); + assert_eq!(deserialized.id, "call_123"); + assert_eq!(deserialized.name, "get_weather"); + + Ok(()) +} + +#[tokio::test] +async fn test_tool_result_content_serialization() -> Result<()> { + let tool_result = ToolResultContent::new( + "call_123", + vec![ContentBlock::text( + "The weather in San Francisco is 72°F and sunny.", + )], + ); + + let json = serde_json::to_string(&tool_result)?; + let deserialized: ToolResultContent = serde_json::from_str(&json)?; + assert_eq!(tool_result, deserialized); + assert_eq!(deserialized.tool_use_id, "call_123"); + assert!(!deserialized.content.is_empty()); + + Ok(()) +} + +#[test] +fn test_tool_result_content_requires_content() { + let raw = serde_json::json!({ + "toolUseId": "call_123" + }); + + let err = serde_json::from_value::(raw).unwrap_err(); + + assert!(err.to_string().contains("missing field `content`")); +} + +#[rstest] +#[case::array(serde_json::json!([{ "city": "SF", "temp": 72 }, { "city": "NY", "temp": 65 }]))] +#[case::string(serde_json::json!("sunny"))] +#[case::integer(serde_json::json!(42))] +#[case::float(serde_json::json!(3.14))] +#[case::boolean(serde_json::json!(true))] +fn tool_result_content_round_trips_non_object_structured_content( + #[case] structured: serde_json::Value, +) -> Result<()> { + let mut tool_result = ToolResultContent::new("call_123", vec![ContentBlock::text("x")]); + tool_result.structured_content = Some(structured); + + let json = serde_json::to_string(&tool_result)?; + let deserialized: ToolResultContent = serde_json::from_str(&json)?; + assert_eq!(tool_result, deserialized); + + Ok(()) +} + +// `null` is a valid JSON value, but `Option` deserializes JSON `null` +// as `None`, so `Some(Value::Null)` collapses to `None` on round-trip. +#[test] +fn tool_result_content_null_structured_content_round_trips_to_none() -> Result<()> { + let mut tool_result = ToolResultContent::new("call_123", vec![ContentBlock::text("x")]); + tool_result.structured_content = Some(serde_json::Value::Null); + + let json = serde_json::to_string(&tool_result)?; + let deserialized: ToolResultContent = serde_json::from_str(&json)?; + assert_eq!(deserialized.structured_content, None); + + Ok(()) +} + +#[tokio::test] +async fn test_sampling_message_with_tool_use() -> Result<()> { + let message = SamplingMessage::assistant_tool_use( + "call_123", + "get_weather", + serde_json::json!({ + "location": "San Francisco, CA" + }) + .as_object() + .unwrap() + .clone(), + ); + + let json = serde_json::to_string(&message)?; + let deserialized: SamplingMessage = serde_json::from_str(&json)?; + assert_eq!(message, deserialized); + assert_eq!(deserialized.role, Role::Assistant); + + let tool_use = deserialized.content.first().unwrap().as_tool_use().unwrap(); + assert_eq!(tool_use.name, "get_weather"); + + Ok(()) +} + +#[tokio::test] +async fn test_sampling_message_with_tool_result() -> Result<()> { + let message = + SamplingMessage::user_tool_result("call_123", vec![ContentBlock::text("72°F and sunny")]); + + let json = serde_json::to_string(&message)?; + let deserialized: SamplingMessage = serde_json::from_str(&json)?; + assert_eq!(message, deserialized); + assert_eq!(deserialized.role, Role::User); + + let tool_result = deserialized + .content + .first() + .unwrap() + .as_tool_result() + .unwrap(); + assert_eq!(tool_result.tool_use_id, "call_123"); + + Ok(()) +} + +#[tokio::test] +async fn test_create_message_result_tool_use_stop_reason() -> Result<()> { + let result = CreateMessageResult::new( + SamplingMessage::assistant_tool_use( + "call_123", + "get_weather", + serde_json::json!({ + "location": "San Francisco" + }) + .as_object() + .unwrap() + .clone(), + ), + "test-model".to_string(), + ) + .with_stop_reason(CreateMessageResult::STOP_REASON_TOOL_USE); + + let json = serde_json::to_string(&result)?; + let deserialized: CreateMessageResult = serde_json::from_str(&json)?; + assert_eq!(result, deserialized); + assert_eq!(deserialized.stop_reason, Some("toolUse".to_string())); + + Ok(()) +} + +#[tokio::test] +async fn test_sampling_capability() -> Result<()> { + let mut cap = SamplingCapability::default(); + cap.tools = Some(JsonObject::default()); + + let json = serde_json::to_string(&cap)?; + let deserialized: SamplingCapability = serde_json::from_str(&json)?; + assert_eq!(cap, deserialized); + assert!(deserialized.tools.is_some()); + assert!(deserialized.context.is_none()); + + let client_cap = ClientCapabilities::builder() + .enable_sampling() + .enable_sampling_tools() + .build(); + + assert!(client_cap.sampling.is_some()); + assert!(client_cap.sampling.as_ref().unwrap().tools.is_some()); + + Ok(()) +} + +#[tokio::test] +async fn test_backward_compat_sampling_message_deserialization() -> Result<()> { + let old_format_json = r#"{ + "role": "user", + "content": { + "type": "text", + "text": "Hello, world!" + } + }"#; + + let message: SamplingMessage = serde_json::from_str(old_format_json)?; + assert_eq!(message.role, Role::User); + let text = message.content.first().unwrap().as_text().unwrap(); + assert_eq!(text.text, "Hello, world!"); + + Ok(()) +} + +#[tokio::test] +async fn test_backward_compat_sampling_message_with_image() -> Result<()> { + let old_format_json = r#"{ + "role": "user", + "content": { + "type": "image", + "data": "base64data", + "mimeType": "image/png" + } + }"#; + + let message: SamplingMessage = serde_json::from_str(old_format_json)?; + assert_eq!(message.role, Role::User); + assert_eq!(message.content.len(), 1); + + Ok(()) +} + +#[tokio::test] +async fn test_backward_compat_sampling_capability_empty_object() -> Result<()> { + let empty_json = "{}"; + let cap: SamplingCapability = serde_json::from_str(empty_json)?; + assert!(cap.tools.is_none()); + assert!(cap.context.is_none()); + + let client_cap_json = r#"{"sampling": {}}"#; + let client_cap: ClientCapabilities = serde_json::from_str(client_cap_json)?; + assert!(client_cap.sampling.is_some()); + + Ok(()) +} + +#[tokio::test] +async fn test_content_to_sampling_message_content_conversion() -> Result<()> { + use std::convert::TryInto; + + let content = ContentBlock::text("Hello"); + let sampling_content: SamplingMessageContentBlock = + content.try_into().map_err(|e: &str| anyhow::anyhow!(e))?; + assert!(sampling_content.as_text().is_some()); + assert_eq!(sampling_content.as_text().unwrap().text, "Hello"); + + let content = ContentBlock::image("base64data", "image/png"); + let sampling_content: SamplingMessageContentBlock = + content.try_into().map_err(|e: &str| anyhow::anyhow!(e))?; + assert!(matches!( + sampling_content, + SamplingMessageContentBlock::Image(_) + )); + + Ok(()) +} + +#[tokio::test] +async fn test_content_to_sampling_content_conversion() -> Result<()> { + use std::convert::TryInto; + + let content = ContentBlock::text("Hello"); + let sampling_content: SamplingContent = + content.try_into().map_err(|e: &str| anyhow::anyhow!(e))?; + assert_eq!(sampling_content.len(), 1); + assert!(sampling_content.first().unwrap().as_text().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn test_content_conversion_unsupported_variants() { + use std::convert::TryInto; + + use rmcp::model::ResourceContents; + + let resource_content = ContentBlock::resource(ResourceContents::TextResourceContents { + uri: "file:///test.txt".to_string(), + mime_type: Some("text/plain".to_string()), + text: "test".to_string(), + meta: None, + }); + + let result: Result = resource_content.try_into(); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "Resource content is not supported in sampling messages" + ); +} + +#[tokio::test] +async fn test_validate_rejects_tool_use_in_user_message() { + let params = CreateMessageRequestParams::new( + vec![SamplingMessage::new( + Role::User, + SamplingMessageContentBlock::tool_use("call_1", "some_tool", Default::default()), + )], + 100, + ); + + let err = params.validate().unwrap_err(); + assert!( + err.contains("ToolUse content is only allowed in assistant messages"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn test_validate_rejects_tool_result_in_assistant_message() { + let params = CreateMessageRequestParams::new( + vec![SamplingMessage::new( + Role::Assistant, + SamplingMessageContentBlock::tool_result("call_1", vec![ContentBlock::text("result")]), + )], + 100, + ); + + let err = params.validate().unwrap_err(); + assert!( + err.contains("ToolResult content is only allowed in user messages"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn test_validate_rejects_mixed_content_with_tool_result() { + let params = CreateMessageRequestParams::new( + vec![SamplingMessage::new_multiple( + Role::User, + vec![ + SamplingMessageContentBlock::tool_result( + "call_1", + vec![ContentBlock::text("result")], + ), + SamplingMessageContentBlock::text("some extra text"), + ], + )], + 100, + ); + + let err = params.validate().unwrap_err(); + assert!( + err.contains("MUST NOT contain other content types"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn test_validate_rejects_unbalanced_tool_use_result() { + let params = CreateMessageRequestParams::new( + vec![ + SamplingMessage::user_text("Hello"), + SamplingMessage::assistant_tool_use("call_1", "some_tool", Default::default()), + ], + 100, + ); + + let err = params.validate().unwrap_err(); + assert!( + err.contains("not balanced with ToolResult"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn test_validate_rejects_tool_result_without_matching_use() { + let params = CreateMessageRequestParams::new( + vec![ + SamplingMessage::user_text("Hello"), + SamplingMessage::user_tool_result( + "nonexistent_call", + vec![ContentBlock::text("result")], + ), + ], + 100, + ); + + let err = params.validate().unwrap_err(); + assert!( + err.contains("has no matching ToolUse"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn test_validate_accepts_valid_tool_conversation() { + let params = CreateMessageRequestParams::new( + vec![ + SamplingMessage::user_text("What's the weather?"), + SamplingMessage::assistant_tool_use( + "call_1", + "get_weather", + serde_json::json!({"location": "SF"}) + .as_object() + .unwrap() + .clone(), + ), + SamplingMessage::user_tool_result("call_1", vec![ContentBlock::text("72°F and sunny")]), + SamplingMessage::assistant_text("It's 72°F and sunny in SF."), + ], + 100, + ); + + assert!(params.validate().is_ok()); +} + +#[tokio::test] +async fn test_create_message_result_validate_rejects_user_role() { + let result = CreateMessageResult::new( + SamplingMessage::user_text("This should not be a user message"), + "test-model".to_string(), + ) + .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN); + + let err = result.validate().unwrap_err(); + assert!( + err.contains("role must be 'assistant'"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn test_create_message_result_validate_accepts_assistant_role() { + let result = CreateMessageResult::new( + SamplingMessage::assistant_text("Hello!"), + "test-model".to_string(), + ) + .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN); + + assert!(result.validate().is_ok()); +} diff --git a/vendor/rmcp/tests/test_sep_2260_request_association.rs b/vendor/rmcp/tests/test_sep_2260_request_association.rs new file mode 100644 index 000000000..d4e20e1e9 --- /dev/null +++ b/vendor/rmcp/tests/test_sep_2260_request_association.rs @@ -0,0 +1,273 @@ +#![cfg(all(feature = "server", feature = "client", not(feature = "local")))] +#![allow(deprecated)] + +use std::sync::{Arc, Mutex}; + +use rmcp::{ + ClientHandler, RoleClient, RoleServer, ServerHandler, ServiceError, ServiceExt, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ClientInfo, ContentBlock, + CreateMessageRequest, CreateMessageRequestParams, CreateMessageResult, ProtocolVersion, + SamplingMessage, ServerCapabilities, ServerInfo, ServerRequest, + }, + service::RequestContext, +}; +use serde_json::{Value, json}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream, Lines, ReadHalf, WriteHalf}, + sync::oneshot, +}; + +#[derive(Clone)] +struct SamplingServer { + outside: Arc>>>>, +} + +impl ServerHandler for SamplingServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + let peer = context.peer.clone(); + let slot = self.outside.clone(); + + let use_generic = request.name == "sample_generic"; + tokio::spawn(async move { + let outside = if use_generic { + peer.send_request(ServerRequest::CreateMessageRequest( + CreateMessageRequest::new(CreateMessageRequestParams::new( + vec![SamplingMessage::user_text("standalone-generic")], + 16, + )), + )) + .await + .map(|_| ()) + } else { + peer.create_message(CreateMessageRequestParams::new( + vec![SamplingMessage::user_text("standalone")], + 16, + )) + .await + .map(|_| ()) + }; + if let Some(tx) = slot.lock().unwrap().take() { + let _ = tx.send(outside); + } + }); + + let nested = context + .peer + .create_message(CreateMessageRequestParams::new( + vec![SamplingMessage::user_text("nested")], + 16, + )) + .await; + nested.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; + Ok(CallToolResult::success(vec![ContentBlock::text("ok")]).into()) + } +} + +#[derive(Clone)] +struct SamplingClient; + +impl ClientHandler for SamplingClient { + async fn create_message( + &self, + _params: CreateMessageRequestParams, + _context: RequestContext, + ) -> Result { + Ok(CreateMessageResult::new( + SamplingMessage::assistant_text("pong"), + "test-model".to_string(), + ) + .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN)) + } + + fn get_info(&self) -> ClientInfo { + let mut info = ClientInfo::default(); + info.protocol_version = ProtocolVersion::V_2026_07_28; + info + } +} + +#[tokio::test] +async fn nested_sampling_allowed_standalone_rejected() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let (tx, rx) = oneshot::channel(); + let server = SamplingServer { + outside: Arc::new(Mutex::new(Some(tx))), + }; + let server_handle = tokio::spawn(async move { + let running = server.serve(server_transport).await?; + running.waiting().await?; + anyhow::Ok(()) + }); + + let client = SamplingClient.serve(client_transport).await?; + + let result = client + .peer() + .call_tool(CallToolRequestParams::new("sample")) + .await?; + assert_eq!( + result.content.first().unwrap().as_text().unwrap().text, + "ok" + ); + + let outside = rx.await?; + assert!(matches!(outside, Err(ServiceError::McpError(_)))); + + client.cancel().await?; + let _ = server_handle.await?; + Ok(()) +} + +#[tokio::test] +async fn generic_send_request_bypass_rejected() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let (tx, rx) = oneshot::channel(); + let server = SamplingServer { + outside: Arc::new(Mutex::new(Some(tx))), + }; + let server_handle = tokio::spawn(async move { + let running = server.serve(server_transport).await?; + running.waiting().await?; + anyhow::Ok(()) + }); + + let client = SamplingClient.serve(client_transport).await?; + + let result = client + .peer() + .call_tool(CallToolRequestParams::new("sample_generic")) + .await?; + assert_eq!( + result.content.first().unwrap().as_text().unwrap().text, + "ok" + ); + + let outside = rx.await?; + assert!( + matches!(outside, Err(ServiceError::McpError(_))), + "generic send_request must not bypass SEP-2260 enforcement" + ); + + client.cancel().await?; + let _ = server_handle.await?; + Ok(()) +} + +// A compliant rmcp server cannot produce an unassociated server-to-client +// request at >= 2026-07-28 (send-side enforcement blocks it), so the client's +// receive-side enforcement is exercised with a raw JSON-RPC server. +type RawServer = ( + Lines>>, + WriteHalf, +); + +async fn raw_initialize(io: DuplexStream, protocol_version: &str) -> anyhow::Result { + let (read, mut write) = tokio::io::split(io); + let mut lines = BufReader::new(read).lines(); + let init: Value = serde_json::from_str(&lines.next_line().await?.expect("initialize request"))?; + assert_eq!(init["method"], "initialize"); + let response = json!({ + "jsonrpc": "2.0", + "id": init["id"], + "result": { + "protocolVersion": protocol_version, + "capabilities": {}, + "serverInfo": { "name": "raw-server", "version": "0.0.0" } + } + }); + write.write_all(format!("{response}\n").as_bytes()).await?; + let initialized: Value = + serde_json::from_str(&lines.next_line().await?.expect("initialized notification"))?; + assert_eq!(initialized["method"], "notifications/initialized"); + Ok((lines, write)) +} + +async fn raw_request(server: &mut RawServer, request: Value) -> anyhow::Result { + let (lines, write) = server; + write.write_all(format!("{request}\n").as_bytes()).await?; + Ok(serde_json::from_str( + &lines.next_line().await?.expect("response"), + )?) +} + +fn raw_sampling_request(id: u32) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "method": "sampling/createMessage", + "params": { + "messages": [{ "role": "user", "content": { "type": "text", "text": "hi" } }], + "maxTokens": 16 + } + }) +} + +#[tokio::test] +async fn unassociated_server_request_rejected_with_invalid_params() -> anyhow::Result<()> { + let (client_io, server_io) = tokio::io::duplex(4096); + let raw = tokio::spawn(async move { + let mut server = raw_initialize(server_io, "2026-07-28").await?; + raw_request(&mut server, raw_sampling_request(100)).await + }); + + let client = SamplingClient.serve(client_io).await?; + let response = raw.await??; + assert_eq!( + response["error"]["code"], -32602, + "SEP-2260: unassociated server-to-client request must be rejected with invalid params, got {response}" + ); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn unassociated_server_request_allowed_on_legacy_protocol() -> anyhow::Result<()> { + let (client_io, server_io) = tokio::io::duplex(4096); + let raw = tokio::spawn(async move { + let mut server = raw_initialize(server_io, "2025-11-25").await?; + raw_request(&mut server, raw_sampling_request(100)).await + }); + + let client = SamplingClient.serve(client_io).await?; + let response = raw.await??; + assert_eq!( + response["result"]["model"], "test-model", + "pre-2026-07-28 peers keep the permissive behavior, got {response}" + ); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn unassociated_ping_allowed() -> anyhow::Result<()> { + let (client_io, server_io) = tokio::io::duplex(4096); + let raw = tokio::spawn(async move { + let mut server = raw_initialize(server_io, "2026-07-28").await?; + raw_request( + &mut server, + json!({ "jsonrpc": "2.0", "id": 101, "method": "ping" }), + ) + .await + }); + + let client = SamplingClient.serve(client_io).await?; + let response = raw.await??; + assert!( + response.get("error").is_none(), + "SEP-2260 excepts ping from request association, got {response}" + ); + + client.cancel().await?; + Ok(()) +} diff --git a/vendor/rmcp/tests/test_sep_2260_stream_enforcement.rs b/vendor/rmcp/tests/test_sep_2260_stream_enforcement.rs new file mode 100644 index 000000000..36595e05d --- /dev/null +++ b/vendor/rmcp/tests/test_sep_2260_stream_enforcement.rs @@ -0,0 +1,309 @@ +//! SEP-2260 follow-up (#1033): stream-based receive-side enforcement. +//! +//! Scripted streamable HTTP "server": answers a legacy initialize with +//! protocol 2026-07-28 AND a session id. That combination is NOT +//! spec-compliant: the 2026-07-28 revision removes protocol-level sessions +//! and the standalone GET stream (SEP-2567; transports spec: "do not mint +//! or echo session IDs"). Receive-side enforcement (#1033) exists precisely +//! to protect the client from non-conforming servers, and rmcp's client +//! tolerates the session id and opens the standalone GET stream — so this +//! is the reachable path where the client has BOTH a GET stream and strict +//! SEP-2260 enforcement. +#![cfg(all( + feature = "client", + feature = "transport-streamable-http-client", + not(feature = "local") +))] +#![expect( + deprecated, + reason = "Sampling is deprecated by SEP-2577 but remains the canonical restricted request" +)] + +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use futures::{StreamExt, stream::BoxStream}; +use http::{HeaderName, HeaderValue}; +use rmcp::{ + ClientHandler, + model::{ + ClientInfo, ClientJsonRpcMessage, CreateMessageRequestParams, CreateMessageResult, + ProtocolVersion, SamplingMessage, ServerCapabilities, ServerInfo, ServerJsonRpcMessage, + }, + service::{ClientLifecycleMode, RequestContext, RoleClient, serve_client_with_lifecycle}, + transport::streamable_http_client::{ + StreamableHttpClient, StreamableHttpClientTransport, StreamableHttpClientTransportConfig, + StreamableHttpError, StreamableHttpPostResponse, + }, +}; +use serde_json::{Value, json}; +use sse_stream::{Error as SseError, Sse}; +use tokio::sync::{Mutex, mpsc}; + +fn to_sse(message: Value) -> Result { + Ok(Sse { + event: None, + data: Some(message.to_string()), + id: None, + retry: None, + }) +} + +fn message_stream(rx: mpsc::Receiver) -> BoxStream<'static, Result> { + tokio_stream::wrappers::ReceiverStream::new(rx) + .map(to_sse) + .boxed() +} + +/// Scripted server: initialize -> JSON init result (2026-07-28 + session); +/// first non-initialize request POST -> SSE stream fed by `post_stream`; +/// everything else -> Accepted. Every message the client POSTs is forwarded +/// to `posted`. +#[derive(Clone)] +struct ScriptedServer { + get_stream: Arc>>>, + post_stream: Arc>>>, + posted: mpsc::UnboundedSender, +} + +impl StreamableHttpClient for ScriptedServer { + type Error = std::io::Error; + + async fn post_message( + &self, + _uri: Arc, + message: ClientJsonRpcMessage, + _session_id: Option>, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result> { + let value = serde_json::to_value(&message).expect("serialize client message"); + // Receiver drop is normal at test teardown; never panic in the transport task. + let _ = self.posted.send(value.clone()); + if value["method"] == "initialize" { + let mut info = ServerInfo::new(ServerCapabilities::default()); + info.protocol_version = ProtocolVersion::V_2026_07_28; + let response = ServerJsonRpcMessage::response( + rmcp::model::ServerResult::InitializeResult(info), + serde_json::from_value(value["id"].clone()).expect("request id"), + ); + return Ok(StreamableHttpPostResponse::Json( + response, + Some("scripted-session".into()), + )); + } + if matches!(message, ClientJsonRpcMessage::Request(_)) { + // Fail as a transport error rather than panicking: this code runs + // in the transport task, where a panic is swallowed and shows up + // only as an opaque timeout in the test. + let rx = self.post_stream.lock().await.take().ok_or_else(|| { + StreamableHttpError::Client(std::io::Error::other( + "scripted server expects exactly one non-initialize request POST", + )) + })?; + return Ok(StreamableHttpPostResponse::Sse(message_stream(rx), None)); + } + Ok(StreamableHttpPostResponse::Accepted) + } + + async fn delete_session( + &self, + _uri: Arc, + _session_id: Arc, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result<(), StreamableHttpError> { + Ok(()) + } + + async fn get_stream( + &self, + _uri: Arc, + _session_id: Option>, + _last_event_id: Option, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result>, StreamableHttpError> { + match self.get_stream.lock().await.take() { + Some(rx) => Ok(message_stream(rx)), + // Reconnect after the scripted stream ends: stay silent. + None => Ok(futures::stream::pending().boxed()), + } + } +} + +#[derive(Clone)] +struct SamplingClient { + // Every invocation is recorded here. Tests assert on the receiver (the + // negative test asserts it stays empty); a panic in this handler would + // run in a spawned task and be silently swallowed. + sampled: mpsc::UnboundedSender, +} + +impl ClientHandler for SamplingClient { + async fn create_message( + &self, + params: CreateMessageRequestParams, + _context: RequestContext, + ) -> Result { + let _ = self.sampled.send(params); + Ok(CreateMessageResult::new( + SamplingMessage::assistant_text("pong"), + "test-model".to_string(), + )) + } + + fn get_info(&self) -> ClientInfo { + ClientInfo::default() + } +} + +fn sampling_request(id: u32) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "method": "sampling/createMessage", + "params": { + "messages": [{ "role": "user", "content": { "type": "text", "text": "hi" } }], + "maxTokens": 16 + } + }) +} + +fn tools_list_response(id: &Value) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "result": { "tools": [] } }) +} + +async fn next_posted(posted: &mut mpsc::UnboundedReceiver) -> Value { + tokio::time::timeout(Duration::from_secs(5), posted.recv()) + .await + .expect("posted message within 5s") + .expect("channel open") +} + +struct Harness { + client: rmcp::service::RunningService, + /// Every message the client POSTs to the scripted server. + posted: mpsc::UnboundedReceiver, + /// Feeds the standalone GET stream. + get_tx: mpsc::Sender, + /// Feeds the SSE stream of the in-flight tools/list POST. + post_tx: mpsc::Sender, + /// In-flight tools/list call (response withheld until the test releases it). + call: tokio::task::JoinHandle>, + tools_list_id: Value, + /// Sampling params seen by the client handler. + sampled: mpsc::UnboundedReceiver, +} + +/// Drive startup + one in-flight tools/list. +async fn setup() -> Harness { + let (get_tx, get_rx) = mpsc::channel(8); + let (post_tx, post_rx) = mpsc::channel(8); + let (posted_tx, mut posted_rx) = mpsc::unbounded_channel(); + let (sampled_tx, sampled_rx) = mpsc::unbounded_channel(); + let server = ScriptedServer { + get_stream: Arc::new(Mutex::new(Some(get_rx))), + post_stream: Arc::new(Mutex::new(Some(post_rx))), + posted: posted_tx, + }; + let transport = StreamableHttpClientTransport::with_client( + server, + StreamableHttpClientTransportConfig::with_uri("http://scripted/mcp"), + ); + let client = serve_client_with_lifecycle( + SamplingClient { + sampled: sampled_tx, + }, + transport, + ClientLifecycleMode::Initialize, + ) + .await + .expect("initialize against scripted server"); + + // initialize + notifications/initialized already posted during startup. + assert_eq!(next_posted(&mut posted_rx).await["method"], "initialize"); + assert_eq!( + next_posted(&mut posted_rx).await["method"], + "notifications/initialized" + ); + + // Unrelated outbound request, kept in flight (response withheld). + let peer = client.peer().clone(); + let call = tokio::spawn(async move { peer.list_tools(None).await }); + let tools_list = next_posted(&mut posted_rx).await; + assert_eq!(tools_list["method"], "tools/list"); + let tools_list_id = tools_list["id"].clone(); + + Harness { + client, + posted: posted_rx, + get_tx, + post_tx, + call, + tools_list_id, + sampled: sampled_rx, + } +} + +/// #1033 scenario 1: a restricted request on the standalone GET stream while +/// an unrelated outbound request is in flight must be rejected with -32602. +/// (The coarse check from #1029 incorrectly accepted this.) +#[tokio::test] +async fn restricted_request_on_get_stream_rejected_while_unrelated_request_in_flight() +-> anyhow::Result<()> { + let mut h = setup().await; + + h.get_tx.send(sampling_request(100)).await?; + + let rejection = next_posted(&mut h.posted).await; + assert_eq!( + rejection["id"], 100, + "reply to the sampling request: {rejection}" + ); + assert_eq!( + rejection["error"]["code"], -32602, + "SEP-2260: GET-stream request must be rejected even with an unrelated \ + request in flight, got {rejection}" + ); + + h.post_tx + .send(tools_list_response(&h.tools_list_id)) + .await?; + tokio::time::timeout(Duration::from_secs(5), h.call).await???; + // Rejected means rejected: the handler must not ALSO have been invoked. + assert!( + h.sampled.try_recv().is_err(), + "handler must not see the rejected sampling request" + ); + tokio::time::timeout(Duration::from_secs(5), h.client.cancel()).await??; + Ok(()) +} + +/// Positive twin: the same restricted request arriving on the SSE stream of +/// the originating POST is dispatched to the handler and answered. +#[tokio::test] +async fn restricted_request_on_originating_post_stream_is_dispatched() -> anyhow::Result<()> { + let mut h = setup().await; + + h.post_tx.send(sampling_request(200)).await?; + + let response = next_posted(&mut h.posted).await; + assert_eq!( + response["id"], 200, + "reply to the sampling request: {response}" + ); + assert_eq!( + response["result"]["model"], "test-model", + "request on the originating POST stream must reach the handler, got {response}" + ); + tokio::time::timeout(Duration::from_secs(5), h.sampled.recv()) + .await? + .expect("handler invoked"); + + h.post_tx + .send(tools_list_response(&h.tools_list_id)) + .await?; + tokio::time::timeout(Duration::from_secs(5), h.call).await???; + tokio::time::timeout(Duration::from_secs(5), h.client.cancel()).await??; + Ok(()) +} diff --git a/vendor/rmcp/tests/test_sep_2260_stream_routing.rs b/vendor/rmcp/tests/test_sep_2260_stream_routing.rs new file mode 100644 index 000000000..824ef0c78 --- /dev/null +++ b/vendor/rmcp/tests/test_sep_2260_stream_routing.rs @@ -0,0 +1,165 @@ +//! SEP-2260 end-to-end: in-handler server→client requests ride the originating +//! POST's SSE stream, never the standalone GET stream. +#![cfg(not(feature = "local"))] + +use std::{collections::BTreeMap, sync::Arc, time::Duration}; + +use futures::StreamExt; +use rmcp::{ + ErrorData as McpError, RoleServer, ServerHandler, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ElicitRequestParams, + ElicitationSchema, ServerCapabilities, ServerInfo, + }, + service::RequestContext, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, +}; +use serde_json::json; +use tokio_util::sync::CancellationToken; + +#[derive(Clone)] +struct ElicitingServer; + +impl ServerHandler for ElicitingServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } + + async fn call_tool( + &self, + _request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + // Never answered: the test only checks the request is emitted on the right stream. + let _ = context + .peer + .create_elicitation(ElicitRequestParams::FormElicitationParams { + meta: None, + message: "need input".to_string(), + requested_schema: ElicitationSchema::new(BTreeMap::new()), + }) + .await; + Ok(CallToolResult::success(vec![ContentBlock::text("done")]).into()) + } +} + +async fn start_server(ct: CancellationToken) -> String { + let service = StreamableHttpService::new( + move || Ok(ElicitingServer), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default().with_cancellation_token(ct.child_token()), + ); + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!( + "http://127.0.0.1:{}/mcp", + listener.local_addr().unwrap().port() + ); + let ct = ct.clone(); + tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled().await }) + .await + .unwrap(); + }); + tokio::time::sleep(Duration::from_millis(100)).await; + url +} + +/// Read an SSE byte stream until `needle` appears or timeout. +async fn sse_contains(resp: reqwest::Response, needle: &str, timeout: Duration) -> bool { + let mut stream = resp.bytes_stream(); + tokio::time::timeout(timeout, async { + let mut buffer = String::new(); + while let Some(Ok(chunk)) = stream.next().await { + buffer.push_str(&String::from_utf8_lossy(&chunk)); + if buffer.contains(needle) { + return true; + } + } + false + }) + .await + .unwrap_or(false) +} + +#[tokio::test] +async fn elicitation_rides_originating_post_stream_not_standalone_get() { + let ct = CancellationToken::new(); + let url = start_server(ct.clone()).await; + let client = reqwest::Client::new(); + + // 2025-11-25 is the latest session-carrying version; SEP-2567 serves + // 2026-07-28+ statelessly, with no standalone GET stream to test against. + let resp = client + .post(&url) + .header("Accept", "text/event-stream, application/json") + .header("Content-Type", "application/json") + .json(&json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": { "elicitation": {} }, + "clientInfo": { "name": "test-client", "version": "1.0.0" } + } + })) + .send() + .await + .unwrap(); + assert!(resp.status().is_success()); + let session_id = resp + .headers() + .get("Mcp-Session-Id") + .expect("session id") + .to_str() + .unwrap() + .to_string(); + + client + .post(&url) + .header("Accept", "text/event-stream, application/json") + .header("Content-Type", "application/json") + .header("Mcp-Session-Id", &session_id) + .json(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})) + .send() + .await + .unwrap(); + + // Standalone GET stream — must NEVER carry the elicitation request. + let get_stream = client + .get(&url) + .header("Accept", "text/event-stream") + .header("Mcp-Session-Id", &session_id) + .send() + .await + .unwrap(); + assert_eq!(get_stream.status(), 200); + + // The in-handler elicitation must appear on this tools/call SSE stream. + let post_stream = client + .post(&url) + .header("Accept", "text/event-stream, application/json") + .header("Content-Type", "application/json") + .header("Mcp-Session-Id", &session_id) + .json(&json!({ + "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": { "name": "ask", "arguments": {} } + })) + .send() + .await + .unwrap(); + assert!(post_stream.status().is_success()); + + assert!( + sse_contains(post_stream, "elicitation/create", Duration::from_secs(5)).await, + "elicitation request must be delivered on the originating POST SSE stream" + ); + assert!( + !sse_contains(get_stream, "elicitation/create", Duration::from_millis(500)).await, + "standalone GET stream must not carry the elicitation request" + ); + + ct.cancel(); +} diff --git a/vendor/rmcp/tests/test_server_discover.rs b/vendor/rmcp/tests/test_server_discover.rs new file mode 100644 index 000000000..f2ca3df20 --- /dev/null +++ b/vendor/rmcp/tests/test_server_discover.rs @@ -0,0 +1,255 @@ +use rmcp::model::{ + ClientCapabilities, ClientJsonRpcMessage, ClientRequest, DiscoverResult, ErrorCode, ErrorData, + JsonRpcRequest, JsonRpcResponse, ProtocolVersion, ServerJsonRpcMessage, ServerResult, +}; +use serde_json::json; + +#[test] +fn discover_request_deserializes_with_request_meta() { + let message: ClientJsonRpcMessage = serde_json::from_value(json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "test-client", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } + })) + .expect("discover request should deserialize"); + + let ClientJsonRpcMessage::Request(JsonRpcRequest { request, .. }) = message else { + panic!("expected request"); + }; + let ClientRequest::DiscoverRequest(request) = request else { + panic!("expected discover request"); + }; + + assert_eq!( + request + .extensions + .get::() + .and_then(|meta| meta.protocol_version()), + Some(ProtocolVersion::V_2026_07_28) + ); +} + +#[test] +fn discover_result_deserializes_to_typed_variant() { + let message: ServerJsonRpcMessage = serde_json::from_value(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "resultType": "complete", + "supportedVersions": ["2025-11-25", "2026-07-28"], + "capabilities": { "tools": {} }, + "serverInfo": { + "name": "test-server", + "version": "1.0.0" + }, + "ttlMs": 0, + "cacheScope": "private" + } + })) + .expect("discover result should deserialize"); + + let ServerJsonRpcMessage::Response(JsonRpcResponse { result, .. }) = message else { + panic!("expected response"); + }; + let ServerResult::DiscoverResult(DiscoverResult { + supported_versions, .. + }) = result + else { + panic!("expected discover result"); + }; + + assert_eq!( + supported_versions, + vec![ProtocolVersion::V_2025_11_25, ProtocolVersion::V_2026_07_28] + ); +} + +#[test] +fn discover_result_accepts_server_info_in_namespaced_metadata() { + let message: ServerJsonRpcMessage = serde_json::from_value(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "resultType": "complete", + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "ttlMs": 0, + "cacheScope": "private", + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "conformance-mock-server", + "version": "1.0.0" + }, + "unrelated": { "preserved": true } + } + } + })) + .expect("discovery response with namespaced server info should deserialize"); + + let ServerJsonRpcMessage::Response(JsonRpcResponse { result, .. }) = message else { + panic!("expected response"); + }; + let ServerResult::DiscoverResult(result) = result else { + panic!("expected discovery response, not a tool-call result"); + }; + + let server_info = result.server_info().expect("server info should be present"); + assert_eq!(server_info.name, "conformance-mock-server"); + assert_eq!(server_info.version, "1.0.0"); + + let metadata = result.meta.expect("discovery metadata should be preserved"); + assert_eq!( + metadata.0.get("io.modelcontextprotocol/serverInfo"), + Some(&json!({ + "name": "conformance-mock-server", + "version": "1.0.0" + })) + ); + assert_eq!( + metadata.0.get("unrelated"), + Some(&json!({ "preserved": true })) + ); +} + +#[test] +fn discover_result_serializes_server_info_in_namespaced_metadata() { + let mut result = DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + rmcp::model::ServerCapabilities::default(), + ); + result.meta = Some(rmcp::model::MetaObject( + json!({ + "io.modelcontextprotocol/serverInfo": { + "name": "stale-server", + "version": "0.1.0" + }, + "unrelated": { "preserved": true } + }) + .as_object() + .expect("metadata is an object") + .clone(), + )); + result.set_server_info(rmcp::model::Implementation::new("test-server", "1.0.0")); + + let serialized = serde_json::to_value(result).expect("serialize discovery result"); + assert!(serialized.get("serverInfo").is_none()); + assert_eq!( + serialized["_meta"]["io.modelcontextprotocol/serverInfo"], + json!({ + "name": "test-server", + "version": "1.0.0" + }) + ); + assert_eq!( + serialized["_meta"]["unrelated"], + json!({ "preserved": true }) + ); +} + +#[test] +fn discover_result_ignores_legacy_top_level_server_info() { + let result: DiscoverResult = serde_json::from_value(json!({ + "resultType": "complete", + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "serverInfo": { + "name": "top-level-server", + "version": "2.0.0" + }, + "ttlMs": 0, + "cacheScope": "private", + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "metadata-server", + "version": "1.0.0" + }, + "unrelated": true + } + })) + .expect("top-level server info should remain supported"); + + let server_info = result + .server_info() + .expect("namespaced server info is present"); + assert_eq!(server_info.name, "metadata-server"); + assert_eq!(server_info.version, "1.0.0"); + assert_eq!( + result + .meta + .as_ref() + .and_then(|metadata| metadata.0.get("unrelated")), + Some(&json!(true)) + ); +} + +#[test] +fn discover_result_allows_missing_or_malformed_optional_server_info() { + let result = json!({ + "resultType": "complete", + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "ttlMs": 0, + "cacheScope": "private", + "_meta": { "unrelated": true } + }); + + let result = + serde_json::from_value::(result).expect("server info metadata is optional"); + assert_eq!(result.server_info(), None); + + let malformed_server_info = json!({ + "resultType": "complete", + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "ttlMs": 0, + "cacheScope": "private", + "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "missing-version" } } + }); + + let result = serde_json::from_value::(malformed_server_info) + .expect("opaque metadata should not prevent deserialization"); + assert_eq!(result.server_info(), None); +} + +#[test] +fn unsupported_protocol_version_error_matches_draft_schema() { + let error = ErrorData::unsupported_protocol_version( + ProtocolVersion::V_2026_07_28, + &[ProtocolVersion::V_2025_11_25], + ); + + assert_eq!(error.code, ErrorCode::UNSUPPORTED_PROTOCOL_VERSION); + assert_eq!( + error.data, + Some(json!({ + "requested": "2026-07-28", + "supported": ["2025-11-25"] + })) + ); +} + +#[test] +fn missing_required_capability_error_matches_draft_schema() { + let required = ClientCapabilities::builder().enable_elicitation().build(); + let error = ErrorData::missing_required_client_capability(required); + + assert_eq!(error.code, ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY); + assert_eq!( + error.data, + Some(json!({ + "requiredCapabilities": { + "elicitation": {} + } + })) + ); +} diff --git a/vendor/rmcp/tests/test_server_discover_client.rs b/vendor/rmcp/tests/test_server_discover_client.rs new file mode 100644 index 000000000..3b309ddca --- /dev/null +++ b/vendor/rmcp/tests/test_server_discover_client.rs @@ -0,0 +1,77 @@ +#![cfg(all(feature = "client", not(feature = "local")))] + +use rmcp::{ + ClientHandler, ServerHandler, ServiceExt, + model::{ + ClientCapabilities, Implementation, ProtocolVersion, RequestMetaObject, ServerCapabilities, + ServerInfo, + }, + select_protocol_version, +}; + +#[derive(Clone, Default)] +struct DiscoveryServer; + +impl ServerHandler for DiscoveryServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new("discovery-server", "1.0.0")) + } +} + +#[derive(Clone, Default)] +struct DiscoveryClient; + +impl ClientHandler for DiscoveryClient {} + +#[test] +fn select_protocol_version_uses_client_preference_order() { + let selected = select_protocol_version( + &[ProtocolVersion::V_2026_07_28, ProtocolVersion::V_2025_11_25], + &[ProtocolVersion::V_2025_11_25, ProtocolVersion::V_2026_07_28], + ); + + assert_eq!(selected, Some(ProtocolVersion::V_2026_07_28)); +} + +#[test] +fn select_protocol_version_returns_none_without_overlap() { + let selected = select_protocol_version( + &[ProtocolVersion::V_2026_07_28], + &[ProtocolVersion::V_2025_11_25], + ); + + assert_eq!(selected, None); +} + +#[tokio::test] +async fn client_discover_helper_returns_typed_result() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + tokio::spawn(async move { + let _ = DiscoveryServer + .serve(server_transport) + .await + .expect("server should start") + .waiting() + .await; + }); + let client = DiscoveryClient + .serve(client_transport) + .await + .expect("client should connect"); + let mut meta = RequestMetaObject::new(); + meta.set_protocol_version(ProtocolVersion::V_2026_07_28); + meta.set_client_info(Implementation::new("discovery-client", "1.0.0")); + meta.set_client_capabilities(ClientCapabilities::default()); + + let result = client + .discover(meta) + .await + .expect("discover should succeed"); + + assert_eq!( + result.server_info(), + Some(Implementation::new("discovery-server", "1.0.0")) + ); + client.cancel().await.expect("client should cancel"); +} diff --git a/vendor/rmcp/tests/test_server_discover_http.rs b/vendor/rmcp/tests/test_server_discover_http.rs new file mode 100644 index 000000000..7a42f06a0 --- /dev/null +++ b/vendor/rmcp/tests/test_server_discover_http.rs @@ -0,0 +1,380 @@ +#![cfg(all( + not(feature = "local"), + feature = "reqwest", + feature = "transport-streamable-http-server" +))] + +use std::borrow::Cow; + +use rmcp::{ + ServerHandler, + model::{Implementation, ProtocolVersion, ServerCapabilities, ServerInfo}, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, +}; +use serde_json::json; +use tokio_util::sync::CancellationToken; + +#[derive(Clone, Default)] +struct DiscoveryServer; + +impl ServerHandler for DiscoveryServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new("discovery-server", "1.0.0")) + .with_instructions("Use the tools carefully") + } + + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(&[ProtocolVersion::V_2025_11_25]) + } +} + +async fn spawn_server(json_response: bool) -> (reqwest::Client, String, CancellationToken) { + spawn_server_with_legacy_session_mode(json_response, false).await +} + +async fn spawn_server_with_legacy_session_mode( + json_response: bool, + legacy_session_mode: bool, +) -> (reqwest::Client, String, CancellationToken) { + let cancellation_token = CancellationToken::new(); + let config = StreamableHttpServerConfig::default() + .with_legacy_session_mode(legacy_session_mode) + .with_json_response(json_response) + .with_sse_keep_alive(None) + .with_cancellation_token(cancellation_token.clone()); + let service: StreamableHttpService = + StreamableHttpService::new(|| Ok(DiscoveryServer), Default::default(), config); + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener should have address"); + + tokio::spawn({ + let cancellation_token = cancellation_token.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { + cancellation_token.cancelled_owned().await; + }) + .await; + } + }); + + ( + reqwest::Client::new(), + format!("http://{address}/mcp"), + cancellation_token, + ) +} + +fn discover_body(version: Option<&str>) -> serde_json::Value { + let meta = version.map(|version| { + json!({ + "io.modelcontextprotocol/protocolVersion": version, + "io.modelcontextprotocol/clientInfo": { + "name": "test-client", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }) + }); + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "server/discover", + "params": meta.map(|meta| json!({ "_meta": meta })).unwrap_or_else(|| json!({})) + }) +} + +async fn post_discover( + client: &reqwest::Client, + url: &str, + header_version: &str, + body_version: Option<&str>, +) -> reqwest::Response { + client + .post(url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", header_version) + .header("Mcp-Method", "server/discover") + .json(&discover_body(body_version)) + .send() + .await + .expect("discover request should send") +} + +#[tokio::test] +async fn discover_returns_server_metadata_without_session() { + let (client, url, cancellation_token) = spawn_server(true).await; + + let response = post_discover(&client, &url, "2025-11-25", Some("2025-11-25")).await; + + assert_eq!(response.status(), 200); + let body: serde_json::Value = response.json().await.expect("response should be JSON"); + assert_eq!( + body["result"], + json!({ + "resultType": "complete", + "supportedVersions": ["2025-11-25"], + "capabilities": { "tools": {} }, + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "discovery-server", + "version": "1.0.0" + } + }, + "instructions": "Use the tools carefully", + "ttlMs": 0, + "cacheScope": "private" + }) + ); + + cancellation_token.cancel(); +} + +#[tokio::test] +async fn discover_does_not_require_initialization_in_legacy_session_mode() { + let (client, url, cancellation_token) = spawn_server_with_legacy_session_mode(true, true).await; + + let response = post_discover(&client, &url, "2025-11-25", Some("2025-11-25")).await; + + assert_eq!(response.status(), 200); + assert!(response.headers().get("Mcp-Session-Id").is_none()); + + cancellation_token.cancel(); +} + +#[tokio::test] +async fn discover_rejects_unsupported_version_with_http_400() { + let (client, url, cancellation_token) = spawn_server(true).await; + + let response = post_discover(&client, &url, "2026-07-28", Some("2026-07-28")).await; + + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await.expect("response should be JSON"); + assert_eq!(body["error"]["code"], -32022); + assert_eq!( + body["error"]["data"], + json!({ + "requested": "2026-07-28", + "supported": ["2025-11-25"] + }) + ); + + cancellation_token.cancel(); +} + +#[tokio::test] +async fn discover_rejects_unknown_version_with_typed_error() { + let (client, url, cancellation_token) = spawn_server(true).await; + + let response = post_discover(&client, &url, "2099-01-01", Some("2099-01-01")).await; + + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await.expect("response should be JSON"); + assert_eq!(body["error"]["code"], -32022); + + cancellation_token.cancel(); +} + +#[tokio::test] +async fn regular_request_rejects_server_unsupported_meta_version() { + let (client, url, cancellation_token) = spawn_server(true).await; + let body = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28" + } + } + }); + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2026-07-28") + .header("Mcp-Method", "tools/list") + .json(&body) + .send() + .await + .expect("request should send"); + + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await.expect("response should be JSON"); + assert_eq!(body["error"]["code"], -32022); + + cancellation_token.cancel(); +} + +#[tokio::test] +async fn unknown_rpc_uses_http_404_for_per_request_protocol() { + let (client, url, cancellation_token) = spawn_server(true).await; + let body = json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "unknown/method", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2025-11-25" + } + } + }); + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2025-11-25") + .json(&body) + .send() + .await + .expect("request should send"); + + assert_eq!(response.status(), 404); + + cancellation_token.cancel(); +} + +#[tokio::test] +async fn legacy_unknown_rpc_preserves_http_200_jsonrpc_error() { + let (client, url, cancellation_token) = spawn_server(true).await; + let body = json!({ + "jsonrpc": "2.0", + "id": 5, + "method": "unknown/method", + "params": {} + }); + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2025-11-25") + .json(&body) + .send() + .await + .expect("request should send"); + + assert_eq!(response.status(), 200); + let body: serde_json::Value = response.json().await.expect("response should be JSON"); + assert_eq!(body["error"]["code"], -32601); + + cancellation_token.cancel(); +} + +#[tokio::test] +async fn discover_rejects_header_meta_version_mismatch() { + let (client, url, cancellation_token) = spawn_server(true).await; + + let response = post_discover(&client, &url, "2026-07-28", Some("2025-11-25")).await; + + assert_eq!(response.status(), 400); + + cancellation_token.cancel(); +} + +#[tokio::test] +async fn discover_rejects_missing_request_meta() { + let (client, url, cancellation_token) = spawn_server(true).await; + + let response = post_discover(&client, &url, "2025-11-25", None).await; + + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await.expect("response should be JSON"); + assert_eq!(body["error"]["code"], -32602); + + cancellation_token.cancel(); +} + +#[tokio::test] +async fn discover_rejects_missing_client_capabilities() { + let (client, url, cancellation_token) = spawn_server(true).await; + let body = json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2025-11-25", + "io.modelcontextprotocol/clientInfo": { + "name": "test-client", + "version": "1.0.0" + } + } + } + }); + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2025-11-25") + .json(&body) + .send() + .await + .expect("request should send"); + + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await.expect("response should be JSON"); + assert_eq!(body["error"]["code"], -32602); + + cancellation_token.cancel(); +} + +#[tokio::test] +async fn discover_accepts_missing_optional_client_info() { + let (client, url, cancellation_token) = spawn_server(true).await; + let body = json!({ + "jsonrpc": "2.0", + "id": 5, + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2025-11-25", + "io.modelcontextprotocol/clientCapabilities": {} + } + } + }); + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2025-11-25") + .json(&body) + .send() + .await + .expect("request should send"); + + assert_eq!(response.status(), 200); + let body: serde_json::Value = response.json().await.expect("response should be JSON"); + assert!(body.get("result").is_some()); + + cancellation_token.cancel(); +} + +#[tokio::test] +async fn discover_error_uses_http_400_when_sse_is_configured() { + let (client, url, cancellation_token) = spawn_server(false).await; + + let response = post_discover(&client, &url, "2026-07-28", Some("2026-07-28")).await; + + assert_eq!(response.status(), 400); + assert_eq!( + response + .headers() + .get("Content-Type") + .and_then(|value| value.to_str().ok()), + Some("application/json") + ); + + cancellation_token.cancel(); +} diff --git a/vendor/rmcp/tests/test_server_initialization.rs b/vendor/rmcp/tests/test_server_initialization.rs new file mode 100644 index 000000000..6542e295b --- /dev/null +++ b/vendor/rmcp/tests/test_server_initialization.rs @@ -0,0 +1,396 @@ +// cargo test --features "client" --package rmcp -- server_init +#![cfg(all(feature = "client", not(feature = "local")))] +mod common; + +use common::handlers::TestServer; +use rmcp::{ + ServerHandler, ServiceExt, + model::{ + ClientJsonRpcMessage, ProtocolVersion, ServerCapabilities, ServerInfo, + ServerJsonRpcMessage, ServerResult, + }, + transport::{IntoTransport, Transport}, +}; + +fn msg(raw: &str) -> ClientJsonRpcMessage { + serde_json::from_str(raw).expect("invalid test message JSON") +} + +fn init_request() -> ClientJsonRpcMessage { + msg(r#"{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": { "name": "test-client", "version": "0.0.1" } + } + }"#) +} + +fn initialized_notification() -> ClientJsonRpcMessage { + msg(r#"{ "jsonrpc": "2.0", "method": "notifications/initialized" }"#) +} + +fn set_level_request(id: u64) -> ClientJsonRpcMessage { + msg(&format!( + r#"{{ "jsonrpc": "2.0", "id": {id}, "method": "logging/setLevel", "params": {{ "level": "info" }} }}"# + )) +} + +fn ping_request(id: u64) -> ClientJsonRpcMessage { + msg(&format!( + r#"{{ "jsonrpc": "2.0", "id": {id}, "method": "ping" }}"# + )) +} + +fn list_tools_request(id: u64) -> ClientJsonRpcMessage { + msg(&format!( + r#"{{ "jsonrpc": "2.0", "id": {id}, "method": "tools/list" }}"# + )) +} + +async fn do_initialize(client: &mut impl Transport) { + client.send(init_request()).await.unwrap(); + let _response = client.receive().await.unwrap(); +} + +// Server handles setLevel sent before initialized notification (processed by serve_inner). +#[tokio::test] +async fn server_init_set_level_response_is_empty_result() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let _server = tokio::spawn(async move { TestServer::new().serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + do_initialize(&mut client).await; + client.send(set_level_request(2)).await.unwrap(); + + // The handler may send logging notifications before the response; + // skip notifications to find the EmptyResult response. + let response = loop { + let msg = client.receive().await.unwrap(); + if matches!(msg, ServerJsonRpcMessage::Response(_)) { + break msg; + } + }; + assert!( + matches!( + response, + ServerJsonRpcMessage::Response(ref r) + if matches!(r.result, ServerResult::EmptyResult(_)) + ), + "expected EmptyResult for setLevel, got: {response:?}" + ); +} + +// Server initializes successfully when setLevel is sent before the initialized notification. +#[tokio::test] +async fn server_init_succeeds_after_set_level_before_initialized() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server_handle = + tokio::spawn(async move { TestServer::new().serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + do_initialize(&mut client).await; + client.send(set_level_request(2)).await.unwrap(); + // Skip notifications until we get the response + loop { + let msg = client.receive().await.unwrap(); + if matches!(msg, ServerJsonRpcMessage::Response(_)) { + break; + } + } + client.send(initialized_notification()).await.unwrap(); + + let result = server_handle.await.unwrap(); + assert!( + result.is_ok(), + "server should initialize successfully after setLevel" + ); + result.unwrap().cancel().await.unwrap(); +} + +// Server responds with EmptyResult to ping received before initialize request. +#[tokio::test] +async fn server_init_ping_response_is_empty_result_before_initialize() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let _server = tokio::spawn(async move { TestServer::new().serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + client.send(ping_request(1)).await.unwrap(); + + let response = client.receive().await.unwrap(); + assert!( + matches!( + response, + ServerJsonRpcMessage::Response(ref r) + if matches!(r.result, ServerResult::EmptyResult(_)) + ), + "expected EmptyResult for pre-initialize ping, got: {response:?}" + ); +} + +// Server initializes successfully when ping is sent before the initialize request. +#[tokio::test] +async fn server_init_succeeds_after_ping_before_initialize() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server_handle = + tokio::spawn(async move { TestServer::new().serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + client.send(ping_request(1)).await.unwrap(); + let _pong = client.receive().await.unwrap(); + do_initialize(&mut client).await; + client.send(initialized_notification()).await.unwrap(); + + let result = server_handle.await.unwrap(); + assert!( + result.is_ok(), + "server should initialize successfully after pre-initialize ping" + ); + result.unwrap().cancel().await.unwrap(); +} + +// Server responds with EmptyResult to ping received before initialized. +#[tokio::test] +async fn server_init_ping_response_is_empty_result() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let _server = tokio::spawn(async move { TestServer::new().serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + do_initialize(&mut client).await; + client.send(ping_request(2)).await.unwrap(); + + let response = client.receive().await.unwrap(); + assert!( + matches!( + response, + ServerJsonRpcMessage::Response(ref r) + if matches!(r.result, ServerResult::EmptyResult(_)) + ), + "expected EmptyResult for ping, got: {response:?}" + ); +} + +// Server initializes successfully when ping is sent before the initialized notification. +#[tokio::test] +async fn server_init_succeeds_after_ping_before_initialized() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server_handle = + tokio::spawn(async move { TestServer::new().serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + do_initialize(&mut client).await; + client.send(ping_request(2)).await.unwrap(); + let _response = client.receive().await.unwrap(); + client.send(initialized_notification()).await.unwrap(); + + let result = server_handle.await.unwrap(); + assert!( + result.is_ok(), + "server should initialize successfully after ping" + ); + result.unwrap().cancel().await.unwrap(); +} + +// Server buffers tools/list sent before initialized and processes it after initialization. +#[tokio::test] +async fn server_init_buffers_request_before_initialized() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server_handle = + tokio::spawn(async move { TestServer::new().serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + do_initialize(&mut client).await; + // Send tools/list before initialized notification + client.send(list_tools_request(2)).await.unwrap(); + // Now send initialized notification + client.send(initialized_notification()).await.unwrap(); + + // The buffered tools/list should be processed — expect a response + let response = client.receive().await.unwrap(); + assert!( + matches!(response, ServerJsonRpcMessage::Response(_)), + "expected response for buffered tools/list, got: {response:?}" + ); + + let result = server_handle.await.unwrap(); + assert!( + result.is_ok(), + "server should initialize successfully when buffering pre-init messages" + ); + result.unwrap().cancel().await.unwrap(); +} + +fn init_request_with_version(v: &str) -> ClientJsonRpcMessage { + msg(&format!( + r#"{{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {{ + "protocolVersion": "{v}", + "capabilities": {{}}, + "clientInfo": {{ "name": "test-client", "version": "0.0.1" }} + }} + }}"# + )) +} + +async fn negotiate_version(handler: H, client_version: &str) -> ProtocolVersion +where + H: ServerHandler + 'static, +{ + let (server_transport, client_transport) = tokio::io::duplex(4096); + let _server = tokio::spawn(async move { handler.serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + client + .send(init_request_with_version(client_version)) + .await + .unwrap(); + let response = client.receive().await.unwrap(); + let ServerJsonRpcMessage::Response(r) = response else { + panic!("expected initialize response, got {response:?}"); + }; + let ServerResult::InitializeResult(init) = r.result else { + panic!("expected InitializeResult"); + }; + init.protocol_version +} + +#[tokio::test] +async fn server_echoes_client_protocol_version_when_known_old() { + let negotiated = negotiate_version(TestServer::new(), "2024-11-05").await; + assert_eq!(negotiated, ProtocolVersion::V_2024_11_05); +} + +#[tokio::test] +async fn server_echoes_client_protocol_version_when_latest() { + let negotiated = negotiate_version(TestServer::new(), "2025-11-25").await; + assert_eq!(negotiated, ProtocolVersion::LATEST); +} + +#[tokio::test] +async fn server_falls_back_when_client_protocol_version_unknown() { + let negotiated = negotiate_version(TestServer::new(), "2099-99-99").await; + assert_eq!(negotiated, ProtocolVersion::LATEST); +} + +struct PinnedServer; + +impl ServerHandler for PinnedServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().build()) + .with_protocol_version(ProtocolVersion::V_2025_06_18) + } +} + +#[tokio::test] +async fn server_pinned_version_does_not_override_known_client_request() { + let negotiated = negotiate_version(PinnedServer, "2025-11-25").await; + assert_eq!(negotiated, ProtocolVersion::LATEST); +} + +#[tokio::test] +async fn server_pinned_version_used_as_fallback_for_unknown_client_request() { + let negotiated = negotiate_version(PinnedServer, "2099-99-99").await; + assert_eq!(negotiated, ProtocolVersion::V_2025_06_18); +} + +fn duplicate_init_request(id: u64, version: &str) -> ClientJsonRpcMessage { + msg(&format!( + r#"{{ + "jsonrpc": "2.0", + "id": {id}, + "method": "initialize", + "params": {{ + "protocolVersion": "{version}", + "capabilities": {{ "sampling": {{}} }}, + "clientInfo": {{ "name": "renegotiated-client", "version": "9.9.9" }} + }} + }}"# + )) +} + +#[tokio::test] +async fn server_accepts_duplicate_initialize() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let _server = tokio::spawn(async move { TestServer::new().serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + do_initialize(&mut client).await; + client.send(initialized_notification()).await.unwrap(); + + client + .send(duplicate_init_request(2, "2025-11-25")) + .await + .unwrap(); + let response = client.receive().await.unwrap(); + assert!( + matches!(response, ServerJsonRpcMessage::Response(_)), + "expected successful InitializeResult, got: {response:?}" + ); +} + +#[tokio::test] +async fn server_session_remains_usable_after_renegotiation() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let _server = tokio::spawn(async move { TestServer::new().serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + do_initialize(&mut client).await; + client.send(initialized_notification()).await.unwrap(); + client + .send(duplicate_init_request(2, "2025-11-25")) + .await + .unwrap(); + let _renegotiated = client.receive().await.unwrap(); + + client.send(ping_request(3)).await.unwrap(); + let pong = client.receive().await.unwrap(); + assert!( + matches!( + pong, + ServerJsonRpcMessage::Response(ref r) + if matches!(r.result, ServerResult::EmptyResult(_)) + ), + "expected EmptyResult ping after renegotiation, got: {pong:?}" + ); +} + +// Server buffers multiple requests before initialized and processes them in order. +#[tokio::test] +async fn server_init_buffers_multiple_requests_before_initialized() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server_handle = + tokio::spawn(async move { TestServer::new().serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + do_initialize(&mut client).await; + // Send two requests before initialized + client.send(list_tools_request(2)).await.unwrap(); + client.send(ping_request(3)).await.unwrap(); + // Now send initialized notification + client.send(initialized_notification()).await.unwrap(); + + // Both buffered messages should get responses + let response1 = client.receive().await.unwrap(); + let response2 = client.receive().await.unwrap(); + assert!( + matches!(response1, ServerJsonRpcMessage::Response(_)), + "expected response for first buffered message, got: {response1:?}" + ); + assert!( + matches!(response2, ServerJsonRpcMessage::Response(_)), + "expected response for second buffered message, got: {response2:?}" + ); + + let result = server_handle.await.unwrap(); + assert!( + result.is_ok(), + "server should initialize successfully with multiple buffered messages" + ); + result.unwrap().cancel().await.unwrap(); +} diff --git a/vendor/rmcp/tests/test_sse_concurrent_streams.rs b/vendor/rmcp/tests/test_sse_concurrent_streams.rs new file mode 100644 index 000000000..e1e885282 --- /dev/null +++ b/vendor/rmcp/tests/test_sse_concurrent_streams.rs @@ -0,0 +1,775 @@ +#![cfg(not(feature = "local"))] +/// Tests for concurrent SSE stream handling (shadow channels) +/// +/// These tests verify that multiple GET SSE streams on the same session +/// don't kill each other by replacing the common channel sender. +/// +/// Root cause: When POST SSE responses include `retry`, the EventSource API +/// reconnects via GET after the stream ends. Each GET was unconditionally +/// replacing `self.common.tx`, killing the other stream's receiver — causing +/// an infinite reconnect loop every `sse_retry` seconds. +/// +/// Fix: `resume_or_shadow_common()` checks if the primary common channel is +/// still active. If so, it creates a "shadow" stream (idle, keep-alive only) +/// instead of replacing the primary. +use std::sync::Arc; +use std::time::Duration; + +use futures::StreamExt; +use rmcp::{ + RoleServer, ServerHandler, + model::{Implementation, ServerCapabilities, ServerInfo, ToolsCapability}, + service::NotificationContext, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, +}; +use serde_json::json; +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; + +const ACCEPT_SSE: &str = "text/event-stream"; +const ACCEPT_BOTH: &str = "text/event-stream, application/json"; + +// ─── Test server ──────────────────────────────────────────────────────────── + +#[derive(Clone)] +pub struct TestServer { + trigger: Arc, +} + +impl TestServer { + fn new(trigger: Arc) -> Self { + Self { trigger } + } +} + +impl ServerHandler for TestServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools_with({ + let mut tools = ToolsCapability::default(); + tools.list_changed = Some(true); + tools + }) + .build(), + ) + .with_server_info(Implementation::new("test-server", "1.0.0")) + } + + async fn on_initialized(&self, context: NotificationContext) { + let peer = context.peer.clone(); + let trigger = self.trigger.clone(); + + tokio::spawn(async move { + trigger.notified().await; + let _ = peer.notify_tool_list_changed().await; + }); + } +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +async fn start_test_server(ct: CancellationToken, trigger: Arc) -> String { + let server = TestServer::new(trigger); + let service = StreamableHttpService::new( + move || Ok(server.clone()), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default().with_cancellation_token(ct.child_token()), + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().unwrap(); + let url = format!("http://127.0.0.1:{}/mcp", addr.port()); + + let ct_clone = ct.clone(); + tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(async move { ct_clone.cancelled().await }) + .await + .unwrap(); + }); + + tokio::time::sleep(Duration::from_millis(100)).await; + url +} + +/// POST initialize and return session ID. +async fn initialize_session(client: &reqwest::Client, url: &str) -> String { + let resp = client + .post(url) + .header("Accept", ACCEPT_BOTH) + .header("Content-Type", "application/json") + .json(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "test-client", "version": "1.0.0" } + } + })) + .timeout(Duration::from_millis(500)) + .send() + .await + .expect("POST initialize"); + + assert!(resp.status().is_success(), "initialize should succeed"); + + resp.headers() + .get("Mcp-Session-Id") + .expect("session ID header") + .to_str() + .unwrap() + .to_string() +} + +/// POST `notifications/initialized` to complete the MCP handshake. +/// This triggers the server's `on_initialized` handler. +async fn send_initialized_notification(client: &reqwest::Client, url: &str, session_id: &str) { + let resp = client + .post(url) + .header("Accept", ACCEPT_BOTH) + .header("Content-Type", "application/json") + .header("Mcp-Session-Id", session_id) + .json(&json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized" + })) + .send() + .await + .expect("POST notifications/initialized"); + + assert_eq!( + resp.status().as_u16(), + 202, + "notifications/initialized should return 202 Accepted" + ); +} + +/// Open a standalone GET SSE stream (no Last-Event-ID). +async fn open_standalone_get( + client: &reqwest::Client, + url: &str, + session_id: &str, +) -> reqwest::Response { + client + .get(url) + .header("Accept", ACCEPT_SSE) + .header("Mcp-Session-Id", session_id) + .send() + .await + .expect("GET SSE stream") +} + +/// Open a GET SSE stream with Last-Event-ID (resume). +async fn open_resume_get( + client: &reqwest::Client, + url: &str, + session_id: &str, + last_event_id: &str, +) -> reqwest::Response { + client + .get(url) + .header("Accept", ACCEPT_SSE) + .header("Mcp-Session-Id", session_id) + .header("Last-Event-ID", last_event_id) + .send() + .await + .expect("GET SSE stream with Last-Event-ID") +} + +/// Read from an SSE byte stream until we find a specific text or timeout. +async fn wait_for_sse_event(resp: reqwest::Response, needle: &str, timeout: Duration) -> bool { + let mut stream = resp.bytes_stream(); + let result = tokio::time::timeout(timeout, async { + while let Some(Ok(chunk)) = stream.next().await { + let text = String::from_utf8_lossy(&chunk); + if text.contains(needle) { + return true; + } + } + false + }) + .await; + + matches!(result, Ok(true)) +} + +// ─── Tests: Shadow stream creation ────────────────────────────────────────── + +/// Second standalone GET with same session ID should return 200 OK +/// (shadow stream), NOT 409 Conflict. +#[tokio::test] +async fn shadow_second_standalone_get_returns_200() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + + // First GET — becomes primary common channel + let get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get1.status(), 200, "First GET should succeed"); + + tokio::time::sleep(Duration::from_millis(100)).await; + + // Second GET — should get 200 (shadow), NOT 409 + let get2 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!( + get2.status(), + 200, + "Second GET should return 200 (shadow stream), not 409 Conflict" + ); + + ct.cancel(); +} + +/// Multiple standalone GETs should all return 200 — the server can handle +/// many shadow streams concurrently. +#[tokio::test] +async fn shadow_multiple_standalone_gets_all_succeed() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + + // Open 5 concurrent standalone GETs + let mut responses = Vec::new(); + for i in 0..5 { + let resp = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(resp.status(), 200, "GET #{i} should succeed"); + responses.push(resp); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // All 5 should be alive (first is primary, rest are shadows) + assert_eq!(responses.len(), 5); + + ct.cancel(); +} + +// ─── Tests: Dead primary replacement ──────────────────────────────────────── + +/// When the primary common channel is dead (first GET dropped), the next GET +/// should replace it and become the new primary. +#[tokio::test] +async fn dead_primary_gets_replaced_by_next_get() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + + // First GET — becomes primary + let get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get1.status(), 200); + + // Drop primary — kills receiver, making sender closed + drop(get1); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Second GET — primary is dead, should replace it + let get2 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!( + get2.status(), + 200, + "GET should succeed as new primary after old primary was dropped" + ); + + ct.cancel(); +} + +/// After primary dies, the replacement primary should be able to receive +/// notifications (verifies the channel was actually replaced, not shadowed). +#[tokio::test] +async fn dead_primary_replacement_receives_notifications() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger.clone()).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + send_initialized_notification(&client, &url, &session_id).await; + tokio::time::sleep(Duration::from_millis(100)).await; + + // First GET — becomes primary + let get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get1.status(), 200); + + // Drop primary + drop(get1); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Second GET — becomes new primary (replacement) + let get2 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get2.status(), 200); + + // Trigger notification — should arrive on get2 (the new primary) + trigger.notify_one(); + + assert!( + wait_for_sse_event(get2, "tools/list_changed", Duration::from_secs(3)).await, + "Replacement primary should receive notifications" + ); + + ct.cancel(); +} + +/// Multiple drops and replacements should work: primary can be replaced +/// more than once. +#[tokio::test] +async fn dead_primary_can_be_replaced_multiple_times() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + + for i in 0..3 { + let get = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get.status(), 200, "GET #{i} should succeed"); + drop(get); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + // Final GET should still work + let final_get = open_standalone_get(&client, &url, &session_id).await; + assert_eq!( + final_get.status(), + 200, + "GET after multiple replacements should succeed" + ); + + ct.cancel(); +} + +// ─── Tests: Notification routing ──────────────────────────────────────────── + +/// Notification should arrive on the primary stream even after shadow streams +/// are created by subsequent GETs. +#[tokio::test] +async fn notification_reaches_primary_not_shadow() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger.clone()).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + send_initialized_notification(&client, &url, &session_id).await; + tokio::time::sleep(Duration::from_millis(200)).await; + + // First GET — primary common channel + let get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get1.status(), 200); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Second GET — shadow stream (should NOT steal notifications) + let _get2 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(_get2.status(), 200); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Trigger notification + trigger.notify_one(); + + // Primary stream should receive the notification + assert!( + wait_for_sse_event(get1, "tools/list_changed", Duration::from_secs(3)).await, + "Primary stream should receive notification even after shadow was created" + ); + + ct.cancel(); +} + +// ─── Tests: Resume with Last-Event-ID ─────────────────────────────────────── + +/// GET with Last-Event-ID referencing a completed request-wise channel should +/// fall through to shadow (not crash or return 500). +/// +/// This simulates the real-world scenario: POST SSE response ends, the +/// EventSource reconnects via GET with the last event ID from the POST stream. +/// The request-wise channel no longer exists, so the server should create a +/// shadow stream. +#[tokio::test] +async fn resume_completed_request_wise_creates_shadow() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + + // First GET — establish primary + let _get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(_get1.status(), 200); + tokio::time::sleep(Duration::from_millis(100)).await; + + // GET with Last-Event-ID for non-existent request-wise channel + let get_resume = open_resume_get(&client, &url, &session_id, "0/999").await; + assert_eq!( + get_resume.status(), + 200, + "Resume of completed request-wise channel should return 200 (shadow)" + ); + + ct.cancel(); +} + +/// GET with Last-Event-ID "0" (common channel resume) while primary is alive +/// should create a shadow. +#[tokio::test] +async fn resume_common_while_primary_alive_creates_shadow() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + + // First GET — establish primary + let _get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(_get1.status(), 200); + tokio::time::sleep(Duration::from_millis(100)).await; + + // GET with Last-Event-ID "0" — resume common while primary alive → shadow + let get_resume = open_resume_get(&client, &url, &session_id, "0").await; + assert_eq!( + get_resume.status(), + 200, + "Common channel resume while primary alive should return 200 (shadow)" + ); + + ct.cancel(); +} + +/// GET with Last-Event-ID "0" (common channel resume) while primary is dead +/// should become the new primary. +#[tokio::test] +async fn resume_common_while_primary_dead_becomes_primary() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger.clone()).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + send_initialized_notification(&client, &url, &session_id).await; + tokio::time::sleep(Duration::from_millis(200)).await; + + // First GET — establish primary + let get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get1.status(), 200); + + // Drop primary + drop(get1); + tokio::time::sleep(Duration::from_millis(100)).await; + + // GET with Last-Event-ID "0" — primary dead → becomes new primary + let get_resume = open_resume_get(&client, &url, &session_id, "0").await; + assert_eq!(get_resume.status(), 200); + + // New primary should receive notifications + trigger.notify_one(); + + assert!( + wait_for_sse_event(get_resume, "tools/list_changed", Duration::from_secs(3)).await, + "Resumed stream that replaced dead primary should receive notifications" + ); + + ct.cancel(); +} + +// ─── Tests: Mixed scenarios ───────────────────────────────────────────────── + +/// POST SSE reconnections and standalone GET should coexist: POST initialize +/// creates a request-wise channel, its EventSource reconnects via GET after +/// the stream ends, while a standalone GET is also active. +#[tokio::test] +async fn post_reconnect_and_standalone_coexist() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + + // Standalone GET — becomes primary + let _standalone = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(_standalone.status(), 200); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Simulate POST SSE response reconnection (EventSource reconnects with + // Last-Event-ID from the initialize POST stream). The request-wise channel + // for the initialize request is already completed. + let reconnect1 = open_resume_get(&client, &url, &session_id, "0/0").await; + assert_eq!( + reconnect1.status(), + 200, + "POST reconnection should get shadow, not replace primary" + ); + + tokio::time::sleep(Duration::from_millis(100)).await; + + // Another POST reconnection (e.g. from tools/list response) + let reconnect2 = open_resume_get(&client, &url, &session_id, "0/1").await; + assert_eq!( + reconnect2.status(), + 200, + "Second POST reconnection should also succeed" + ); + + ct.cancel(); +} + +/// Standalone GET is dropped (e.g. client timeout), a new standalone GET +/// connects. The new one should become the primary and receive notifications. +#[tokio::test] +async fn reconnect_after_stream_timeout() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger.clone()).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + send_initialized_notification(&client, &url, &session_id).await; + tokio::time::sleep(Duration::from_millis(200)).await; + + // First standalone GET — primary + let get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get1.status(), 200); + + // Client drops the stream (e.g. timeout or reconnection) + drop(get1); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Client reconnects with a new standalone GET + let get2 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get2.status(), 200); + + // Notification should reach the new primary + trigger.notify_one(); + + assert!( + wait_for_sse_event(get2, "tools/list_changed", Duration::from_secs(3)).await, + "Reconnected stream should receive notifications" + ); + + ct.cancel(); +} + +// ─── Tests: Edge cases ────────────────────────────────────────────────────── + +/// GET with an unknown session ID should return 404 Not Found per MCP spec. +/// This signals the client to re-initialize (not re-authenticate). +#[tokio::test] +async fn get_without_valid_session_returns_404() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger).await; + let client = reqwest::Client::new(); + + let resp = client + .get(&url) + .header("Accept", ACCEPT_SSE) + .header("Mcp-Session-Id", "nonexistent-session-id") + .send() + .await + .expect("GET with invalid session"); + + assert_eq!( + resp.status().as_u16(), + 404, + "GET with unknown session ID should return 404 Not Found per MCP spec" + ); + + ct.cancel(); +} + +/// GET without session ID header should return 400 Bad Request per MCP spec. +#[tokio::test] +async fn get_without_session_id_header_returns_400() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger).await; + let client = reqwest::Client::new(); + + let resp = client + .get(&url) + .header("Accept", ACCEPT_SSE) + .send() + .await + .expect("GET without session ID"); + + assert_eq!( + resp.status().as_u16(), + 400, + "GET without session ID should return 400 Bad Request per MCP spec" + ); + + ct.cancel(); +} + +/// Shadow streams should be idle — they should NOT receive notifications. +/// Only the primary receives them. +#[tokio::test] +async fn shadow_stream_does_not_receive_notifications() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger.clone()).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + send_initialized_notification(&client, &url, &session_id).await; + tokio::time::sleep(Duration::from_millis(200)).await; + + // First GET — primary + let _get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(_get1.status(), 200); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Second GET — shadow + let get2 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get2.status(), 200); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Trigger notification + trigger.notify_one(); + + // Shadow stream should NOT receive the notification (timeout expected) + let shadow_received = + wait_for_sse_event(get2, "tools/list_changed", Duration::from_millis(500)).await; + assert!( + !shadow_received, + "Shadow stream should NOT receive notifications" + ); + + ct.cancel(); +} + +/// Dropping all shadow streams should not affect the primary channel. +/// Primary should still receive notifications after all shadows are dropped. +#[tokio::test] +async fn dropping_shadows_does_not_affect_primary() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger.clone()).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + send_initialized_notification(&client, &url, &session_id).await; + tokio::time::sleep(Duration::from_millis(200)).await; + + // Primary GET + let get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get1.status(), 200); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Create and drop several shadows + for _ in 0..3 { + let shadow = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(shadow.status(), 200); + drop(shadow); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // Trigger notification — primary should still receive it + trigger.notify_one(); + + assert!( + wait_for_sse_event(get1, "tools/list_changed", Duration::from_secs(3)).await, + "Primary should still work after all shadows are dropped" + ); + + ct.cancel(); +} + +// ─── Tests: Cache replay on dead primary replacement ───────────────────────── + +/// When a notification is sent while the primary is alive, then the primary +/// dies and a new GET resumes with Last-Event-ID "0", the replacement primary +/// should receive the cached notification via sync() replay. +#[tokio::test] +async fn dead_primary_replacement_replays_cached_events() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger.clone()).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + send_initialized_notification(&client, &url, &session_id).await; + tokio::time::sleep(Duration::from_millis(200)).await; + + // First GET — becomes primary + let get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get1.status(), 200); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Trigger notification while primary is alive (gets cached) + trigger.notify_one(); + tokio::time::sleep(Duration::from_millis(200)).await; + + // Drop primary — notification was sent and cached + drop(get1); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Resume with Last-Event-ID "0" — primary is dead, should replace it + // and replay cached events from index 0 + let get_resume = open_resume_get(&client, &url, &session_id, "0").await; + assert_eq!(get_resume.status(), 200); + + // The cached notification should be replayed on the new primary + assert!( + wait_for_sse_event(get_resume, "tools/list_changed", Duration::from_secs(3)).await, + "Replacement primary should receive cached notification via sync() replay" + ); + + ct.cancel(); +} + +// ─── Tests: Shadow stream limits ───────────────────────────────────────────── + +/// Opening more than 32 shadow streams should not crash or reject — the server +/// drops the oldest shadow to stay within the limit. Primary still works. +#[tokio::test] +async fn shadow_stream_limit_drops_oldest() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger.clone()).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + send_initialized_notification(&client, &url, &session_id).await; + tokio::time::sleep(Duration::from_millis(200)).await; + + // First GET — primary + let get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get1.status(), 200); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Open 35 shadow streams (exceeds MAX_SHADOW_STREAMS=32) + let mut shadows = Vec::new(); + for i in 0..35 { + let shadow = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(shadow.status(), 200, "Shadow #{i} should succeed"); + shadows.push(shadow); + } + + // Primary should still receive notifications despite shadow churn + trigger.notify_one(); + + assert!( + wait_for_sse_event(get1, "tools/list_changed", Duration::from_secs(3)).await, + "Primary should still work after exceeding shadow limit" + ); + + ct.cancel(); +} diff --git a/vendor/rmcp/tests/test_stateless_protocol_version.rs b/vendor/rmcp/tests/test_stateless_protocol_version.rs new file mode 100644 index 000000000..02222ec2c --- /dev/null +++ b/vendor/rmcp/tests/test_stateless_protocol_version.rs @@ -0,0 +1,226 @@ +//! Tests for protocol version negotiation in stateless HTTP mode. +//! +//! Supported versions are echoed back; unknown versions, and versions outside +//! the server's `supported_protocol_versions`, fall back to the handler's own +//! version. +#![cfg(not(feature = "local"))] + +use std::borrow::Cow; + +use rmcp::{ + ErrorData, RoleServer, ServerHandler, + model::{ + InitializeRequestParams, InitializeResult, ProtocolVersion, ServerCapabilities, ServerInfo, + }, + service::RequestContext, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, +}; +use tokio_util::sync::CancellationToken; + +#[derive(Clone, Default)] +struct OverridingInitialize; + +impl ServerHandler for OverridingInitialize { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::default()) + } + + async fn initialize( + &self, + _request: InitializeRequestParams, + _context: RequestContext, + ) -> Result { + Ok(self.get_info()) + } +} + +/// Every known version except `2026-07-28`, standing in for a server that has +/// not implemented that revision. +const NARROWED_VERSIONS: &[ProtocolVersion] = &[ + ProtocolVersion::V_2024_11_05, + ProtocolVersion::V_2025_03_26, + ProtocolVersion::V_2025_06_18, + ProtocolVersion::V_2025_11_25, +]; + +/// Overrides `initialize`, so the handler-side default negotiation never runs, +/// *and* narrows the supported versions. +#[derive(Clone, Default)] +struct NarrowedOverridingInitialize; + +impl ServerHandler for NarrowedOverridingInitialize { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::default()) + } + + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(NARROWED_VERSIONS) + } + + async fn initialize( + &self, + _request: InitializeRequestParams, + _context: RequestContext, + ) -> Result { + Ok(self.get_info()) + } +} + +fn stateless_sse_config() -> StreamableHttpServerConfig { + StreamableHttpServerConfig::default() + .with_legacy_session_mode(false) + .with_sse_keep_alive(None) + .with_cancellation_token(CancellationToken::new()) +} + +fn stateless_json_config() -> StreamableHttpServerConfig { + stateless_sse_config().with_json_response(true) +} + +async fn spawn_server( + config: StreamableHttpServerConfig, +) -> (reqwest::Client, String, CancellationToken) { + spawn_server_of::(config).await +} + +async fn spawn_server_of( + config: StreamableHttpServerConfig, +) -> (reqwest::Client, String, CancellationToken) { + let ct = config.cancellation_token.clone(); + let service: StreamableHttpService = + StreamableHttpService::new(|| Ok(H::default()), Default::default(), config); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = tcp_listener.local_addr().unwrap(); + + tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + (reqwest::Client::new(), format!("http://{addr}/mcp"), ct) +} + +async fn post_init(client: &reqwest::Client, url: &str, body_version: &str) -> serde_json::Value { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": body_version, + "capabilities": {}, + "clientInfo": {"name": "test", "version": "0.0.1"} + } + }); + let resp = client + .post(url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(body.to_string()) + .send() + .await + .expect("send request"); + assert!(resp.status().is_success(), "HTTP {}", resp.status()); + let is_json = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("application/json")); + if is_json { + resp.json().await.expect("parse JSON") + } else { + let body = resp.text().await.expect("read SSE body"); + let data = body + .lines() + .find_map(|line| line.strip_prefix("data:")) + .map(str::trim) + .filter(|data| !data.is_empty()) + .expect("SSE response contains data"); + serde_json::from_str(data).expect("parse SSE data") + } +} + +#[tokio::test] +async fn stateless_json_init_echoes_known_versions_when_handler_overrides_initialize() { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + for version in ProtocolVersion::KNOWN_VERSIONS { + let resp = post_init(&client, &url, version.as_str()).await; + assert_eq!( + resp["result"]["protocolVersion"], + version.as_str(), + "known version {version} should be echoed back" + ); + } + + ct.cancel(); +} + +#[tokio::test] +async fn stateless_sse_init_echoes_known_versions_when_handler_overrides_initialize() { + let (client, url, ct) = spawn_server(stateless_sse_config()).await; + + for version in ProtocolVersion::KNOWN_VERSIONS { + let resp = post_init(&client, &url, version.as_str()).await; + assert_eq!( + resp["result"]["protocolVersion"], + version.as_str(), + "known version {version} should be echoed back" + ); + } + + ct.cancel(); +} + +#[tokio::test] +async fn stateless_json_init_preserves_handler_fallback_for_unknown_version() { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + let resp = post_init(&client, &url, "1999-01-01").await; + assert_eq!( + resp["result"]["protocolVersion"], + ProtocolVersion::LATEST.as_str(), + "unknown version should preserve the handler's fallback" + ); + + ct.cancel(); +} + +#[tokio::test] +async fn stateless_json_init_echoes_versions_the_server_narrowed_to() { + let (client, url, ct) = + spawn_server_of::(stateless_json_config()).await; + + for version in NARROWED_VERSIONS { + let resp = post_init(&client, &url, version.as_str()).await; + assert_eq!( + resp["result"]["protocolVersion"], + version.as_str(), + "supported version {version} should be echoed back" + ); + } + + ct.cancel(); +} + +#[tokio::test] +async fn stateless_json_init_does_not_agree_to_version_outside_supported_list() { + let (client, url, ct) = + spawn_server_of::(stateless_json_config()).await; + + let resp = post_init(&client, &url, ProtocolVersion::V_2026_07_28.as_str()).await; + assert_eq!( + resp["result"]["protocolVersion"], + ProtocolVersion::V_2025_11_25.as_str(), + "a version outside supported_protocol_versions should not be echoed back" + ); + + ct.cancel(); +} diff --git a/vendor/rmcp/tests/test_stateless_server_requests.rs b/vendor/rmcp/tests/test_stateless_server_requests.rs new file mode 100644 index 000000000..4404e08cf --- /dev/null +++ b/vendor/rmcp/tests/test_stateless_server_requests.rs @@ -0,0 +1,196 @@ +#![cfg(all(feature = "server", not(feature = "local")))] + +use std::sync::{Arc, Mutex}; + +use rmcp::{ + ServerHandler, ServiceExt, + model::{ + ClientCapabilities, ClientJsonRpcMessage, ClientRequest, DiscoverRequest, + DiscoverRequestParams, ErrorCode, ErrorData, Implementation, ListToolsRequest, + ListToolsResult, PaginatedRequestParams, ProtocolVersion, RequestId, RequestMetaObject, + ServerJsonRpcMessage, + }, + service::{MaybeSendFuture, RequestContext, RoleServer, ServerInitializeError}, + transport::{IntoTransport, Transport}, +}; + +#[derive(Clone, Default)] +struct StatelessServer; + +impl ServerHandler for StatelessServer {} + +fn complete_meta() -> RequestMetaObject { + complete_meta_for("stateless-client") +} + +fn complete_meta_for(client_name: &str) -> RequestMetaObject { + let mut meta = RequestMetaObject::new(); + meta.set_protocol_version(ProtocolVersion::V_2026_07_28); + meta.set_client_info(Implementation::new(client_name, "1.0.0")); + meta.set_client_capabilities(ClientCapabilities::default()); + meta +} + +fn list_tools_request(meta: RequestMetaObject) -> ClientJsonRpcMessage { + let mut request = ListToolsRequest { + method: Default::default(), + params: None, + extensions: Default::default(), + }; + request.extensions.insert(meta); + ClientJsonRpcMessage::request( + ClientRequest::ListToolsRequest(request), + RequestId::Number(1), + ) +} + +#[tokio::test] +async fn stateless_server_rejects_missing_metadata_on_every_request() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server_task = tokio::spawn(async move { + StatelessServer + .serve(server_transport) + .await + .expect("server should start") + }); + let mut client = IntoTransport::::into_transport(client_transport); + + let mut discover = DiscoverRequest::new(DiscoverRequestParams {}); + discover.extensions.insert(complete_meta()); + client + .send(ClientJsonRpcMessage::request( + ClientRequest::DiscoverRequest(discover), + RequestId::Number(1), + )) + .await + .expect("send discover"); + assert!(matches!( + client.receive().await, + Some(ServerJsonRpcMessage::Response(_)) + )); + + client + .send(ClientJsonRpcMessage::request( + ClientRequest::ListToolsRequest(ListToolsRequest { + method: Default::default(), + params: None, + extensions: Default::default(), + }), + RequestId::Number(2), + )) + .await + .expect("send list tools"); + let Some(ServerJsonRpcMessage::Error(error)) = client.receive().await else { + panic!("expected invalid params"); + }; + assert_eq!(error.error.code, ErrorCode::INVALID_PARAMS); + + server_task + .await + .expect("server task") + .cancel() + .await + .expect("cancel server"); +} + +#[derive(Clone)] +struct ContextServer { + seen_clients: Arc>>, +} + +impl ServerHandler for ContextServer { + fn list_tools( + &self, + _request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let seen_clients = self.seen_clients.clone(); + async move { + seen_clients + .lock() + .expect("seen clients lock") + .push(context.client_info().expect("current client info").name); + Ok(ListToolsResult::default()) + } + } +} + +#[tokio::test] +async fn stateless_server_uses_each_requests_client_context() { + let seen_clients = Arc::new(Mutex::new(Vec::new())); + let handler = ContextServer { + seen_clients: seen_clients.clone(), + }; + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server_task = tokio::spawn(async move { + handler + .serve(server_transport) + .await + .expect("server should start") + }); + let mut client = IntoTransport::::into_transport(client_transport); + + client + .send(list_tools_request(complete_meta_for("first-client"))) + .await + .expect("send first request"); + assert!(matches!( + client.receive().await, + Some(ServerJsonRpcMessage::Response(_)) + )); + + let mut second = list_tools_request(complete_meta_for("second-client")); + if let ClientJsonRpcMessage::Request(request) = &mut second { + request.id = RequestId::Number(2); + } + client.send(second).await.expect("send second request"); + assert!(matches!( + client.receive().await, + Some(ServerJsonRpcMessage::Response(_)) + )); + + assert_eq!( + *seen_clients.lock().expect("seen clients lock"), + ["first-client", "second-client"] + ); + server_task + .await + .expect("server task") + .cancel() + .await + .expect("cancel server"); +} + +#[tokio::test] +async fn stateless_server_rejects_malformed_metadata_opener() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server_task = tokio::spawn(async move { StatelessServer.serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + let mut request = ListToolsRequest { + method: Default::default(), + params: None, + extensions: Default::default(), + }; + let malformed: RequestMetaObject = serde_json::from_value(serde_json::json!({ + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": "wrong", + "io.modelcontextprotocol/clientCapabilities": null + })) + .unwrap(); + request.extensions.insert(malformed); + client + .send(ClientJsonRpcMessage::request( + ClientRequest::ListToolsRequest(request), + RequestId::Number(1), + )) + .await + .expect("send list tools"); + let Err(error) = server_task.await.expect("server task") else { + panic!("malformed opener should not start a session"); + }; + assert!(matches!( + error, + ServerInitializeError::ExpectedInitializeRequest(Some(_)) + )); +} diff --git a/vendor/rmcp/tests/test_stdio_response_concurrency.rs b/vendor/rmcp/tests/test_stdio_response_concurrency.rs new file mode 100644 index 000000000..2e1e40a22 --- /dev/null +++ b/vendor/rmcp/tests/test_stdio_response_concurrency.rs @@ -0,0 +1,199 @@ +#![cfg(not(feature = "local"))] + +use std::{collections::BTreeSet, process::Stdio, time::Duration}; + +use rmcp::{ + ErrorData as McpError, ServerHandler, ServiceExt, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities, + ServerInfo, + }, +}; +use serde_json::{Value, json}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}, + process::{Child, Command}, +}; + +const HELPER_ENV: &str = "RMCP_STDIO_RESPONSE_CONCURRENCY_HELPER"; +const REQUESTS: usize = 200; +const RESPONSE_BYTES: usize = 64 * 1024; +const READ_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test(flavor = "multi_thread", worker_threads = 8)] +async fn raw_client_concurrent_large_stdio_tool_responses_are_not_lost() -> anyhow::Result<()> { + // Spawn the same test binary as a child process so the server uses real + // stdio pipes, not an in-process transport. + let mut child = spawn_helper(); + let mut writer = child.stdin.take().expect("helper stdin"); + let stdout = child.stdout.take().expect("helper stdout"); + let mut reader = BufReader::new(stdout); + + // Complete the normal MCP initialization flow before stressing tools/call. + send_json( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "raw-test-client", "version": "0.0.0" } + } + }), + ) + .await?; + read_response_for_id(&mut reader, 1).await?; + + send_json( + &mut writer, + &json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), + ) + .await?; + + // Send the whole batch before reading responses. This creates concurrent + // request handling and concurrent response production inside rmcp. + for id in request_ids() { + send_json( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { "name": "large-response", "arguments": {} } + }), + ) + .await?; + } + + let missing_ids = read_responses_for_ids(&mut reader, request_ids(), READ_TIMEOUT).await?; + assert!( + missing_ids.is_empty(), + "missing response ids: {missing_ids:?}" + ); + + drop(writer); + wait_for_child(&mut child).await; + Ok(()) +} + +struct LargeResponseServer; + +impl ServerHandler for LargeResponseServer { + #[allow(deprecated)] + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: rmcp::service::RequestContext, + ) -> Result { + assert_eq!("large-response", request.name.as_ref()); + Ok(CallToolResult::success(vec![ContentBlock::text("x".repeat(RESPONSE_BYTES))]).into()) + } +} + +#[tokio::test] +async fn stdio_response_concurrency_helper() -> anyhow::Result<()> { + // The parent test starts this same binary with HELPER_ENV=1 so it can act + // as a small MCP server connected over real stdin/stdout pipes. + if std::env::var(HELPER_ENV).as_deref() != Ok("1") { + return Ok(()); + } + let server = LargeResponseServer.serve(rmcp::transport::stdio()).await?; + server.waiting().await?; + Ok(()) +} + +fn spawn_helper() -> Child { + let exe = std::env::current_exe().expect("current test exe"); + Command::new(exe) + .arg("--exact") + .arg("stdio_response_concurrency_helper") + .arg("--quiet") + .arg("--nocapture") + .arg("--test-threads") + .arg("1") + .env(HELPER_ENV, "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .expect("spawn helper") +} + +async fn wait_for_child(child: &mut Child) { + let _ = tokio::time::timeout(Duration::from_secs(2), child.wait()).await; + if child.id().is_some() { + let _ = child.kill().await; + } +} + +fn request_ids() -> BTreeSet { + (1000..1000 + REQUESTS as u64).collect() +} + +async fn send_json(writer: &mut W, message: &Value) -> anyhow::Result<()> +where + W: AsyncWrite + Unpin, +{ + let serialized = serde_json::to_string(message)?; + writer.write_all(serialized.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.flush().await?; + Ok(()) +} + +async fn read_response_for_id(reader: &mut BufReader, expected_id: u64) -> anyhow::Result<()> +where + R: tokio::io::AsyncRead + Unpin, +{ + let missing = + read_responses_for_ids(reader, BTreeSet::from([expected_id]), READ_TIMEOUT).await?; + if missing.is_empty() { + Ok(()) + } else { + anyhow::bail!("missing response id {expected_id}") + } +} + +async fn read_responses_for_ids( + reader: &mut BufReader, + mut pending_ids: BTreeSet, + timeout: Duration, +) -> anyhow::Result> +where + R: tokio::io::AsyncRead + Unpin, +{ + let deadline = tokio::time::Instant::now() + timeout; + while !pending_ids.is_empty() { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + let mut line = String::new(); + let Ok(read_result) = tokio::time::timeout(remaining, reader.read_line(&mut line)).await + else { + break; + }; + let read = read_result?; + if read == 0 { + break; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Ok(value) = serde_json::from_str::(trimmed) else { + continue; + }; + if let Some(id) = value.get("id").and_then(Value::as_u64) { + pending_ids.remove(&id); + } + } + Ok(pending_ids) +} diff --git a/vendor/rmcp/tests/test_streamable_http_4xx_error_body.rs b/vendor/rmcp/tests/test_streamable_http_4xx_error_body.rs new file mode 100644 index 000000000..ea49a4172 --- /dev/null +++ b/vendor/rmcp/tests/test_streamable_http_4xx_error_body.rs @@ -0,0 +1,121 @@ +#![cfg(all( + feature = "transport-streamable-http-client", + feature = "transport-streamable-http-client-reqwest", + not(feature = "local") +))] + +use std::{collections::HashMap, sync::Arc}; + +use rmcp::{ + model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, + transport::streamable_http_client::{ + StreamableHttpClient, StreamableHttpError, StreamableHttpPostResponse, + }, +}; + +/// Spin up a minimal axum server that always responds with the given status, +/// content-type, and body — no MCP logic involved. +async fn spawn_mock_server(status: u16, content_type: &'static str, body: &'static str) -> String { + use axum::{Router, body::Body, http::Response, routing::post}; + + let router = Router::new().route( + "/mcp", + post(move || async move { + Response::builder() + .status(status) + .header("content-type", content_type) + .body(Body::from(body)) + .unwrap() + }), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + + format!("http://{addr}/mcp") +} + +fn ping_message() -> ClientJsonRpcMessage { + ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ) +} + +/// HTTP 4xx with Content-Type: application/json and a valid JSON-RPC error body +/// must be surfaced as `StreamableHttpPostResponse::Json`, not swallowed as a +/// transport error. +#[tokio::test] +async fn http_4xx_json_rpc_error_body_is_surfaced_as_json_response() { + let body = r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Invalid Request"}}"#; + let url = spawn_mock_server(400, "application/json", body).await; + + let client = reqwest::Client::new(); + let result = client + .post_message( + Arc::from(url.as_str()), + ping_message(), + None, + None, + HashMap::new(), + ) + .await; + + match result { + Ok(StreamableHttpPostResponse::Json(msg, _)) => { + let json = serde_json::to_value(&msg).unwrap(); + assert_eq!(json["error"]["code"], -32600); + assert_eq!(json["error"]["message"], "Invalid Request"); + } + other => panic!("expected Json response, got: {other:?}"), + } +} + +/// HTTP 4xx with non-JSON content-type must still return `UnexpectedServerResponse` +/// (no regression on the original error path). +#[tokio::test] +async fn http_4xx_non_json_body_returns_unexpected_server_response() { + let url = spawn_mock_server(400, "text/plain", "Bad Request").await; + + let client = reqwest::Client::new(); + let result = client + .post_message( + Arc::from(url.as_str()), + ping_message(), + None, + None, + HashMap::new(), + ) + .await; + + match result { + Err(StreamableHttpError::UnexpectedServerResponse(_)) => {} + other => panic!("expected UnexpectedServerResponse, got: {other:?}"), + } +} + +/// HTTP 4xx with Content-Type: application/json but a body that is NOT a valid +/// JSON-RPC message must fall back to `UnexpectedServerResponse`. +#[tokio::test] +async fn http_4xx_malformed_json_body_falls_back_to_unexpected_server_response() { + let url = spawn_mock_server(400, "application/json", r#"{"error":"not jsonrpc"}"#).await; + + let client = reqwest::Client::new(); + let result = client + .post_message( + Arc::from(url.as_str()), + ping_message(), + None, + None, + HashMap::new(), + ) + .await; + + match result { + Err(StreamableHttpError::UnexpectedServerResponse(_)) => {} + other => panic!("expected UnexpectedServerResponse, got: {other:?}"), + } +} diff --git a/vendor/rmcp/tests/test_streamable_http_connection_reuse.rs b/vendor/rmcp/tests/test_streamable_http_connection_reuse.rs new file mode 100644 index 000000000..553448eae --- /dev/null +++ b/vendor/rmcp/tests/test_streamable_http_connection_reuse.rs @@ -0,0 +1,122 @@ +#![cfg(not(feature = "local"))] + +use std::time::Instant; + +use rmcp::{ + ServerHandler, ServiceExt, + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{CallToolRequestParams, ClientInfo, ServerCapabilities, ServerInfo}, + schemars, tool, tool_handler, tool_router, + transport::{ + StreamableHttpClientTransport, + streamable_http_client::StreamableHttpClientTransportConfig, + streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, + }, +}; +use tokio_util::sync::CancellationToken; + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct SumRequest { + a: i32, + b: i32, +} + +#[derive(Debug, Clone)] +struct SumServer { + tool_router: ToolRouter, +} + +impl SumServer { + fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } +} + +#[tool_router] +impl SumServer { + #[tool(description = "Sum two numbers")] + fn sum(&self, Parameters(SumRequest { a, b }): Parameters) -> String { + (a + b).to_string() + } +} + +#[tool_handler(router = self.tool_router)] +impl ServerHandler for SumServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } +} + +/// Verify that subsequent tool calls do not regress in latency due to +/// HTTP/1.1 connection pool exhaustion. Before the fix, each POST SSE +/// response was dropped without fully consuming the body, preventing +/// connection reuse and forcing a new TCP connection (~40 ms) per call. +#[tokio::test] +async fn test_subsequent_tool_calls_reuse_connections() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + + let service: StreamableHttpService = StreamableHttpService::new( + || Ok(SumServer::new()), + Default::default(), + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + + let server_handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")), + ); + let client = ClientInfo::default().serve(transport).await?; + + // Warm up: first call may include one-time setup costs. + let args: serde_json::Map = + serde_json::from_value(serde_json::json!({"a": 1, "b": 2}))?; + let _ = client + .call_tool(CallToolRequestParams::new("sum").with_arguments(args)) + .await?; + + // Measure subsequent calls. + let mut durations = Vec::new(); + for i in 0..5i32 { + let args: serde_json::Map = + serde_json::from_value(serde_json::json!({"a": i, "b": i + 1}))?; + let start = Instant::now(); + let result = client + .call_tool(CallToolRequestParams::new("sum").with_arguments(args)) + .await?; + let elapsed = start.elapsed(); + durations.push(elapsed); + + assert!(result.is_error != Some(true)); + } + + let _ = client.cancel().await; + ct.cancel(); + server_handle.await?; + + // With connection reuse, localhost calls should complete well under 20 ms. + // Before the fix, they consistently took ~42 ms due to new TCP connections. + let max_allowed = std::time::Duration::from_millis(20); + for d in &durations { + assert!(*d < max_allowed); + } + + Ok(()) +} diff --git a/vendor/rmcp/tests/test_streamable_http_disconnect_cancel.rs b/vendor/rmcp/tests/test_streamable_http_disconnect_cancel.rs new file mode 100644 index 000000000..31e74137d --- /dev/null +++ b/vendor/rmcp/tests/test_streamable_http_disconnect_cancel.rs @@ -0,0 +1,196 @@ +#![cfg(all( + feature = "server", + feature = "transport-streamable-http-server", + feature = "reqwest", + not(feature = "local") +))] + +//! Regression test for #857: when a stateless streamable-HTTP client disconnects +//! (drops the response) while a tool handler is still awaiting, the per-request +//! `RequestContext::ct` should fire so the handler can cancel cooperatively. +//! +//! Stateless requests are one-shot (no session, no resumption), so a dropped +//! response is terminal and safe to cancel — unlike the stateful/resumable path, +//! where a disconnect may be recovered via `Last-Event-ID`. + +use std::{sync::Arc, time::Duration}; + +use rmcp::{ + ErrorData as McpError, RoleServer, ServerHandler, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities, + ServerInfo, + }, + service::RequestContext, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, +}; +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; + +#[derive(Clone)] +struct CancelProbe { + started: Arc, + cancelled: Arc, +} + +impl ServerHandler for CancelProbe { + #[allow(deprecated)] + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } + + async fn call_tool( + &self, + _request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + self.started.notify_one(); + // Wait until the per-request cancellation token fires, or give up after a + // generous timeout so a buggy build fails via the outer assertion rather + // than hanging the test. + tokio::select! { + _ = context.ct.cancelled() => { + self.cancelled.notify_one(); + Ok(CallToolResult::success(vec![ContentBlock::text("cancelled")]).into()) + } + _ = tokio::time::sleep(Duration::from_secs(30)) => { + Ok(CallToolResult::success(vec![ContentBlock::text("ran_to_completion")]).into()) + } + } + } +} + +const CALL_BODY: &str = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"wait_for_cancel","arguments":{}}}"#; + +struct TestServer { + url: String, + server_ct: CancellationToken, + started: Arc, + cancelled: Arc, +} + +async fn spawn_stateless_server(json_response: bool) -> anyhow::Result { + let started = Arc::new(Notify::new()); + let cancelled = Arc::new(Notify::new()); + let probe = CancelProbe { + started: started.clone(), + cancelled: cancelled.clone(), + }; + + let server_ct = CancellationToken::new(); + let config = StreamableHttpServerConfig::default() + .with_legacy_session_mode(false) + .with_json_response(json_response) + // A short keep-alive lets the SSE server notice a dropped connection + // quickly (hyper only observes the disconnect on its next write). + .with_sse_keep_alive(Some(Duration::from_millis(100))) + .with_cancellation_token(server_ct.child_token()); + + let service: StreamableHttpService = + StreamableHttpService::new( + move || Ok(probe.clone()), + Arc::new(LocalSessionManager::default()), + config, + ); + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + tokio::spawn({ + let ct = server_ct.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + Ok(TestServer { + url: format!("http://{addr}/mcp"), + server_ct, + started, + cancelled, + }) +} + +/// SSE mode: the response is a stream; dropping it (client disconnect) must fire +/// the handler's cancellation token. +#[tokio::test] +async fn stateless_sse_client_disconnect_cancels_request() -> anyhow::Result<()> { + let server = spawn_stateless_server(false).await?; + let client = reqwest::Client::builder() + .pool_max_idle_per_host(0) + .build()?; + + // A single self-contained tools/call (no session, no initialize handshake). + let call = client + .post(&server.url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2025-03-26") + .body(CALL_BODY) + .send() + .await?; + assert!( + call.status().is_success(), + "tools/call failed: {:?}", + call.status() + ); + + tokio::time::timeout(Duration::from_secs(5), server.started.notified()) + .await + .expect("tool handler should start"); + + // Client disconnects mid-call: drop the streaming response (and the client). + drop(call); + drop(client); + + tokio::time::timeout(Duration::from_secs(10), server.cancelled.notified()) + .await + .expect("RequestContext::ct should fire after client disconnect (SSE)"); + + server.server_ct.cancel(); + Ok(()) +} + +/// JSON-direct mode: the server holds the connection open awaiting the single +/// response. A client that disconnects while the handler is running must still +/// fire the handler's cancellation token. +#[tokio::test] +async fn stateless_json_client_disconnect_cancels_request() -> anyhow::Result<()> { + let server = spawn_stateless_server(true).await?; + let client = reqwest::Client::builder() + .pool_max_idle_per_host(0) + .build()?; + + // In JSON mode the server does not respond until the handler completes, so + // the request stays pending; drive it from a task we can abort to disconnect. + let url = server.url.clone(); + let req_task = tokio::spawn(async move { + let _ = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2025-03-26") + .body(CALL_BODY) + .send() + .await; + // Keep the client alive until the request future is dropped by abort(). + drop(client); + }); + + tokio::time::timeout(Duration::from_secs(5), server.started.notified()) + .await + .expect("tool handler should start"); + + // Client disconnects: abort the in-flight request, closing the connection. + req_task.abort(); + + tokio::time::timeout(Duration::from_secs(10), server.cancelled.notified()) + .await + .expect("RequestContext::ct should fire after client disconnect (JSON)"); + + server.server_ct.cancel(); + Ok(()) +} diff --git a/vendor/rmcp/tests/test_streamable_http_empty_2xx_notification.rs b/vendor/rmcp/tests/test_streamable_http_empty_2xx_notification.rs new file mode 100644 index 000000000..c8a153345 --- /dev/null +++ b/vendor/rmcp/tests/test_streamable_http_empty_2xx_notification.rs @@ -0,0 +1,48 @@ +#![cfg(all( + feature = "transport-streamable-http-client", + feature = "transport-streamable-http-client-reqwest", + not(feature = "local") +))] + +use std::{collections::HashMap, sync::Arc}; + +use rmcp::{ + model::{ClientJsonRpcMessage, ClientNotification, InitializedNotification}, + transport::streamable_http_client::{StreamableHttpClient, StreamableHttpPostResponse}, +}; + +async fn spawn_empty_ok_server() -> String { + use axum::{Router, http::StatusCode, routing::post}; + + let router = Router::new().route("/mcp", post(|| async { StatusCode::OK })); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + + format!("http://{addr}/mcp") +} + +#[tokio::test] +async fn empty_success_response_to_notification_is_accepted() { + let url = spawn_empty_ok_server().await; + let client = reqwest::Client::new(); + let result = client + .post_message( + Arc::from(url.as_str()), + ClientJsonRpcMessage::notification(ClientNotification::InitializedNotification( + InitializedNotification::default(), + )), + None, + None, + HashMap::new(), + ) + .await; + + match result { + Ok(StreamableHttpPostResponse::Accepted) => {} + other => panic!("expected Accepted, got: {other:?}"), + } +} diff --git a/vendor/rmcp/tests/test_streamable_http_event_store.rs b/vendor/rmcp/tests/test_streamable_http_event_store.rs new file mode 100644 index 000000000..cc88f1077 --- /dev/null +++ b/vendor/rmcp/tests/test_streamable_http_event_store.rs @@ -0,0 +1,372 @@ +#![cfg(all( + feature = "client", + feature = "server", + feature = "transport-streamable-http-client-reqwest", + feature = "transport-streamable-http-server", + not(feature = "local") +))] + +use std::{ + collections::HashMap, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; + +use futures::StreamExt; +use rmcp::{ + ErrorData, ServerHandler, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, + ProgressNotificationParam, ServerCapabilities, ServerInfo, + }, + service::RequestContext, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, + session::{ + EventStore, EventStoreError, EventStream, ServerSseMessage, SessionState, SessionStore, + SessionStoreError, local::LocalSessionManager, never::NeverSessionManager, + }, + }, +}; +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; + +#[derive(Clone, Default)] +struct InMemorySessionStore(Arc>>); + +#[async_trait::async_trait] +impl SessionStore for InMemorySessionStore { + async fn load(&self, session_id: &str) -> Result, SessionStoreError> { + Ok(self.0.read().await.get(session_id).cloned()) + } + + async fn store(&self, session_id: &str, state: &SessionState) -> Result<(), SessionStoreError> { + self.0 + .write() + .await + .insert(session_id.to_owned(), state.clone()); + Ok(()) + } + + async fn delete(&self, session_id: &str) -> Result<(), SessionStoreError> { + self.0.write().await.remove(session_id); + Ok(()) + } +} + +#[derive(Clone)] +struct StoredEvent { + stream_id: String, + event: ServerSseMessage, +} + +#[derive(Clone, Default)] +struct InMemoryEventStore { + events: Arc>>, + next_id: Arc, +} + +#[async_trait::async_trait] +impl EventStore for InMemoryEventStore { + async fn store_event( + &self, + stream_id: &str, + event: &ServerSseMessage, + ) -> Result { + let event_id = format!("event-{}", self.next_id.fetch_add(1, Ordering::Relaxed)); + let mut event = event.clone(); + event.event_id = Some(event_id.clone()); + self.events.write().await.push(StoredEvent { + stream_id: stream_id.to_owned(), + event, + }); + Ok(event_id) + } + + async fn replay_events_after( + &self, + last_event_id: &str, + ) -> Result { + let events = self.events.read().await; + let last_index = events + .iter() + .position(|stored| stored.event.event_id.as_deref() == Some(last_event_id)) + .ok_or_else(|| std::io::Error::other("event not found"))?; + let stream_id = events[last_index].stream_id.clone(); + let replay = events + .iter() + .skip(last_index + 1) + .filter(|stored| stored.stream_id == stream_id) + .map(|stored| stored.event.clone()) + .collect::>(); + Ok(Box::pin(futures::stream::iter(replay))) + } +} + +#[derive(Clone)] +struct ProgressServer; + +impl ServerHandler for ProgressServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + if request.name == "progress" || request.name == "slow-progress" { + let progress_token = context + .meta + .get_progress_token() + .expect("request includes progressToken"); + context + .peer + .notify_progress( + ProgressNotificationParam::new(progress_token, 50.0) + .with_total(100.0) + .with_message("working"), + ) + .await + .expect("progress notification is delivered"); + } + if request.name == "slow-progress" { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + Ok(CallToolResult::success(vec![ContentBlock::text("done")]).into()) + } +} + +async fn spawn_server( + session_store: Arc, + event_store: Arc, + cancellation_token: &CancellationToken, + legacy_session_mode: bool, +) -> anyhow::Result<(String, tokio::task::JoinHandle<()>)> { + let config = { + let mut config = StreamableHttpServerConfig::default(); + config.sse_keep_alive = None; + config.legacy_session_mode = legacy_session_mode; + config.cancellation_token = cancellation_token.child_token(); + config.session_store = Some(session_store); + config + }; + let router = if legacy_session_mode { + let session_manager = + Arc::new(LocalSessionManager::default().with_event_store(event_store)); + let service = StreamableHttpService::new(|| Ok(ProgressServer), session_manager, config); + axum::Router::new().nest_service("/mcp", service) + } else { + let session_manager = + Arc::new(NeverSessionManager::default().with_event_store(event_store)); + let service = StreamableHttpService::new(|| Ok(ProgressServer), session_manager, config); + axum::Router::new().nest_service("/mcp", service) + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let address = listener.local_addr()?; + let handle = tokio::spawn({ + let cancellation_token = cancellation_token.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { cancellation_token.cancelled_owned().await }) + .await; + } + }); + Ok((format!("http://{address}/mcp"), handle)) +} + +fn event_id_containing<'a>(body: &'a str, needle: &str) -> Option<&'a str> { + body.split("\n\n") + .find(|event| event.contains(needle))? + .lines() + .find_map(|line| line.strip_prefix("id: ")) +} + +#[tokio::test] +async fn restored_instance_replays_events_from_shared_store() -> anyhow::Result<()> { + let session_store: Arc = Arc::new(InMemorySessionStore::default()); + let event_store: Arc = Arc::new(InMemoryEventStore::default()); + let http = reqwest::Client::new(); + + let cancellation_a = CancellationToken::new(); + let (url_a, server_a) = spawn_server( + session_store.clone(), + event_store.clone(), + &cancellation_a, + true, + ) + .await?; + let initialize = http + .post(&url_a) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#) + .send() + .await?; + let session_id = initialize + .headers() + .get("mcp-session-id") + .expect("initialize returns a session ID") + .to_str()? + .to_owned(); + let _ = initialize.text().await?; + + let initialized_status = http + .post(&url_a) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("Mcp-Session-Id", &session_id) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) + .send() + .await? + .status(); + assert_eq!(initialized_status, reqwest::StatusCode::ACCEPTED); + + let original_body = http + .post(&url_a) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("Mcp-Session-Id", &session_id) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"progress","arguments":{},"_meta":{"progressToken":"progress-1"}}}"#) + .send() + .await? + .text() + .await?; + let progress_event_id = event_id_containing(&original_body, "notifications/progress") + .expect("progress event has a persisted event ID") + .to_owned(); + + cancellation_a.cancel(); + server_a.await?; + + let cancellation_b = CancellationToken::new(); + let (url_b, server_b) = spawn_server(session_store, event_store, &cancellation_b, true).await?; + let replay = http + .get(&url_b) + .header("Accept", "text/event-stream") + .header("Mcp-Session-Id", &session_id) + .header("Mcp-Protocol-Version", "2025-06-18") + .header("Last-Event-ID", progress_event_id) + .send() + .await?; + assert_eq!(replay.status(), reqwest::StatusCode::OK); + let replay_body = replay.text().await?; + assert!( + replay_body.contains(r#""id":2"#), + "instance B should replay the final response stored by instance A: {replay_body}" + ); + + cancellation_b.cancel(); + server_b.await?; + Ok(()) +} + +#[tokio::test] +async fn stateless_instance_replays_events_from_shared_store() -> anyhow::Result<()> { + let session_store: Arc = Arc::new(InMemorySessionStore::default()); + let event_store = Arc::new(InMemoryEventStore::default()); + let http = reqwest::Client::new(); + + let cancellation_a = CancellationToken::new(); + let (url_a, server_a) = spawn_server( + session_store.clone(), + event_store.clone(), + &cancellation_a, + false, + ) + .await?; + let original = http + .post(&url_a) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2026-07-28") + .header("Mcp-Method", "tools/call") + .header("Mcp-Name", "slow-progress") + .body( + r#"{ + "jsonrpc":"2.0", + "id":2, + "method":"tools/call", + "params":{ + "name":"slow-progress", + "arguments":{}, + "_meta":{ + "progressToken":"progress-1", + "io.modelcontextprotocol/protocolVersion":"2026-07-28", + "io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"}, + "io.modelcontextprotocol/clientCapabilities":{} + } + } + }"#, + ) + .send() + .await?; + assert_eq!(original.status(), reqwest::StatusCode::OK); + assert!( + original.headers().get("mcp-session-id").is_none(), + "stateless response must not create a session" + ); + let mut body = original.bytes_stream(); + let mut received = String::new(); + let progress_event_id = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let chunk = body + .next() + .await + .expect("response remains open until progress arrives")?; + received.push_str(&String::from_utf8_lossy(&chunk)); + if let Some(event_id) = event_id_containing(&received, "notifications/progress") { + return Ok::<_, reqwest::Error>(event_id.to_owned()); + } + } + }) + .await??; + drop(body); + + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let stored_response = event_store.events.read().await.iter().any(|stored| { + stored.event.message.as_ref().is_some_and(|message| { + matches!( + message.as_ref(), + rmcp::model::ServerJsonRpcMessage::Response(_) + ) + }) + }); + if stored_response { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }) + .await?; + + cancellation_a.cancel(); + server_a.await?; + + let cancellation_b = CancellationToken::new(); + let (url_b, server_b) = + spawn_server(session_store, event_store, &cancellation_b, false).await?; + let replay = http + .get(&url_b) + .header("Accept", "text/event-stream") + .header("MCP-Protocol-Version", "2026-07-28") + .header("Last-Event-ID", progress_event_id) + .send() + .await?; + assert_eq!(replay.status(), reqwest::StatusCode::OK); + let replay_body = replay.text().await?; + assert!( + replay_body.contains(r#""id":2"#), + "instance B should replay the stateless response stored by instance A: {replay_body}" + ); + + cancellation_b.cancel(); + server_b.await?; + Ok(()) +} diff --git a/vendor/rmcp/tests/test_streamable_http_idle_timeout_log.rs b/vendor/rmcp/tests/test_streamable_http_idle_timeout_log.rs new file mode 100644 index 000000000..e0559b5b8 --- /dev/null +++ b/vendor/rmcp/tests/test_streamable_http_idle_timeout_log.rs @@ -0,0 +1,252 @@ +#![cfg(all( + feature = "transport-streamable-http-server", + feature = "transport-streamable-http-client-reqwest", + not(feature = "local") +))] + +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; + +use rmcp::transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, + session::{SessionManager, local::LocalSessionManager}, +}; +use tokio_util::sync::CancellationToken; +use tracing_subscriber::layer::SubscriberExt; + +mod common; +use common::calculator::Calculator; + +struct CapturedEvent { + level: tracing::Level, + target: String, + message: String, +} + +struct CapturingLayer { + events: Arc>>, +} + +impl tracing_subscriber::Layer for CapturingLayer { + fn on_event( + &self, + event: &tracing::Event<'_>, + _ctx: tracing_subscriber::layer::Context<'_, S>, + ) { + let mut visitor = MessageVisitor(String::new()); + event.record(&mut visitor); + self.events.lock().unwrap().push(CapturedEvent { + level: *event.metadata().level(), + target: event.metadata().target().to_string(), + message: visitor.0, + }); + } +} + +struct MessageVisitor(String); + +impl tracing::field::Visit for MessageVisitor { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.0 = format!("{:?}", value); + } + } +} + +#[tokio::test(flavor = "current_thread")] +async fn test_keep_alive_timeout_does_not_emit_error_log() { + let events = Arc::new(Mutex::new(Vec::::new())); + + let subscriber = tracing_subscriber::registry().with(CapturingLayer { + events: events.clone(), + }); + + let _guard = tracing::subscriber::set_default(subscriber); + + let ct = CancellationToken::new(); + let mut session_manager = LocalSessionManager::default(); + session_manager.session_config.keep_alive = Some(Duration::from_millis(200)); + let session_manager = Arc::new(session_manager); + + let service = StreamableHttpService::new( + || Ok(Calculator::new()), + session_manager.clone(), + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = tcp_listener.local_addr().unwrap(); + + tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let client = reqwest::Client::new(); + + let response = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let session_id = response.headers()["mcp-session-id"] + .to_str() + .unwrap() + .to_string(); + + client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("mcp-session-id", &session_id) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) + .send() + .await + .unwrap(); + + tokio::time::sleep(Duration::from_millis(400)).await; + + // Wait until close_session() has completed so all logs are captured. + let session_id_parsed: Arc = Arc::from(session_id.as_str()); + for _ in 0..20 { + if !session_manager + .has_session(&session_id_parsed) + .await + .unwrap() + { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + assert!( + !session_manager + .has_session(&session_id_parsed) + .await + .unwrap(), + "session should have been removed after idle reap" + ); + + let captured = events.lock().unwrap(); + + let error_events: Vec<_> = captured + .iter() + .filter(|e| e.level == tracing::Level::ERROR && e.target.starts_with("rmcp")) + .collect(); + assert!( + error_events.is_empty(), + "idle reap should not produce any ERROR logs, found {}: {:?}", + error_events.len(), + error_events.iter().map(|e| &e.message).collect::>() + ); + + let debug_events: Vec<_> = captured + .iter() + .filter(|e| { + e.level == tracing::Level::DEBUG + && e.target.starts_with("rmcp") + && e.message.contains("IdleTimeout") + }) + .collect(); + assert!( + !debug_events.is_empty(), + "expected a DEBUG log with IdleTimeout, but found none" + ); + + ct.cancel(); +} + +#[tokio::test(flavor = "current_thread")] +async fn test_explicit_close_on_live_session_succeeds() { + let ct = CancellationToken::new(); + let mut session_manager = LocalSessionManager::default(); + session_manager.session_config.keep_alive = Some(Duration::from_secs(60)); + let session_manager = Arc::new(session_manager); + + let service = StreamableHttpService::new( + || Ok(Calculator::new()), + session_manager.clone(), + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = tcp_listener.local_addr().unwrap(); + + tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let client = reqwest::Client::new(); + + let response = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let session_id = response.headers()["mcp-session-id"] + .to_str() + .unwrap() + .to_string(); + + client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("mcp-session-id", &session_id) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) + .send() + .await + .unwrap(); + + let session_id_parsed: Arc = Arc::from(session_id.as_str()); + + assert!( + session_manager + .has_session(&session_id_parsed) + .await + .unwrap(), + "session should exist before explicit close" + ); + + let result = session_manager.close_session(&session_id_parsed).await; + assert!( + result.is_ok(), + "close_session on a live worker should succeed: {result:?}" + ); + + assert!( + !session_manager + .has_session(&session_id_parsed) + .await + .unwrap(), + "session should not exist after explicit close" + ); + + ct.cancel(); +} diff --git a/vendor/rmcp/tests/test_streamable_http_init_timeout.rs b/vendor/rmcp/tests/test_streamable_http_init_timeout.rs new file mode 100644 index 000000000..c9a2ee89b --- /dev/null +++ b/vendor/rmcp/tests/test_streamable_http_init_timeout.rs @@ -0,0 +1,63 @@ +#![cfg(all(feature = "transport-streamable-http-server", not(feature = "local")))] + +use std::time::Duration; + +use rmcp::{ + model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, + transport::streamable_http_server::session::{SessionManager, local::LocalSessionManager}, +}; + +#[tokio::test] +async fn test_init_timeout_terminates_pre_init_session() -> anyhow::Result<()> { + let mut manager = LocalSessionManager::default(); + manager.session_config.init_timeout = Some(Duration::from_millis(200)); + + // Bind the transport so its drop-guard doesn't cancel the worker — we + // want termination via init_timeout, not via cancellation. + let (session_id, _transport) = manager.create_session().await?; + + tokio::time::sleep(Duration::from_millis(500)).await; + + let message = ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ); + let result = manager.initialize_session(&session_id, message).await; + + assert!( + result.is_err(), + "expected worker to be dead; got: {result:?}" + ); + + Ok(()) +} + +#[tokio::test] +async fn test_init_timeout_none_keeps_worker_alive() -> anyhow::Result<()> { + let mut manager = LocalSessionManager::default(); + manager.session_config.init_timeout = None; + + let (session_id, _transport) = manager.create_session().await?; + + tokio::time::sleep(Duration::from_millis(500)).await; + + let message = ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ); + // Liveness probe: a live worker accepts the send then stalls waiting for + // a handler response (none is wired up), tripping the outer timeout. A + // dead worker would fail the send and return immediately. + let probe = tokio::time::timeout( + Duration::from_millis(200), + manager.initialize_session(&session_id, message), + ) + .await; + + assert!( + probe.is_err(), + "expected worker to be alive; got: {probe:?}" + ); + + Ok(()) +} diff --git a/vendor/rmcp/tests/test_streamable_http_json_response.rs b/vendor/rmcp/tests/test_streamable_http_json_response.rs new file mode 100644 index 000000000..b1c09f512 --- /dev/null +++ b/vendor/rmcp/tests/test_streamable_http_json_response.rs @@ -0,0 +1,405 @@ +#![cfg(not(feature = "local"))] +use rmcp::{ + ErrorData, ServerHandler, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, + ProgressNotificationParam, ServerCapabilities, ServerInfo, + }, + service::RequestContext, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, +}; +use tokio_util::sync::CancellationToken; + +mod common; +use common::calculator::Calculator; + +const INIT_BODY: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#; +const CALL_WITH_PROGRESS_BODY: &str = r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"progress","arguments":{},"_meta":{"progressToken":"progress-test-1"}}}"#; +const NEGOTIATED_CALL_BODY: &str = r#"{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "terminal", + "arguments": {}, + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "test", + "version": "1.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } +}"#; +const NEGOTIATED_CALL_WITH_PROGRESS_BODY: &str = r#"{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "progress", + "arguments": {}, + "_meta": { + "progressToken": "progress-test-1", + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "test", + "version": "1.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } +}"#; + +#[derive(Clone)] +struct ProgressServer; + +impl ServerHandler for ProgressServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + if request.name == "progress" { + let progress_token = context + .meta + .get_progress_token() + .expect("request includes progressToken"); + context + .peer + .notify_progress( + ProgressNotificationParam::new(progress_token, 50.0) + .with_total(100.0) + .with_message("working"), + ) + .await + .expect("progress notification is delivered"); + } + Ok(CallToolResult::success(vec![ContentBlock::text("done")]).into()) + } +} + +async fn spawn_server( + config: StreamableHttpServerConfig, +) -> (reqwest::Client, String, CancellationToken) { + let ct = config.cancellation_token.clone(); + let service: StreamableHttpService = + StreamableHttpService::new(|| Ok(Calculator::new()), Default::default(), config); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = tcp_listener.local_addr().unwrap(); + + tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let client = reqwest::Client::new(); + let base_url = format!("http://{addr}/mcp"); + (client, base_url, ct) +} + +async fn spawn_progress_server( + config: StreamableHttpServerConfig, +) -> (reqwest::Client, String, CancellationToken) { + let ct = config.cancellation_token.clone(); + let service: StreamableHttpService = + StreamableHttpService::new(|| Ok(ProgressServer), Default::default(), config); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = tcp_listener.local_addr().unwrap(); + + tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let client = reqwest::Client::new(); + let base_url = format!("http://{addr}/mcp"); + (client, base_url, ct) +} + +#[tokio::test] +async fn stateless_json_response_returns_application_json() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + let (client, url, ct) = spawn_server( + StreamableHttpServerConfig::default() + .with_legacy_session_mode(false) + .with_json_response(true) + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ) + .await; + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(INIT_BODY) + .send() + .await?; + + assert_eq!(response.status(), 200); + + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + content_type.contains("application/json"), + "Expected application/json, got: {content_type}" + ); + + let body = response.text().await?; + let parsed: serde_json::Value = serde_json::from_str(&body)?; + assert_eq!(parsed["jsonrpc"], "2.0"); + assert_eq!(parsed["id"], 1); + assert!(parsed["result"].is_object(), "Expected result object"); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn stateless_negotiated_terminal_response_returns_application_json() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + let (client, url, ct) = spawn_progress_server( + StreamableHttpServerConfig::default() + .with_legacy_session_mode(false) + .with_json_response(true) + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ) + .await; + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2026-07-28") + .header("Mcp-Method", "tools/call") + .header("Mcp-Name", "terminal") + .body(NEGOTIATED_CALL_BODY) + .send() + .await?; + + assert_eq!(response.status(), 200); + + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + assert!( + content_type.contains("application/json"), + "Expected application/json, got: {content_type}" + ); + + let body: serde_json::Value = response.json().await?; + assert_eq!(body["id"], 2); + assert!(body["result"].is_object(), "Expected result object"); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn stateless_json_response_falls_back_to_sse_for_progress() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + let (client, url, ct) = spawn_progress_server( + StreamableHttpServerConfig::default() + .with_legacy_session_mode(false) + .with_json_response(true) + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ) + .await; + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(CALL_WITH_PROGRESS_BODY) + .send() + .await?; + + assert_eq!(response.status(), 200); + + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + assert!( + content_type.contains("text/event-stream"), + "Expected SSE fallback, got: {content_type}" + ); + + let body = response.text().await?; + let messages: Vec = body + .lines() + .filter_map(|line| line.strip_prefix("data:")) + .map(str::trim) + .filter(|data| !data.is_empty()) + .map(serde_json::from_str) + .collect::>()?; + assert_eq!(messages.len(), 2, "Expected progress and result: {body}"); + assert_eq!(messages[0]["method"], "notifications/progress"); + assert_eq!(messages[1]["id"], 2); + assert!( + messages[1]["result"].is_object(), + "Expected result object: {body}" + ); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn stateless_negotiated_json_response_falls_back_to_sse_for_progress() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + let (client, url, ct) = spawn_progress_server( + StreamableHttpServerConfig::default() + .with_legacy_session_mode(false) + .with_json_response(true) + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ) + .await; + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2026-07-28") + .header("Mcp-Method", "tools/call") + .header("Mcp-Name", "progress") + .body(NEGOTIATED_CALL_WITH_PROGRESS_BODY) + .send() + .await?; + + assert_eq!(response.status(), 200); + + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + assert!( + content_type.contains("text/event-stream"), + "Expected SSE fallback, got: {content_type}" + ); + + let body = response.text().await?; + let messages: Vec = body + .lines() + .filter_map(|line| line.strip_prefix("data:")) + .map(str::trim) + .filter(|data| !data.is_empty()) + .map(serde_json::from_str) + .collect::>()?; + assert_eq!(messages.len(), 2, "Expected progress and result: {body}"); + assert_eq!(messages[0]["method"], "notifications/progress"); + assert_eq!(messages[1]["id"], 2); + assert!( + messages[1]["result"].is_object(), + "Expected result object: {body}" + ); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn stateless_sse_mode_default_unchanged() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + let (client, url, ct) = spawn_server( + StreamableHttpServerConfig::default() + .with_legacy_session_mode(false) + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ) + .await; + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(INIT_BODY) + .send() + .await?; + + assert_eq!(response.status(), 200); + + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + content_type.contains("text/event-stream"), + "Expected text/event-stream, got: {content_type}" + ); + + let body = response.text().await?; + assert!( + body.contains("data:"), + "Expected SSE framing (data: prefix), got: {body}" + ); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn json_response_ignored_in_legacy_session_mode() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + // json_response: true has no effect when legacy_session_mode: true — server still uses SSE + let (client, url, ct) = spawn_server( + StreamableHttpServerConfig::default() + .with_json_response(true) + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ) + .await; + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(INIT_BODY) + .send() + .await?; + + assert_eq!(response.status(), 200); + + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + content_type.contains("text/event-stream"), + "Stateful mode should always use SSE regardless of json_response, got: {content_type}" + ); + + ct.cancel(); + Ok(()) +} diff --git a/vendor/rmcp/tests/test_streamable_http_priming.rs b/vendor/rmcp/tests/test_streamable_http_priming.rs new file mode 100644 index 000000000..1e0e5e5f9 --- /dev/null +++ b/vendor/rmcp/tests/test_streamable_http_priming.rs @@ -0,0 +1,499 @@ +#![cfg(not(feature = "local"))] +use std::time::Duration; + +use rmcp::transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, + session::{SessionId, local::LocalSessionManager}, +}; +use tokio_util::sync::CancellationToken; + +mod common; +use common::calculator::Calculator; + +#[tokio::test] +async fn test_priming_on_stream_start() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + + // legacy_session_mode: true automatically enables priming with DEFAULT_RETRY_INTERVAL (3 seconds) + let service: StreamableHttpService = + StreamableHttpService::new( + || Ok(Calculator::new()), + Default::default(), + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = tcp_listener.local_addr()?; + + let handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + // Send initialize request + let client = reqwest::Client::new(); + let response = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#) + .send() + .await?; + + assert_eq!(response.status(), 200); + + let body = response.text().await?; + + // Split SSE events by double newline + let events: Vec<&str> = body.split("\n\n").filter(|e| !e.is_empty()).collect(); + assert!(events.len() >= 2); + + // Verify priming event (first event) — initialize uses "0" (no http_request_id) + let priming_event = events[0]; + assert!(priming_event.contains("id: 0")); + assert!(priming_event.contains("retry: 3000")); + assert!(priming_event.contains("data:")); + + // Verify initialize response (second event) + let response_event = events[1]; + assert!(response_event.contains(r#""jsonrpc":"2.0""#)); + assert!(response_event.contains(r#""id":1"#)); + + ct.cancel(); + handle.await?; + + Ok(()) +} + +#[tokio::test] +async fn test_request_wise_priming_includes_http_request_id() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + + let service: StreamableHttpService = + StreamableHttpService::new( + || Ok(Calculator::new()), + Default::default(), + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = tcp_listener.local_addr()?; + + let handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let client = reqwest::Client::new(); + + // Initialize the session + let response = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#) + .send() + .await?; + assert_eq!(response.status(), 200); + let session_id: SessionId = response.headers()["mcp-session-id"].to_str()?.into(); + + // Send notifications/initialized + let status = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("mcp-session-id", session_id.to_string()) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) + .send() + .await? + .status(); + assert_eq!(status, 202); + + // First tool call — should get http_request_id 0 + let body = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("mcp-session-id", session_id.to_string()) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"sum","arguments":{"a":1,"b":2}}}"#) + .send() + .await? + .text() + .await?; + + let events: Vec<&str> = body.split("\n\n").filter(|e| !e.is_empty()).collect(); + assert!( + events.len() >= 2, + "expected priming + response, got: {body}" + ); + + // Priming event should encode the http_request_id (0) + let priming = events[0]; + assert!( + priming.contains("id: 0/0"), + "first request priming should be 0/0, got: {priming}" + ); + assert!(priming.contains("retry: 3000")); + + // Response event should use index 1 (since priming occupies index 0) + let response_event = events[1]; + assert!( + response_event.contains("id: 1/0"), + "first response event id should be 1/0, got: {response_event}" + ); + assert!(response_event.contains(r#""id":2"#)); + + // Second tool call — should get http_request_id 1 + let body = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("mcp-session-id", session_id.to_string()) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"sum","arguments":{"a":3,"b":4}}}"#) + .send() + .await? + .text() + .await?; + + let events: Vec<&str> = body.split("\n\n").filter(|e| !e.is_empty()).collect(); + assert!( + events.len() >= 2, + "expected priming + response, got: {body}" + ); + + let priming = events[0]; + assert!( + priming.contains("id: 0/1"), + "second request priming should be 0/1, got: {priming}" + ); + + let response_event = events[1]; + assert!( + response_event.contains("id: 1/1"), + "second response event id should be 1/1, got: {response_event}" + ); + assert!(response_event.contains(r#""id":3"#)); + + ct.cancel(); + handle.await?; + + Ok(()) +} + +#[tokio::test] +async fn test_resume_after_request_wise_channel_completed() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + + let service: StreamableHttpService = + StreamableHttpService::new( + || Ok(Calculator::new()), + Default::default(), + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = tcp_listener.local_addr()?; + + let handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let client = reqwest::Client::new(); + + // Initialize session + let response = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#) + .send() + .await?; + assert_eq!(response.status(), 200); + let session_id: SessionId = response.headers()["mcp-session-id"].to_str()?.into(); + + // Complete handshake + let status = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("mcp-session-id", session_id.to_string()) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) + .send() + .await? + .status(); + assert_eq!(status, 202); + + // Call a tool and consume the full response (channel completes) + let body = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("mcp-session-id", session_id.to_string()) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"sum","arguments":{"a":1,"b":2}}}"#) + .send() + .await? + .text() + .await?; + + let events: Vec<&str> = body.split("\n\n").filter(|e| !e.is_empty()).collect(); + assert!( + events.len() >= 2, + "expected priming + response, got: {body}" + ); + assert!(events[0].contains("id: 0/0")); + assert!(events[1].contains(r#""id":2"#)); + + // Resume with Last-Event-ID after the channel has completed. + // The server returns 200 — either with replayed cached events + // (if the channel is still retained) or an empty stream (if the + // session worker hasn't processed the completion yet). + let resume = client + .get(format!("http://{addr}/mcp")) + .header("Accept", "text/event-stream") + .header("mcp-session-id", session_id.to_string()) + .header("Mcp-Protocol-Version", "2025-06-18") + .header("last-event-id", "0/0") + .send() + .await?; + assert_eq!(resume.status(), 200); + + let resume_body = resume.text().await?; + // The stream should complete (not hang), regardless of whether + // it contains replayed events or is empty. + assert!( + !resume_body.contains("standalone"), + "should not receive events from a different stream" + ); + + ct.cancel(); + handle.await?; + + Ok(()) +} + +#[tokio::test] +async fn test_completed_cache_ttl_eviction() -> anyhow::Result<()> { + use std::sync::Arc; + + let ct = CancellationToken::new(); + let mut session_manager = LocalSessionManager::default(); + session_manager.session_config.completed_cache_ttl = Duration::from_millis(200); + let session_manager = Arc::new(session_manager); + + let service = StreamableHttpService::new( + || Ok(Calculator::new()), + session_manager.clone(), + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = tcp_listener.local_addr()?; + + let handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let client = reqwest::Client::new(); + + // Initialize session + let response = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#) + .send() + .await?; + assert_eq!(response.status(), 200); + let session_id: SessionId = response.headers()["mcp-session-id"].to_str()?.into(); + + // Complete handshake + client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("mcp-session-id", session_id.to_string()) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) + .send() + .await?; + + // Call a tool and consume the response (channel completes) + let body = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("mcp-session-id", session_id.to_string()) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"sum","arguments":{"a":1,"b":2}}}"#) + .send() + .await? + .text() + .await?; + assert!(body.contains(r#""id":2"#)); + + // Wait for TTL to expire (200ms) plus margin + tokio::time::sleep(Duration::from_millis(400)).await; + + // Send a notification to trigger an event loop iteration (runs eviction) + client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("mcp-session-id", session_id.to_string()) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) + .send() + .await?; + + // Small delay to ensure the eviction ran + tokio::time::sleep(Duration::from_millis(50)).await; + + // Resume after TTL — channel should be evicted. The server returns + // 200 with an empty stream (no events from a different stream). + let resume = client + .get(format!("http://{addr}/mcp")) + .header("Accept", "text/event-stream") + .header("mcp-session-id", session_id.to_string()) + .header("Mcp-Protocol-Version", "2025-06-18") + .header("last-event-id", "0/0") + .send() + .await?; + assert_eq!(resume.status(), 200); + + let body = resume.text().await?; + assert!( + !body.contains(r#""id":2"#), + "should NOT contain the old tool response after eviction, got: {body}" + ); + + ct.cancel(); + handle.await?; + + Ok(()) +} + +#[tokio::test] +async fn test_priming_on_stream_close() -> anyhow::Result<()> { + use std::sync::Arc; + + use rmcp::transport::streamable_http_server::session::SessionId; + + let ct = CancellationToken::new(); + let session_manager = Arc::new(LocalSessionManager::default()); + + // legacy_session_mode: true automatically enables priming with DEFAULT_RETRY_INTERVAL (3 seconds) + let service = StreamableHttpService::new( + || Ok(Calculator::new()), + session_manager.clone(), + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = tcp_listener.local_addr()?; + + let handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + // Send initialize request to create a session + let client = reqwest::Client::new(); + let response = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#) + .send() + .await?; + + let session_id: SessionId = response.headers()["mcp-session-id"].to_str()?.into(); + + // Open a standalone GET stream (send() returns when headers are received) + let response = client + .get(format!("http://{addr}/mcp")) + .header("Accept", "text/event-stream") + .header("mcp-session-id", session_id.to_string()) + .send() + .await?; + + assert_eq!(response.status(), 200); + + // Spawn a task to read the response body (blocks until stream closes) + let read_task = tokio::spawn(async move { response.text().await.unwrap() }); + + // Close the standalone stream with a 5-second retry hint + let sessions = session_manager.sessions.read().await; + let session = sessions.get(&session_id).unwrap(); + session + .close_standalone_sse_stream(Some(Duration::from_secs(5))) + .await?; + drop(sessions); + + // Wait for the read task to complete and verify the response + let body = read_task.await?; + + // Verify the stream received two priming events: + // 1. At stream start (retry: 3000) + // 2. Before close (retry: 5000) + let events: Vec<&str> = body.split("\n\n").filter(|e| !e.is_empty()).collect(); + assert_eq!(events.len(), 2); + + // First event: priming at stream start + let start_priming = events[0]; + assert!(start_priming.contains("id:")); + assert!(start_priming.contains("retry: 3000")); + assert!(start_priming.contains("data:")); + + // Second event: priming before close + let close_priming = events[1]; + assert!(close_priming.contains("id:")); + assert!(close_priming.contains("retry: 5000")); + assert!(close_priming.contains("data:")); + + ct.cancel(); + handle.await?; + + Ok(()) +} diff --git a/vendor/rmcp/tests/test_streamable_http_protocol_version.rs b/vendor/rmcp/tests/test_streamable_http_protocol_version.rs new file mode 100644 index 000000000..fcbeb41a0 --- /dev/null +++ b/vendor/rmcp/tests/test_streamable_http_protocol_version.rs @@ -0,0 +1,853 @@ +#![cfg(not(feature = "local"))] +//! Streamable HTTP protocol-version and request-metadata validation tests. +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use rmcp::{ + ServerHandler, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, +}; +use serde_json::{Value, json}; +use tokio_util::sync::CancellationToken; + +mod common; +use common::calculator::Calculator; + +fn init_body(body_version: &str) -> String { + format!( + r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"{body_version}","capabilities":{{}},"clientInfo":{{"name":"test","version":"1.0"}}}}}}"# + ) +} + +async fn spawn_server( + config: StreamableHttpServerConfig, +) -> (reqwest::Client, String, CancellationToken) { + spawn_server_with_manager(config, Arc::new(LocalSessionManager::default())).await +} + +async fn spawn_server_with_manager( + config: StreamableHttpServerConfig, + session_manager: Arc, +) -> (reqwest::Client, String, CancellationToken) { + let ct = config.cancellation_token.clone(); + let service: StreamableHttpService = + StreamableHttpService::new(|| Ok(Calculator::new()), session_manager, config); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = tcp_listener.local_addr().unwrap(); + + tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let client = reqwest::Client::new(); + let base_url = format!("http://{addr}/mcp"); + (client, base_url, ct) +} + +fn stateless_json_config() -> StreamableHttpServerConfig { + StreamableHttpServerConfig::default() + .with_legacy_session_mode(false) + .with_json_response(true) + .with_sse_keep_alive(None) + .with_cancellation_token(CancellationToken::new()) +} + +fn stateful_config() -> StreamableHttpServerConfig { + StreamableHttpServerConfig::default() + .with_legacy_session_mode(true) + .with_sse_keep_alive(None) + .with_cancellation_token(CancellationToken::new()) +} + +async fn post_init( + client: &reqwest::Client, + url: &str, + header: Option<&str>, + body_version: &str, +) -> reqwest::Response { + let mut req = client + .post(url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(init_body(body_version)); + if let Some(h) = header { + req = req.header("MCP-Protocol-Version", h); + } + req.send().await.expect("send initialize request") +} + +async fn post_non_initialize(client: &reqwest::Client, url: &str) -> reqwest::Response { + client + .post(url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#) + .send() + .await + .expect("send non-initialize request") +} + +async fn post_modern_request( + client: &reqwest::Client, + url: &str, + method: &str, + name: Option<&str>, + params: Value, +) -> reqwest::Response { + let mut request = client + .post(url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2026-07-28") + .header("Mcp-Method", method) + .json(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + })); + if let Some(name) = name { + request = request.header("Mcp-Name", name); + } + request.send().await.expect("send modern request") +} + +#[tokio::test] +async fn stateless_init_rejects_when_header_older_than_body() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + let response = post_init(&client, &url, Some("2025-03-26"), "2025-11-25").await; + assert_eq!(response.status(), 400); + + let body: serde_json::Value = response.json().await?; + assert_eq!(body["error"]["code"], -32600); + assert!( + body["error"]["message"] + .as_str() + .unwrap_or_default() + .contains("MCP-Protocol-Version"), + "expected error message to mention the header, got: {body}" + ); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn stateless_init_rejects_when_header_newer_than_body() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + let response = post_init(&client, &url, Some("2025-11-25"), "2025-03-26").await; + assert_eq!(response.status(), 400); + + let body: serde_json::Value = response.json().await?; + assert_eq!(body["error"]["code"], -32600); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn stateless_init_accepts_when_header_matches_body() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + let response = post_init(&client, &url, Some("2025-11-25"), "2025-11-25").await; + assert_eq!(response.status(), 200); + + let body: serde_json::Value = response.json().await?; + assert!( + body["result"].is_object(), + "expected an InitializeResult, got: {body}" + ); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn stateless_init_accepts_when_header_absent() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + let response = post_init(&client, &url, None, "2025-11-25").await; + assert_eq!(response.status(), 200); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn stateful_init_rejects_when_header_mismatches_body() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server(stateful_config()).await; + + let response = post_init(&client, &url, Some("2024-11-05"), "2025-11-25").await; + assert_eq!(response.status(), 400); + + let body: serde_json::Value = response.json().await?; + assert_eq!(body["error"]["code"], -32600); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn stateful_rejected_initial_posts_do_not_create_sessions() -> anyhow::Result<()> { + let session_manager = Arc::new(LocalSessionManager::default()); + let (client, url, ct) = + spawn_server_with_manager(stateful_config(), session_manager.clone()).await; + + let response = post_non_initialize(&client, &url).await; + assert_eq!(response.status(), 422); + assert_eq!(session_manager.sessions.read().await.len(), 0); + + let response = post_init(&client, &url, Some("2024-11-05"), "2025-11-25").await; + assert_eq!(response.status(), 400); + assert_eq!(session_manager.sessions.read().await.len(), 0); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn stateless_missing_protocol_header_returns_header_mismatch() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + let body = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}"#; + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("Mcp-Method", "tools/list") + .body(body) + .send() + .await + .expect("send non-initialize request"); + + assert_eq!(response.status(), 400); + + let body: serde_json::Value = response.json().await?; + assert_eq!(body["jsonrpc"], "2.0"); + assert_eq!(body["id"], 1); + assert_eq!(body["error"]["code"], -32020); + assert!( + body["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("requires MCP-Protocol-Version header")), + "expected missing protocol header message, got: {body}" + ); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn stateless_tools_list_rejects_missing_request_meta() { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + let response = post_modern_request(&client, &url, "tools/list", None, json!({})).await; + + assert_eq!(response.status(), 400); + let body: Value = response.json().await.expect("response should be JSON"); + assert_eq!(body["error"]["code"], -32602); + assert!( + body["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("io.modelcontextprotocol/protocolVersion")), + "expected error message to mention protocolVersion, got: {body}" + ); + ct.cancel(); +} + +#[tokio::test] +async fn stateless_tools_call_rejects_missing_request_meta() { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + let response = post_modern_request( + &client, + &url, + "tools/call", + Some("sum"), + json!({ + "name": "sum", + "arguments": { + "a": 1, + "b": 2 + } + }), + ) + .await; + + assert_eq!(response.status(), 400); + let body: Value = response.json().await.expect("response should be JSON"); + assert_eq!(body["error"]["code"], -32602); + assert!( + body["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("io.modelcontextprotocol/protocolVersion")), + "expected error message to mention protocolVersion, got: {body}" + ); + ct.cancel(); +} + +#[tokio::test] +async fn stateless_request_rejects_missing_meta_protocol_version() { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + let response = post_modern_request( + &client, + &url, + "tools/list", + None, + json!({ + "_meta": { + "io.modelcontextprotocol/clientInfo": { + "name": "test", + "version": "1.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + }), + ) + .await; + + assert_eq!(response.status(), 400); + let body: Value = response.json().await.expect("response should be JSON"); + assert_eq!(body["error"]["code"], -32602); + assert!( + body["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("io.modelcontextprotocol/protocolVersion")), + "expected error message to mention protocolVersion, got: {body}" + ); + ct.cancel(); +} + +#[tokio::test] +async fn stateless_request_rejects_missing_meta_client_capabilities() { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + let response = post_modern_request( + &client, + &url, + "tools/list", + None, + json!({ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "test", + "version": "1.0" + } + } + }), + ) + .await; + + assert_eq!(response.status(), 400); + let body: Value = response.json().await.expect("response should be JSON"); + assert_eq!(body["error"]["code"], -32602); + assert!( + body["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("io.modelcontextprotocol/clientCapabilities")), + "expected error message to mention clientCapabilities, got: {body}" + ); + ct.cancel(); +} + +#[tokio::test] +async fn stateless_request_accepts_missing_optional_meta_client_info() { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + let response = post_modern_request( + &client, + &url, + "tools/list", + None, + json!({ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {} + } + }), + ) + .await; + + assert_eq!(response.status(), 200); + ct.cancel(); +} + +// --------------------------------------------------------------------------- +// Opt-in seam: `with_stateless_protocol_metadata_required(true)` +// --------------------------------------------------------------------------- +// In stateless mode, every non-initialize Streamable HTTP JSON-RPC request +// POST must carry the `MCP-Protocol-Version` HTTP header (missing → HTTP 400 / +// JSON-RPC `-32020` HeaderMismatch before dispatch). Every non-initialize, +// non-discover request must additionally carry a per-request +// `io.modelcontextprotocol/protocolVersion` in `_meta` (missing → HTTP 400 / +// JSON-RPC `-32602`). The existing rule that requires `server/discover` to +// carry `_meta.protocolVersion` is preserved unchanged. +// +// Non-dispatch is proven with an explicit invocation counter (`AtomicUsize`). + +#[derive(Clone)] +struct CountingServer { + lists: Arc, +} + +impl CountingServer { + fn new() -> (Self, Arc) { + let lists = Arc::new(AtomicUsize::new(0)); + ( + Self { + lists: lists.clone(), + }, + lists, + ) + } +} + +impl ServerHandler for CountingServer { + fn get_info(&self) -> rmcp::model::ServerInfo { + rmcp::model::ServerInfo::new( + rmcp::model::ServerCapabilities::builder() + .enable_tools() + .build(), + ) + } + + fn list_tools( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + + Send + + '_ { + self.lists.fetch_add(1, Ordering::SeqCst); + std::future::ready(Ok(rmcp::model::ListToolsResult::default())) + } +} + +async fn spawn_counting( + config: StreamableHttpServerConfig, +) -> (reqwest::Client, String, CancellationToken, Arc) { + let ct = config.cancellation_token.clone(); + let (server, lists) = CountingServer::new(); + let service: StreamableHttpService = + StreamableHttpService::new(move || Ok(server.clone()), Default::default(), config); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = tcp_listener.local_addr().unwrap(); + + tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + ( + reqwest::Client::new(), + format!("http://{addr}/mcp"), + ct, + lists, + ) +} + +fn modern_required_config() -> StreamableHttpServerConfig { + StreamableHttpServerConfig::default() + .with_legacy_session_mode(false) + .with_json_response(true) + .with_sse_keep_alive(None) + .with_stateless_protocol_metadata_required(true) + .with_cancellation_token(CancellationToken::new()) +} + +async fn post_seam( + client: &reqwest::Client, + url: &str, + body: &str, + header_version: Option<&str>, + extra_headers: &[(&str, &str)], +) -> reqwest::Response { + let mut req = client + .post(url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(body.to_owned()); + if let Some(h) = header_version { + req = req.header("MCP-Protocol-Version", h); + } + for (k, v) in extra_headers { + req = req.header(*k, *v); + } + req.send().await.expect("send request") +} + +// With the seam disabled, explicit stateless mode still dispatches a request +// without protocol metadata, preserving backwards compatibility. +#[tokio::test] +async fn seam_disabled_preserves_stateless_compatibility() -> anyhow::Result<()> { + let (client, url, ct, lists) = spawn_counting(stateless_json_config()).await; + + let body = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#; + let response = post_seam(&client, &url, body, None, &[]).await; + assert_eq!(response.status(), 200); + assert_eq!( + lists.load(Ordering::SeqCst), + 1, + "seam-disabled stateless config must dispatch exactly once" + ); + + ct.cancel(); + Ok(()) +} + +// With legacy compatibility enabled, the seam applies only to requests that +// are routed statelessly: an established legacy session remains unchanged, +// while a self-identifying modern request is still enforced. +#[tokio::test] +async fn seam_mixed_mode_enforces_only_stateless_routed_requests() -> anyhow::Result<()> { + let config = StreamableHttpServerConfig::default() + .with_legacy_session_mode(true) + .with_json_response(true) + .with_sse_keep_alive(None) + .with_stateless_protocol_metadata_required(true) + .with_cancellation_token(CancellationToken::new()); + let (client, url, ct, lists) = spawn_counting(config).await; + + let legacy_body = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#; + let unknown_response = post_seam( + &client, + &url, + legacy_body, + None, + &[("Mcp-Session-Id", "unknown-session")], + ) + .await; + assert_eq!( + unknown_response.status(), + reqwest::StatusCode::NOT_FOUND, + "the stateless seam must not shadow the legacy unknown-session boundary" + ); + assert_eq!(lists.load(Ordering::SeqCst), 0); + + let initialize = post_init(&client, &url, None, "2025-11-25").await; + assert_eq!(initialize.status(), 200); + let session_id = initialize + .headers() + .get("Mcp-Session-Id") + .and_then(|value| value.to_str().ok()) + .expect("legacy initialize response must include a session id") + .to_owned(); + + let legacy_response = post_seam( + &client, + &url, + legacy_body, + None, + &[("Mcp-Session-Id", &session_id)], + ) + .await; + assert_eq!(legacy_response.status(), 200); + let _ = legacy_response.bytes().await?; + assert_eq!( + lists.load(Ordering::SeqCst), + 1, + "legacy session routing must remain unchanged" + ); + + let delete_response = client + .delete(&url) + .header("Mcp-Session-Id", &session_id) + .send() + .await?; + assert_eq!(delete_response.status(), reqwest::StatusCode::ACCEPTED); + + let terminated_response = post_seam( + &client, + &url, + legacy_body, + None, + &[("Mcp-Session-Id", &session_id)], + ) + .await; + assert_eq!( + terminated_response.status(), + reqwest::StatusCode::NOT_FOUND, + "the stateless seam must not shadow the legacy terminated-session boundary" + ); + assert_eq!(lists.load(Ordering::SeqCst), 1); + + let modern_body = r#"{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}"#; + let modern_response = post_seam( + &client, + &url, + modern_body, + Some("2026-07-28"), + &[("Mcp-Method", "tools/list")], + ) + .await; + assert_eq!(modern_response.status(), 400); + let payload: serde_json::Value = modern_response.json().await?; + assert_eq!(payload["error"]["code"], -32602); + assert_eq!( + lists.load(Ordering::SeqCst), + 1, + "stateless metadata rejection must happen before another handler dispatch" + ); + + ct.cancel(); + Ok(()) +} + +// Both signals absent → missing header / -32020 before dispatch. +#[tokio::test] +async fn seam_opt_in_rejects_missing_header_before_dispatch() -> anyhow::Result<()> { + let (client, url, ct, lists) = spawn_counting(modern_required_config()).await; + + let without_meta = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#; + let response = post_seam(&client, &url, without_meta, None, &[]).await; + assert_eq!(response.status(), 400, "missing header must yield HTTP 400"); + let payload: serde_json::Value = response.json().await?; + assert_eq!(payload["error"]["code"], -32020); + assert_eq!( + lists.load(Ordering::SeqCst), + 0, + "handler invocation counter must stay at 0 when the header is missing" + ); + + ct.cancel(); + Ok(()) +} + +// Header present but `_meta.protocolVersion` absent → -32602 before +// dispatch. Counter must equal 0. +#[tokio::test] +async fn seam_opt_in_rejects_missing_meta_before_dispatch() -> anyhow::Result<()> { + let (client, url, ct, lists) = spawn_counting(modern_required_config()).await; + + let body = r#"{"jsonrpc":"2.0","id":4,"method":"tools/list","params":{}}"#; + let response = post_seam( + &client, + &url, + body, + Some("2026-07-28"), + &[("Mcp-Method", "tools/list")], + ) + .await; + assert_eq!(response.status(), 400); + let payload: serde_json::Value = response.json().await?; + assert_eq!(payload["error"]["code"], -32602); + assert_eq!( + lists.load(Ordering::SeqCst), + 0, + "handler invocation counter must stay at 0 when _meta.protocolVersion is missing" + ); + + ct.cancel(); + Ok(()) +} + +// A 2026 request with protocolVersion but no clientCapabilities reaches the +// existing inline-metadata validator and is rejected before method dispatch. +#[tokio::test] +async fn seam_opt_in_rejects_missing_client_capabilities_before_dispatch() -> anyhow::Result<()> { + let (client, url, ct, lists) = spawn_counting(modern_required_config()).await; + + let body = r#"{"jsonrpc":"2.0","id":5,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}"#; + let response = post_seam( + &client, + &url, + body, + Some("2026-07-28"), + &[("Mcp-Method", "tools/list")], + ) + .await; + assert_eq!(response.status(), 400); + let payload: serde_json::Value = response.json().await?; + assert_eq!(payload["error"]["code"], -32602); + assert_eq!( + lists.load(Ordering::SeqCst), + 0, + "handler invocation counter must stay at 0 when clientCapabilities is missing" + ); + + ct.cancel(); + Ok(()) +} + +// Both signals present at the current version, plus the routing header +// required by `validate_standard_headers` for `>= STANDARD_HEADERS`. The +// seam must dispatch and the handler must run exactly once. +#[tokio::test] +async fn seam_opt_in_dispatches_when_header_and_meta_present() -> anyhow::Result<()> { + let (client, url, ct, lists) = spawn_counting(modern_required_config()).await; + + let body = r#"{"jsonrpc":"2.0","id":6,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}"#; + let response = post_seam( + &client, + &url, + body, + Some("2026-07-28"), + &[("Mcp-Method", "tools/list")], + ) + .await; + assert_eq!(response.status(), 200); + let payload: serde_json::Value = response.json().await?; + assert!(payload.get("result").is_some()); + assert_eq!( + lists.load(Ordering::SeqCst), + 1, + "handler invocation counter must equal 1 on successful dispatch" + ); + + ct.cancel(); + Ok(()) +} + +// rmcp clients negotiated below 2026-07-28 send the protocol header but do +// not attach per-request protocol metadata. Requiring the metadata therefore +// rejects their real request shape even if the handler supports that version. +#[tokio::test] +async fn seam_opt_in_rejects_older_rmcp_client_request_shape() -> anyhow::Result<()> { + let (client, url, ct, lists) = spawn_counting(modern_required_config()).await; + + let body = r#"{"jsonrpc":"2.0","id":7,"method":"tools/list","params":{}}"#; + let response = post_seam(&client, &url, body, Some("2025-11-25"), &[]).await; + assert_eq!(response.status(), 400); + let payload: serde_json::Value = response.json().await?; + assert_eq!(payload["error"]["code"], -32602); + assert_eq!( + lists.load(Ordering::SeqCst), + 0, + "an older client request without per-request metadata must not dispatch" + ); + + ct.cancel(); + Ok(()) +} + +// When the 2026+ header is present but `Mcp-Method` is missing, the +// existing `validate_standard_headers` rule must fire first with -32020, +// before the seam's -32602 missing-meta check. +#[tokio::test] +async fn seam_opt_in_preserves_standard_header_precedence() -> anyhow::Result<()> { + let (client, url, ct, lists) = spawn_counting(modern_required_config()).await; + + let body = r#"{"jsonrpc":"2.0","id":7,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}"#; + let response = post_seam(&client, &url, body, Some("2026-07-28"), &[]).await; + assert_eq!(response.status(), 400); + let payload: serde_json::Value = response.json().await?; + assert_eq!( + payload["error"]["code"], -32020, + "standard-headers routing-header error must precede the seam's meta check" + ); + assert_eq!( + lists.load(Ordering::SeqCst), + 0, + "handler invocation counter must stay at 0 when a routing header is missing" + ); + + ct.cancel(); + Ok(()) +} + +// `initialize` is exempt from the new required-header check while retaining +// its existing optional-header and header/body consistency rules. +#[tokio::test] +async fn seam_opt_in_preserves_initialize_rules() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server(modern_required_config()).await; + + let response = post_init(&client, &url, None, "2025-11-25").await; + assert_eq!(response.status(), 200); + + let response = post_init(&client, &url, Some("2025-11-25"), "2025-11-25").await; + assert_eq!(response.status(), 200); + + let response = post_init(&client, &url, Some("2025-03-26"), "2025-11-25").await; + assert_eq!(response.status(), 400); + let payload: serde_json::Value = response.json().await?; + assert_eq!(payload["error"]["code"], -32600); + + ct.cancel(); + Ok(()) +} + +// `notifications/initialized` returns HTTP 202 (Accepted) without +// requiring any protocol metadata, even under the seam. +#[tokio::test] +async fn seam_opt_in_notifications_return_202() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server(modern_required_config()).await; + + let body = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#; + let response = post_seam(&client, &url, body, None, &[]).await; + assert_eq!( + response.status(), + reqwest::StatusCode::ACCEPTED, + "notifications must surface HTTP 202 regardless of metadata" + ); + + ct.cancel(); + Ok(()) +} + +// `server/discover` is still subject to the new HTTP-header precheck. +#[tokio::test] +async fn seam_opt_in_discover_rejects_missing_header() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server(modern_required_config()).await; + + let with_meta = r#"{"jsonrpc":"2.0","id":8,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2025-11-25","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}"#; + let without_meta = r#"{"jsonrpc":"2.0","id":9,"method":"server/discover","params":{}}"#; + for body in [with_meta, without_meta] { + let response = post_seam( + &client, + &url, + body, + None, + &[("Mcp-Method", "server/discover")], + ) + .await; + assert_eq!(response.status(), 400); + let payload: serde_json::Value = response.json().await?; + assert_eq!(payload["error"]["code"], -32020); + } + + let response = post_seam( + &client, + &url, + without_meta, + Some("2025-11-25"), + &[("Mcp-Method", "server/discover")], + ) + .await; + assert_eq!(response.status(), 400); + let payload: serde_json::Value = response.json().await?; + assert_eq!(payload["error"]["code"], -32602); + + let response = post_seam( + &client, + &url, + with_meta, + Some("2025-11-25"), + &[("Mcp-Method", "server/discover")], + ) + .await; + assert_eq!(response.status(), 200); + + ct.cancel(); + Ok(()) +} diff --git a/vendor/rmcp/tests/test_streamable_http_session_store.rs b/vendor/rmcp/tests/test_streamable_http_session_store.rs new file mode 100644 index 000000000..0c8f6a835 --- /dev/null +++ b/vendor/rmcp/tests/test_streamable_http_session_store.rs @@ -0,0 +1,398 @@ +#![cfg(all( + feature = "client", + feature = "server", + feature = "transport-streamable-http-client-reqwest", + feature = "transport-streamable-http-server", + not(feature = "local") +))] + +use std::{collections::HashMap, sync::Arc}; + +use rmcp::{ + ServiceExt, + transport::{ + StreamableHttpClientTransport, + streamable_http_client::StreamableHttpClientTransportConfig, + streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, + session::{SessionState, SessionStore, SessionStoreError, local::LocalSessionManager}, + }, + }, +}; +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; + +mod common; +use common::calculator::Calculator; + +// --------------------------------------------------------------------------- +// Shared in-memory store used across tests +// --------------------------------------------------------------------------- + +#[derive(Default, Clone)] +struct InMemorySessionStore(Arc>>); + +impl InMemorySessionStore { + fn new() -> Self { + Self::default() + } + + async fn len(&self) -> usize { + self.0.read().await.len() + } +} + +#[async_trait::async_trait] +impl SessionStore for InMemorySessionStore { + async fn load(&self, session_id: &str) -> Result, SessionStoreError> { + Ok(self.0.read().await.get(session_id).cloned()) + } + + async fn store(&self, session_id: &str, state: &SessionState) -> Result<(), SessionStoreError> { + self.0 + .write() + .await + .insert(session_id.to_owned(), state.clone()); + Ok(()) + } + + async fn delete(&self, session_id: &str) -> Result<(), SessionStoreError> { + self.0.write().await.remove(session_id); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Helper: spin up a StreamableHttpService backed by the given store and +// return the bound address together with the cancellation token. +// --------------------------------------------------------------------------- + +fn make_service( + session_store: Arc, + ct: &CancellationToken, +) -> StreamableHttpService { + StreamableHttpService::new(|| Ok(Calculator::new()), Default::default(), { + let mut cfg = StreamableHttpServerConfig::default(); + cfg.legacy_session_mode = true; + cfg.sse_keep_alive = None; + cfg.cancellation_token = ct.child_token(); + cfg.session_store = Some(session_store); + cfg + }) +} + +// --------------------------------------------------------------------------- +// Test 1 — state is persisted to the store after a successful handshake +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_session_state_persisted_to_store() -> anyhow::Result<()> { + let store = Arc::new(InMemorySessionStore::new()); + let ct = CancellationToken::new(); + let service = make_service(store.clone(), &ct); + + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + + let handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + // Connect a full client — this performs the initialize + initialized handshake. + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")), + ); + let client = ().serve(transport).await?; + + // Make a real request so the session is fully active. + let _resources = client.list_all_resources().await?; + + // The store should now contain exactly one session entry. + assert_eq!( + store.len().await, + 1, + "session state should be persisted to the store after initialization" + ); + + // Verify the stored state contains the expected client info. + let entries = store.0.read().await; + let state = entries.values().next().expect("store entry should exist"); + assert_eq!( + state.initialize_params.client_info.name, "rmcp", + "stored client_info.name should match the rmcp client" + ); + + let _ = client.cancel().await; + ct.cancel(); + handle.await?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Test 2 — store entry is removed when the client sends HTTP DELETE +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_session_state_deleted_from_store_on_delete() -> anyhow::Result<()> { + let store = Arc::new(InMemorySessionStore::new()); + let session_manager = Arc::new(LocalSessionManager::default()); + let ct = CancellationToken::new(); + + let service = StreamableHttpService::new(|| Ok(Calculator::new()), session_manager.clone(), { + let mut cfg = StreamableHttpServerConfig::default(); + cfg.legacy_session_mode = true; + cfg.sse_keep_alive = None; + cfg.cancellation_token = ct.child_token(); + cfg.session_store = Some(store.clone()); + cfg + }); + + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + + let handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")), + ); + let client = ().serve(transport).await?; + let _resources = client.list_all_resources().await?; + + assert_eq!(store.len().await, 1, "store should have one entry"); + + // Get the session ID from the server's in-memory map. + let session_id = { + let sessions = session_manager.sessions.read().await; + sessions + .keys() + .next() + .cloned() + .expect("session should exist") + }; + + // Send an explicit HTTP DELETE — this is the signal to remove from store. + let http_client = reqwest::Client::new(); + let response = http_client + .delete(format!("http://{addr}/mcp")) + .header("mcp-session-id", session_id.as_ref()) + .send() + .await?; + assert_eq!(response.status(), 202); + + assert_eq!( + store.len().await, + 0, + "store entry should be removed after explicit DELETE" + ); + + let _ = client.cancel().await; + ct.cancel(); + handle.await?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Helper: spin up a server on an ephemeral port and return its address and +// the join handle. The server shuts down when `ct` is cancelled. +// --------------------------------------------------------------------------- + +fn spawn_server( + session_store: Option>, + session_manager: Arc, + ct: &CancellationToken, +) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) { + let svc = StreamableHttpService::new(|| Ok(Calculator::new()), session_manager, { + let mut cfg = StreamableHttpServerConfig::default(); + cfg.legacy_session_mode = true; + cfg.sse_keep_alive = None; + cfg.cancellation_token = ct.child_token(); + cfg.session_store = session_store; + cfg + }); + // Use std::net::TcpListener so the port is bound synchronously before + // we return — avoids a race between returning the addr and the server + // actually starting to accept connections. + let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + std_listener.set_nonblocking(true).unwrap(); + let addr = std_listener.local_addr().unwrap(); + let listener = tokio::net::TcpListener::from_std(std_listener).unwrap(); + let router = axum::Router::new().nest_service("/mcp", svc); + let handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + (addr, handle) +} + +// --------------------------------------------------------------------------- +// Test 3 — cross-instance session restore +// +// Both halves follow the same structure: +// +// Instance A initializes the session (session state may be saved to store) +// Instance A is fully shut down +// Instance B (fresh, no in-memory state) receives a request for the old ID +// +// Without a store → 404. With a shared store → transparent restore. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_cross_instance_session_restore() -> anyhow::Result<()> { + let http = reqwest::Client::new(); + + // ----------------------------------------------------------------------- + // Negative check: no session store → instance B returns 404. + // ----------------------------------------------------------------------- + { + // --- Instance A (no store): initialize --- + let ct_a = CancellationToken::new(); + let (addr_a, srv_a) = spawn_server(None, Arc::new(LocalSessionManager::default()), &ct_a); + + let init_resp = http + .post(format!("http://{addr_a}/mcp")) + .header("accept", "application/json, text/event-stream") + .header("content-type", "application/json") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}"#) + .send() + .await?; + assert_eq!( + init_resp.status(), + 200, + "instance A: initialize should succeed" + ); + let session_id = init_resp + .headers() + .get("mcp-session-id") + .expect("session ID header must be present") + .to_str()? + .to_owned(); + + // Shut down instance A completely. + ct_a.cancel(); + srv_a.await?; + + // --- Instance B (no store, fresh state): send request --- + let ct_b = CancellationToken::new(); + let (addr_b, srv_b) = spawn_server(None, Arc::new(LocalSessionManager::default()), &ct_b); + + let resp = http + .post(format!("http://{addr_b}/mcp")) + .header("accept", "application/json, text/event-stream") + .header("content-type", "application/json") + .header("mcp-session-id", &session_id) + .body(r#"{"jsonrpc":"2.0","id":2,"method":"ping","params":{}}"#) + .send() + .await?; + assert_eq!( + resp.status(), + reqwest::StatusCode::NOT_FOUND, + "without a session store, instance B must return 404 for an unknown session ID" + ); + + ct_b.cancel(); + srv_b.await?; + } + + // ----------------------------------------------------------------------- + // Positive check: shared session store → instance B restores transparently. + // ----------------------------------------------------------------------- + { + let store: Arc = Arc::new(InMemorySessionStore::new()); + + // --- Instance A (with store): initialize --- + let ct_a = CancellationToken::new(); + let sm_a = Arc::new(LocalSessionManager::default()); + let (addr_a, srv_a) = spawn_server(Some(store.clone()), sm_a.clone(), &ct_a); + + let init_resp = http + .post(format!("http://{addr_a}/mcp")) + .header("accept", "application/json, text/event-stream") + .header("content-type", "application/json") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}"#) + .send() + .await?; + assert_eq!( + init_resp.status(), + 200, + "instance A: initialize should succeed" + ); + let original_session_id = init_resp + .headers() + .get("mcp-session-id") + .expect("session ID header must be present") + .to_str()? + .to_owned(); + + // Confirm the session was persisted. + let store_ref = store + .load(&original_session_id) + .await + .expect("store load should not error"); + assert!( + store_ref.is_some(), + "store should hold the session after initialization" + ); + + // Shut down instance A completely — session lives only in the store now. + ct_a.cancel(); + srv_a.await?; + + // --- Instance B (same store, fresh in-memory state): send request --- + let ct_b = CancellationToken::new(); + let sm_b = Arc::new(LocalSessionManager::default()); + let (addr_b, srv_b) = spawn_server(Some(store.clone()), sm_b.clone(), &ct_b); + + let resp = http + .post(format!("http://{addr_b}/mcp")) + .header("accept", "application/json, text/event-stream") + .header("content-type", "application/json") + .header("mcp-session-id", &original_session_id) + .body(r#"{"jsonrpc":"2.0","id":2,"method":"ping","params":{}}"#) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "instance B: request must succeed after transparent restore" + ); + + // The session must be in instance B's memory under the ORIGINAL ID. + { + let sessions = sm_b.sessions.read().await; + let restored_id = sessions + .keys() + .next() + .expect("session should exist in instance B after restore"); + assert_eq!( + restored_id.as_ref(), + original_session_id.as_str(), + "restored session must keep the original session ID" + ); + } + + ct_b.cancel(); + srv_b.await?; + } + + Ok(()) +} diff --git a/vendor/rmcp/tests/test_streamable_http_stale_session.rs b/vendor/rmcp/tests/test_streamable_http_stale_session.rs new file mode 100644 index 000000000..be1ac0269 --- /dev/null +++ b/vendor/rmcp/tests/test_streamable_http_stale_session.rs @@ -0,0 +1,464 @@ +#![cfg(all( + feature = "transport-streamable-http-client", + feature = "transport-streamable-http-client-reqwest", + feature = "transport-streamable-http-server", + not(feature = "local") +))] + +use std::{ + collections::{HashMap, VecDeque}, + sync::Arc, +}; + +use futures::stream; +use http::{HeaderName, HeaderValue}; +use rmcp::{ + ServiceError, ServiceExt, + model::{ + CallToolRequestParams, ClientInfo, ClientJsonRpcMessage, ClientRequest, ErrorCode, + ErrorData, InitializeResult, PingRequest, ProtocolVersion, RequestId, ServerCapabilities, + ServerJsonRpcMessage, ServerResult, + }, + transport::{ + StreamableHttpClientTransport, + streamable_http_client::{ + StreamableHttpClient, StreamableHttpClientTransportConfig, StreamableHttpError, + StreamableHttpPostResponse, + }, + streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, + }, +}; +use tokio_util::sync::CancellationToken; + +mod common; +use common::calculator::Calculator; + +#[derive(Debug, thiserror::Error)] +#[error("mock streamable http client error")] +struct MockClientError; + +#[derive(Clone)] +struct ReinitDropsAcceptedResponseClient { + state: Arc>, + stale_stream_cancelled: CancellationToken, + initial_request_accepted: Arc, + final_retry_accepted: Arc, +} + +struct MockState { + session_counter: usize, + posts: VecDeque, +} + +enum MockPost { + Initialize, + Initialized, + Accepted, + SessionExpired, +} + +impl ReinitDropsAcceptedResponseClient { + fn new() -> Self { + Self { + state: Arc::new(tokio::sync::Mutex::new(MockState { + session_counter: 0, + posts: VecDeque::from([ + MockPost::Initialize, + MockPost::Initialized, + MockPost::Accepted, + MockPost::SessionExpired, + MockPost::Initialize, + MockPost::Initialized, + MockPost::Accepted, + ]), + })), + stale_stream_cancelled: CancellationToken::new(), + initial_request_accepted: Arc::new(tokio::sync::Semaphore::new(0)), + final_retry_accepted: Arc::new(tokio::sync::Semaphore::new(0)), + } + } +} + +impl StreamableHttpClient for ReinitDropsAcceptedResponseClient { + type Error = MockClientError; + + async fn post_message( + &self, + _uri: Arc, + message: ClientJsonRpcMessage, + _session_id: Option>, + _auth_header: Option, + custom_headers: HashMap, + ) -> Result> { + let mut state = self.state.lock().await; + match state + .posts + .pop_front() + .expect("unexpected mock post_message call") + { + MockPost::Initialize => { + state.session_counter += 1; + let protocol_version = if state.session_counter == 1 { + ProtocolVersion::V_2025_11_25 + } else { + ProtocolVersion::V_2025_06_18 + }; + let id = match message { + ClientJsonRpcMessage::Request(request) => request.id, + other => panic!("expected initialize request, got {other:?}"), + }; + Ok(StreamableHttpPostResponse::Json( + ServerJsonRpcMessage::response( + ServerResult::InitializeResult( + InitializeResult::new( + ServerCapabilities::builder().enable_tools().build(), + ) + .with_protocol_version(protocol_version), + ), + id, + ), + Some(format!("session-{}", state.session_counter)), + )) + } + MockPost::Initialized => { + assert!( + matches!(message, ClientJsonRpcMessage::Notification(_)), + "expected initialized notification, got {message:?}" + ); + Ok(StreamableHttpPostResponse::Accepted) + } + MockPost::Accepted => { + if state.posts.is_empty() { + assert_eq!( + custom_headers + .get(&HeaderName::from_static("mcp-protocol-version")) + .and_then(|value| value.to_str().ok()), + Some(ProtocolVersion::V_2025_06_18.as_str()) + ); + self.final_retry_accepted.add_permits(1); + } else { + self.initial_request_accepted.add_permits(1); + } + Ok(StreamableHttpPostResponse::Accepted) + } + MockPost::SessionExpired => Err(StreamableHttpError::SessionExpired), + } + } + + async fn delete_session( + &self, + _uri: Arc, + _session_id: Arc, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result<(), StreamableHttpError> { + Ok(()) + } + + async fn get_stream( + &self, + _uri: Arc, + session_id: Option>, + _last_event_id: Option, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result< + futures::stream::BoxStream<'static, Result>, + StreamableHttpError, + > { + if session_id.as_deref() == Some("session-1") { + let cancel = self.stale_stream_cancelled.clone(); + Ok(Box::pin(stream::once(async move { + cancel.cancelled_owned().await; + Ok(sse_stream::Sse { + event: None, + data: Some( + serde_json::to_string(&ServerJsonRpcMessage::error( + ErrorData::new( + ErrorCode::INTERNAL_ERROR, + "stale stream should not deliver after re-init", + None, + ), + Some(RequestId::Number(2)), + )) + .expect("serialize stale error"), + ), + id: None, + retry: None, + }) + }))) + } else { + Ok(Box::pin(stream::pending())) + } + } +} + +#[tokio::test] +async fn test_reinitialization_completes_accepted_sse_request_instead_of_hanging() +-> anyhow::Result<()> { + let mock_client = ReinitDropsAcceptedResponseClient::new(); + let initial_request_accepted = mock_client.initial_request_accepted.clone(); + let final_retry_accepted = mock_client.final_retry_accepted.clone(); + let transport = StreamableHttpClientTransport::with_client( + mock_client, + StreamableHttpClientTransportConfig::with_uri("mock://mcp"), + ); + let mut client = ClientInfo::default().serve(transport).await?; + + let peer = client.peer().clone(); + let pending_call = tokio::spawn(async move { + peer.call_tool(CallToolRequestParams::new("slow_tool")) + .await + }); + + let _initial_permit = tokio::time::timeout( + std::time::Duration::from_secs(1), + initial_request_accepted.acquire(), + ) + .await + .expect("initial accepted request should be observed") + .expect("initial accepted request semaphore should stay open"); + + let reinit_trigger = { + let peer = client.peer().clone(); + tokio::spawn(async move { peer.list_tools(None).await }) + }; + + let _retry_permit = tokio::time::timeout( + std::time::Duration::from_secs(1), + final_retry_accepted.acquire(), + ) + .await + .expect("re-initialization retry should be accepted") + .expect("re-initialization retry semaphore should stay open"); + + let err = tokio::time::timeout(std::time::Duration::from_millis(100), pending_call) + .await + .expect("accepted SSE-backed request should complete instead of hanging")? + .expect_err( + "accepted request should fail after re-initialization drops its response stream", + ); + + match err { + ServiceError::McpError(error) => { + assert_eq!(error.code, ErrorCode::INTERNAL_ERROR); + assert!( + error.message.contains("session"), + "expected session-related error, got: {error}" + ); + } + other => panic!("expected McpError for orphaned request, got: {other:?}"), + } + + reinit_trigger.abort(); + let _ = client.close().await; + + Ok(()) +} + +#[tokio::test] +async fn test_stale_session_id_returns_status_aware_error() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + let service: StreamableHttpService = + StreamableHttpService::new( + || Ok(Calculator::new()), + Default::default(), + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + + let handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let uri = Arc::::from(format!("http://{addr}/mcp")); + let message = ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ); + + let client = reqwest::Client::new(); + let result = client + .post_message( + uri.clone(), + message, + Some(Arc::from("stale-session-id")), + None, + HashMap::new(), + ) + .await; + + let raw_response = reqwest::Client::new() + .post(uri.as_ref()) + .header("accept", "application/json, text/event-stream") + .header("content-type", "application/json") + .header("mcp-session-id", "stale-session-id") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}"#) + .send() + .await?; + + assert_eq!(raw_response.status(), reqwest::StatusCode::NOT_FOUND); + match result { + Err(StreamableHttpError::SessionExpired) => { + // Expected: post_message detects 404 with a session ID and returns SessionExpired + } + other => panic!("expected SessionExpired, got: {other:?}"), + } + + ct.cancel(); + handle.await?; + + Ok(()) +} + +/// Verify that when the server loses a session (returns HTTP 404), the client +/// transparently re-initializes and the original request succeeds. +#[tokio::test] +async fn test_transparent_reinitialization_on_session_expiry() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + let session_manager = Arc::new(LocalSessionManager::default()); + + let service = StreamableHttpService::new( + || Ok(Calculator::new()), + session_manager.clone(), + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + + let server_handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + // Connect a full client transport (this performs initialize + notifications/initialized) + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")) + .reinit_on_expired_session(true), + ); + let client = ().serve(transport).await?; + + // Verify the session is established: list_all_resources() succeeds + let _resources = client.list_all_resources().await?; + + // Capture the current session ID from the server + let original_session_id = { + let sessions = session_manager.sessions.read().await; + sessions + .keys() + .next() + .cloned() + .expect("session should exist") + }; + + // Force session expiry by removing all sessions from the server-side manager + { + let mut sessions = session_manager.sessions.write().await; + sessions.clear(); + } + + // This call should trigger transparent re-initialization and still succeed + let _resources_after = client.list_all_resources().await?; + + // Verify the server created a new session with a different ID + { + let sessions = session_manager.sessions.read().await; + let new_session_id = sessions + .keys() + .next() + .expect("new session should exist after re-initialization"); + assert_ne!( + new_session_id, &original_session_id, + "new session ID should differ from the original" + ); + } + + let _ = client.cancel().await; + ct.cancel(); + server_handle.await?; + + Ok(()) +} + +/// Verify that when `reinit_on_expired_session` is false and the server loses the session, +/// the client receives a `SessionExpired` transport error instead of retrying. +#[tokio::test] +async fn test_session_expired_error_when_reinit_disabled() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + let session_manager = Arc::new(LocalSessionManager::default()); + + let service = StreamableHttpService::new( + || Ok(Calculator::new()), + session_manager.clone(), + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + + let server_handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")) + .reinit_on_expired_session(false), + ); + let client = ().serve(transport).await?; + + // Verify the session is established + let _resources = client.list_all_resources().await?; + + // Force session expiry by removing all sessions from the server-side manager + { + let mut sessions = session_manager.sessions.write().await; + sessions.clear(); + } + + // This call should fail with a SessionExpired transport error + let result = client.list_all_resources().await; + match result { + Err(ServiceError::TransportSend(ref dyn_err)) => { + let err_msg = format!("{dyn_err}"); + assert!( + err_msg.contains("Session expired"), + "expected 'Session expired' in error message, got: {err_msg}" + ); + } + other => panic!("expected TransportSend(SessionExpired), got: {other:?}"), + } + + let _ = client.cancel().await; + ct.cancel(); + server_handle.await?; + + Ok(()) +} diff --git a/vendor/rmcp/tests/test_streamable_http_standard_headers.rs b/vendor/rmcp/tests/test_streamable_http_standard_headers.rs new file mode 100644 index 000000000..510433e70 --- /dev/null +++ b/vendor/rmcp/tests/test_streamable_http_standard_headers.rs @@ -0,0 +1,315 @@ +#![cfg(not(feature = "local"))] +//! SEP-2243 server-side validation of `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*` headers. +use std::sync::Arc; + +use rmcp::{ + ServerHandler, + model::{ServerCapabilities, ServerInfo, Tool}, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, +}; +use tokio_util::sync::CancellationToken; + +const SEP_VERSION: &str = "2026-07-28"; + +/// Server exposing one tool whose `region` argument is promoted to `Mcp-Param-Region`. +#[derive(Clone, Default)] +struct HeaderValidationServer; + +impl ServerHandler for HeaderValidationServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } + + fn get_tool(&self, name: &str) -> Option { + if name != "deploy" { + return None; + } + let schema = serde_json::json!({ + "type": "object", + "properties": { "region": { "type": "string", "x-mcp-header": "Region" } } + }); + let schema = schema.as_object().expect("object schema").clone(); + Some(Tool::new("deploy", "deploy a thing", Arc::new(schema))) + } +} + +async fn spawn_server() -> (reqwest::Client, String, CancellationToken) { + let config = StreamableHttpServerConfig::default() + .with_legacy_session_mode(false) + .with_json_response(true) + .with_sse_keep_alive(None) + .with_cancellation_token(CancellationToken::new()); + let ct = config.cancellation_token.clone(); + let service: StreamableHttpService = + StreamableHttpService::new(|| Ok(HeaderValidationServer), Default::default(), config); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = tcp_listener.local_addr().unwrap(); + tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + (reqwest::Client::new(), format!("http://{addr}/mcp"), ct) +} + +/// POSTs a `tools/call` with the given protocol-version and optional SEP-2243 headers. +async fn post_tool_call( + client: &reqwest::Client, + url: &str, + version: &str, + tool_name: &str, + arguments: serde_json::Value, + mcp_method: Option<&str>, + mcp_name: Option<&str>, + param_region: Option<&str>, +) -> reqwest::Response { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments, + } + }); + let mut req = client + .post(url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", version) + .body(body.to_string()); + if let Some(method) = mcp_method { + req = req.header("Mcp-Method", method); + } + if let Some(name) = mcp_name { + req = req.header("Mcp-Name", name); + } + if let Some(region) = param_region { + req = req.header("Mcp-Param-Region", region); + } + req.send().await.expect("send tools/call request") +} + +#[tokio::test] +async fn accepts_matching_standard_headers() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server().await; + + // Matching headers pass validation and reach dispatch. (Stateless mode without a + // prior initialize yields an unrelated -32601, which still proves -32020 was not raised.) + let response = post_tool_call( + &client, + &url, + SEP_VERSION, + "sum", + serde_json::json!({ "a": 1, "b": 2 }), + Some("tools/call"), + Some("sum"), + None, + ) + .await; + let body: serde_json::Value = response.json().await?; + assert_ne!( + body["error"]["code"], -32020, + "matching headers must not be rejected as a header mismatch, got: {body}" + ); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn rejects_method_mismatch_with_32020() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server().await; + + let response = post_tool_call( + &client, + &url, + SEP_VERSION, + "sum", + serde_json::json!({ "a": 1, "b": 2 }), + Some("tools/list"), + Some("sum"), + None, + ) + .await; + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await?; + assert_eq!(body["error"]["code"], -32020); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn rejects_missing_method_header_with_32020() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server().await; + + let response = post_tool_call( + &client, + &url, + SEP_VERSION, + "sum", + serde_json::json!({ "a": 1, "b": 2 }), + None, + Some("sum"), + None, + ) + .await; + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await?; + assert_eq!(body["error"]["code"], -32020); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn rejects_name_mismatch_with_32020() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server().await; + + let response = post_tool_call( + &client, + &url, + SEP_VERSION, + "sum", + serde_json::json!({ "a": 1, "b": 2 }), + Some("tools/call"), + Some("product"), + None, + ) + .await; + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await?; + assert_eq!(body["error"]["code"], -32020); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn skips_validation_for_pre_sep_version() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server().await; + + // Older version: headers are not enforced even when absent. + let response = post_tool_call( + &client, + &url, + "2025-11-25", + "sum", + serde_json::json!({ "a": 1, "b": 2 }), + None, + None, + None, + ) + .await; + let body: serde_json::Value = response.json().await?; + assert_ne!( + body["error"]["code"], -32020, + "pre-SEP versions must skip header validation, got: {body}" + ); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn accepts_matching_param_header() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server().await; + + let response = post_tool_call( + &client, + &url, + SEP_VERSION, + "deploy", + serde_json::json!({ "region": "us-west1" }), + Some("tools/call"), + Some("deploy"), + Some("us-west1"), + ) + .await; + let body: serde_json::Value = response.json().await?; + assert_ne!( + body["error"]["code"], -32020, + "matching Mcp-Param-* must not be rejected, got: {body}" + ); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn rejects_param_mismatch_with_32020() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server().await; + + let response = post_tool_call( + &client, + &url, + SEP_VERSION, + "deploy", + serde_json::json!({ "region": "us-west1" }), + Some("tools/call"), + Some("deploy"), + Some("eu-central1"), + ) + .await; + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await?; + assert_eq!(body["error"]["code"], -32020); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn rejects_decoded_base64_param_mismatch_with_32020() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server().await; + + let response = post_tool_call( + &client, + &url, + SEP_VERSION, + "deploy", + serde_json::json!({ "region": "us-west1" }), + Some("tools/call"), + Some("deploy"), + Some("=?base64?ZXUtY2VudHJhbDE=?="), + ) + .await; + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await?; + assert_eq!(body["error"]["code"], -32020); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn rejects_missing_param_header_with_32020() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server().await; + + // `region` argument is present but the annotated `Mcp-Param-Region` header is absent. + let response = post_tool_call( + &client, + &url, + SEP_VERSION, + "deploy", + serde_json::json!({ "region": "us-west1" }), + Some("tools/call"), + Some("deploy"), + None, + ) + .await; + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await?; + assert_eq!(body["error"]["code"], -32020); + + ct.cancel(); + Ok(()) +} diff --git a/vendor/rmcp/tests/test_structured_output.rs b/vendor/rmcp/tests/test_structured_output.rs new file mode 100644 index 000000000..f3416e519 --- /dev/null +++ b/vendor/rmcp/tests/test_structured_output.rs @@ -0,0 +1,422 @@ +#![allow(clippy::exhaustive_structs)] +//cargo test --test test_structured_output --features "client server macros" +use rmcp::{ + Json, ServerHandler, + handler::server::{router::tool::ToolRouter, tool::IntoCallToolResult, wrapper::Parameters}, + model::{CallToolResponse, CallToolResult, ContentBlock, ServerResult, Tool}, + tool, tool_handler, tool_router, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct CalculationRequest { + pub a: i32, + pub b: i32, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct CalculationResult { + pub sum: i32, + pub product: i32, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct UserInfo { + pub name: String, + pub age: u32, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct GreetingRequest { + pub name: String, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct GetUserRequest { + pub user_id: String, +} + +#[tool_handler(router = self.tool_router)] +impl ServerHandler for TestServer {} + +#[derive(Debug, Clone)] +pub struct TestServer { + tool_router: ToolRouter, +} + +impl Default for TestServer { + fn default() -> Self { + Self::new() + } +} + +#[tool_router(router = tool_router)] +impl TestServer { + pub fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } + + /// Tool that returns structured output + #[tool(name = "calculate", description = "Perform calculations")] + pub async fn calculate( + &self, + params: Parameters, + ) -> Result, String> { + Ok(Json(CalculationResult { + sum: params.0.a + params.0.b, + product: params.0.a * params.0.b, + })) + } + + /// Tool that returns regular string output + #[tool(name = "get-greeting", description = "Get a greeting")] + pub async fn get_greeting(&self, params: Parameters) -> String { + format!("Hello, {}!", params.0.name) + } + + /// Tool that returns structured user info + #[tool(name = "get-user", description = "Get user info")] + pub async fn get_user( + &self, + params: Parameters, + ) -> Result, String> { + if params.0.user_id == "123" { + Ok(Json(UserInfo { + name: "Alice".to_string(), + age: 30, + })) + } else { + Err("User not found".to_string()) + } + } + + /// Tool that returns a list of calculation results + #[tool( + name = "calculate-list", + description = "Return a list of calculation results" + )] + pub async fn calculate_list( + &self, + params: Parameters, + ) -> Result>, String> { + Ok(Json(vec![CalculationResult { + sum: params.0.a + params.0.b, + product: params.0.a * params.0.b, + }])) + } + + /// Tool that returns a count + #[tool(name = "get-count", description = "Return a count")] + pub async fn get_count(&self) -> Result, String> { + Ok(Json(42)) + } +} + +#[tokio::test] +async fn test_tool_with_output_schema() { + let server = TestServer::new(); + let tools = server.tool_router.list_all(); + + // Find the calculate tool + let calculate_tool = tools.iter().find(|t| t.name == "calculate").unwrap(); + + // Verify it has an output schema + assert!(calculate_tool.output_schema.is_some()); + + let schema = calculate_tool.output_schema.as_ref().unwrap(); + + // Check that the schema contains expected fields + let schema_str = serde_json::to_string(schema).unwrap(); + assert!(schema_str.contains("sum")); + assert!(schema_str.contains("product")); +} + +#[tokio::test] +async fn test_tool_without_output_schema() { + let server = TestServer::new(); + let tools = server.tool_router.list_all(); + + // Find the get-greeting tool + let greeting_tool = tools.iter().find(|t| t.name == "get-greeting").unwrap(); + + // Verify it doesn't have an output schema (returns String) + assert!(greeting_tool.output_schema.is_none()); +} + +#[tokio::test] +async fn test_structured_content_in_call_result() { + // Test creating a CallToolResult with structured content + let structured_data = json!({ + "sum": 7, + "product": 12 + }); + + let result = CallToolResult::structured(structured_data.clone()); + + assert!(!result.content.is_empty()); + assert!(result.structured_content.is_some()); + + let contents = result.content; + + assert_eq!(contents.len(), 1); + + let content_text = contents.first().unwrap().as_text(); + + assert!(content_text.is_some()); + + let content_value: Value = serde_json::from_str(&content_text.unwrap().text).unwrap(); + + assert_eq!(content_value, structured_data); + assert_eq!(result.structured_content.unwrap(), structured_data); + assert_eq!(result.is_error, Some(false)); +} + +#[tokio::test] +async fn test_structured_error_in_call_result() { + // Test creating a CallToolResult with structured error + let error_data = json!({ + "error_code": "NOT_FOUND", + "message": "User not found" + }); + + let result = CallToolResult::structured_error(error_data.clone()); + + assert!(!result.content.is_empty()); + assert!(result.structured_content.is_some()); + + let contents = result.content; + + assert_eq!(contents.len(), 1); + + let content_text = contents.first().unwrap().as_text(); + + assert!(content_text.is_some()); + + let content_value: Value = serde_json::from_str(&content_text.unwrap().text).unwrap(); + + assert_eq!(content_value, error_data); + assert_eq!(result.structured_content.unwrap(), error_data); + assert_eq!(result.is_error, Some(true)); +} + +#[tokio::test] +async fn test_mutual_exclusivity_validation() { + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] + pub struct Response { + message: String, + } + let response = Response { + message: "Hello".into(), + }; + // Test that content and structured_content can both be passed separately + let content_result = + CallToolResult::success(vec![ContentBlock::json(response.clone()).unwrap()]); + let structured_result = CallToolResult::structured(json!({"message": "Hello"})); + + // Verify the validation + content_result + .into_typed::() + .expect("Failed to extract content"); + structured_result + .into_typed::() + .expect("Failed to extract content"); + + // Try to create a result with both fields + let json_with_both = json!({ + "content": [{"type": "text", "text": "Hello"}], + "structuredContent": {"message": "Hello"} + }); + + // The deserialization itself should not fail + let deserialized: Result = serde_json::from_value(json_with_both); + assert!(deserialized.is_ok()); +} + +#[tokio::test] +async fn test_structured_return_conversion() { + // Test that Json converts to CallToolResult with structured_content + let calc_result = CalculationResult { + sum: 7, + product: 12, + }; + + let structured = Json(calc_result); + let result: Result = + rmcp::handler::server::tool::IntoCallToolResult::into_call_tool_result(structured); + + assert!(result.is_ok()); + let CallToolResponse::Complete(call_result) = result.unwrap() else { + panic!("expected complete CallToolResult"); + }; + + // Tools which return structured content should also return a serialized version as + // Content::text for backwards compatibility. + assert!(!call_result.content.is_empty()); + assert!(call_result.structured_content.is_some()); + + let contents = call_result.content; + + assert_eq!(contents.len(), 1); + + let content_text = contents.first().unwrap().as_text(); + + assert!(content_text.is_some()); + + let content_value: Value = serde_json::from_str(&content_text.unwrap().text).unwrap(); + let structured_value = call_result.structured_content.unwrap(); + + assert_eq!(content_value, structured_value); + + assert_eq!(structured_value["sum"], 7); + assert_eq!(structured_value["product"], 12); +} + +#[tokio::test] +async fn test_tool_serialization_with_output_schema() { + let server = TestServer::new(); + let tools = server.tool_router.list_all(); + + let calculate_tool = tools.iter().find(|t| t.name == "calculate").unwrap(); + + // Serialize the tool + let serialized = serde_json::to_value(calculate_tool).unwrap(); + + // Check that outputSchema is included + assert!(serialized["outputSchema"].is_object()); + + // Deserialize back + let deserialized: Tool = serde_json::from_value(serialized).unwrap(); + assert!(deserialized.output_schema.is_some()); +} + +#[tokio::test] +async fn test_output_schema_requires_structured_content() { + // Test that tools with output_schema must use structured_content + let server = TestServer::new(); + let tools = server.tool_router.list_all(); + + // The calculate tool should have output_schema + let calculate_tool = tools.iter().find(|t| t.name == "calculate").unwrap(); + assert!(calculate_tool.output_schema.is_some()); + + // Directly call the tool and verify its result structure + let params = Parameters(CalculationRequest { a: 5, b: 3 }); + let result = server.calculate(params).await.unwrap(); + + // Convert the Json to CallToolResult + let call_result: Result = + IntoCallToolResult::into_call_tool_result(result); + + assert!(call_result.is_ok()); + let CallToolResponse::Complete(call_result) = call_result.unwrap() else { + panic!("expected complete CallToolResult"); + }; + + // Verify it has structured_content and content + assert!(call_result.structured_content.is_some()); + assert!(!call_result.content.is_empty()); +} + +#[tokio::test] +async fn test_empty_content_array_deserializes() { + let raw = json!({ "content": [] }); + let result: CallToolResult = serde_json::from_value(raw).unwrap(); + assert!(result.content.is_empty()); + assert!(result.structured_content.is_none()); + assert!(result.is_error.is_none()); +} + +#[tokio::test] +async fn test_empty_content_array_with_is_error() { + let raw = json!({ "content": [], "isError": false }); + let result: CallToolResult = serde_json::from_value(raw).unwrap(); + assert!(result.content.is_empty()); + assert_eq!(result.is_error, Some(false)); +} + +#[test] +fn test_missing_content_defaults_to_empty() { + let raw = json!({ "isError": false }); + let result: CallToolResult = serde_json::from_value(raw).unwrap(); + assert!(result.content.is_empty()); + assert_eq!(result.is_error, Some(false)); +} + +#[test] +fn test_missing_content_with_structured_content_deserializes() { + let raw = json!({ "structuredContent": {"key": "value"}, "isError": false }); + let result: CallToolResult = serde_json::from_value(raw).unwrap(); + assert!(result.content.is_empty()); + assert_eq!(result.structured_content.unwrap()["key"], "value"); +} + +#[tokio::test] +async fn test_empty_content_deserializes_as_call_tool_result_variant() { + let raw = json!({ "content": [] }); + let result: ServerResult = serde_json::from_value(raw).unwrap(); + match result { + ServerResult::CallToolResult(call_result) => { + assert!(call_result.content.is_empty()); + assert!(call_result.structured_content.is_none()); + } + other => panic!("Expected CallToolResult, got {:?}", other), + } +} + +#[tokio::test] +async fn test_empty_content_roundtrip() { + let result = CallToolResult::success(vec![]); + let v = serde_json::to_value(&result).unwrap(); + assert_eq!(v["content"], json!([])); + let deserialized: CallToolResult = serde_json::from_value(v).unwrap(); + assert_eq!(deserialized, result); +} + +#[test] +fn test_call_tool_result_deserialize_without_content() { + let json = json!({ + "structuredContent": {"message": "Hello"} + }); + let result: CallToolResult = serde_json::from_value(json).unwrap(); + assert!(result.content.is_empty()); + assert!(result.structured_content.is_some()); +} + +#[tokio::test] +async fn test_tool_with_array_output_schema() { + let server = TestServer::new(); + let tools = server.tool_router.list_all(); + + // Find the calculate-list tool + let calculate_list_tool = tools.iter().find(|t| t.name == "calculate-list").unwrap(); + + // Verify it has an output schema + assert!(calculate_list_tool.output_schema.is_some()); + + let schema = calculate_list_tool.output_schema.as_ref().unwrap(); + + // Check that the schema contains array type + let schema_str = serde_json::to_string(schema).unwrap(); + assert!(schema_str.contains("array")); +} + +#[tokio::test] +async fn test_tool_with_primitive_output_schema() { + let server = TestServer::new(); + let tools = server.tool_router.list_all(); + + // Find the get-count tool + let get_count_tool = tools.iter().find(|t| t.name == "get-count").unwrap(); + + // Verify it has an output schema + assert!(get_count_tool.output_schema.is_some()); + + let schema = get_count_tool.output_schema.as_ref().unwrap(); + + // Check that the schema contains integer type + assert_eq!(schema.get("type"), Some(&serde_json::json!("integer"))); +} diff --git a/vendor/rmcp/tests/test_subscriptions.rs b/vendor/rmcp/tests/test_subscriptions.rs new file mode 100644 index 000000000..8ceef93cd --- /dev/null +++ b/vendor/rmcp/tests/test_subscriptions.rs @@ -0,0 +1,727 @@ +#![cfg(all( + not(feature = "local"), + feature = "client", + feature = "server", + feature = "transport-io" +))] + +use std::{ + num::NonZeroUsize, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use rmcp::{ + ClientHandler, ClientServiceExt, ServerHandler, ServiceExt, + model::{ + ClientNotification, ClientRequest, DiscoverResult, GetMeta, Implementation, + NotificationMetaObject, PromptListChangedNotification, ProtocolVersion, ServerCapabilities, + ServerInfo, ServerNotification, ServerResult, SubscriptionFilter, + SubscriptionsAcknowledgedNotification, SubscriptionsAcknowledgedNotificationParams, + SubscriptionsListenResult, + }, + service::{ + NotificationContext, RequestContext, RoleClient, RoleServer, SubscriptionContext, + SubscriptionEnd, SubscriptionSendError, SubscriptionSink, + }, +}; +use tokio::sync::{Mutex, Notify}; + +struct ToolsOnlyServer; + +#[derive(Clone)] +struct CountingClient { + tool_changes: Arc, +} + +impl ClientHandler for CountingClient { + async fn on_tool_list_changed(&self, _context: NotificationContext) { + self.tool_changes.fetch_add(1, Ordering::Relaxed); + } +} + +impl ServerHandler for ToolsOnlyServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .build(), + ) + .with_server_info(Implementation::new("tools-only-server", "1.0.0")) + } + + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.supported_by(&self.get_info().capabilities)) + } + + async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { + context + .sink() + .notify_tool_list_changed() + .await + .expect("accepted tool notification"); + assert!(matches!( + context.sink().notify_prompt_list_changed().await, + Err(SubscriptionSendError::NotificationNotAccepted( + "notifications/prompts/list_changed" + )) + )); + Ok(()) + } +} + +struct ToolsAndPromptsServer; + +impl ServerHandler for ToolsAndPromptsServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .enable_prompts() + .enable_prompts_list_changed() + .build(), + ) + } + + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.supported_by(&self.get_info().capabilities)) + } + + async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { + if context.accepted().tools_list_changed == Some(true) { + context + .sink() + .notify_tool_list_changed() + .await + .expect("send tool notification"); + } + if context.accepted().prompts_list_changed == Some(true) { + context + .sink() + .notify_prompt_list_changed() + .await + .expect("send prompt notification"); + } + Ok(()) + } +} + +struct ResourceSubscriptionServer; + +impl ServerHandler for ResourceSubscriptionServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_resources() + .enable_resources_subscribe() + .build(), + ) + } + + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.supported_by(&self.get_info().capabilities)) + } + + async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { + context + .sink() + .notify_resource_updated("file:///accepted") + .await + .expect("accepted URI"); + assert!(matches!( + context + .sink() + .notify_resource_updated("file:///not-requested") + .await, + Err(SubscriptionSendError::NotificationNotAccepted( + "notifications/resources/updated" + )) + )); + Ok(()) + } +} + +struct RemoteCancellationServer; + +impl ServerHandler for RemoteCancellationServer { + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.clone()) + } + + async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { + context + .request_context() + .peer + .notify_cancelled(rmcp::model::CancelledNotificationParam::new( + Some(context.sink().id().clone()), + Some("server shutdown".to_owned()), + )) + .await + .expect("send server cancellation"); + std::future::pending().await + } +} + +struct FloodServer; + +impl ServerHandler for FloodServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .build(), + ) + } + + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.clone()) + } + + async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { + for _ in 0..10 { + if context.sink().notify_tool_list_changed().await.is_err() { + break; + } + } + context.cancelled().await; + Ok(()) + } +} + +#[derive(Clone)] +struct ClosedSinkServer { + sink: Arc>>, +} + +struct LeakyServer; + +impl ServerHandler for LeakyServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .build(), + ) + } + + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.supported_by(&self.get_info().capabilities)) + } + + async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { + let mut notification = + ServerNotification::PromptListChangedNotification(PromptListChangedNotification { + method: Default::default(), + extensions: Default::default(), + }); + notification + .get_meta_mut() + .set_subscription_id(context.sink().id().clone()); + context + .request_context() + .peer + .send_notification(notification) + .await + .expect("send deliberately invalid notification"); + std::future::pending().await + } +} + +struct MalformedAcknowledgmentServer { + cancelled: Arc, +} + +impl rmcp::service::Service for MalformedAcknowledgmentServer { + async fn handle_request( + &self, + request: ClientRequest, + context: RequestContext, + ) -> Result { + match request { + ClientRequest::DiscoverRequest(_) => Ok(ServerResult::DiscoverResult( + DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .enable_prompts() + .enable_prompts_list_changed() + .build(), + ) + .with_server_info(Implementation::new("malformed-ack-server", "1.0.0")), + )), + ClientRequest::SubscriptionsListenRequest(_) => { + let mut acknowledgment = SubscriptionsAcknowledgedNotification::new( + SubscriptionsAcknowledgedNotificationParams::new( + SubscriptionFilter::builder().prompts_list_changed().build(), + ), + ); + let mut meta = NotificationMetaObject::new(); + meta.set_subscription_id(context.id.clone()); + acknowledgment.extensions.insert(meta); + context + .peer + .send_notification(ServerNotification::SubscriptionsAcknowledgedNotification( + acknowledgment, + )) + .await + .map_err(|error| rmcp::ErrorData::internal_error(error.to_string(), None))?; + context.ct.cancelled().await; + Ok(ServerResult::SubscriptionsListenResult( + SubscriptionsListenResult::complete(context.id), + )) + } + _ => Err(rmcp::ErrorData::invalid_request( + "unexpected test request", + None, + )), + } + } + + async fn handle_notification( + &self, + notification: ClientNotification, + _context: NotificationContext, + ) -> Result<(), rmcp::ErrorData> { + if matches!(notification, ClientNotification::CancelledNotification(_)) { + self.cancelled.notify_one(); + } + Ok(()) + } + + fn get_info(&self) -> rmcp::model::ServerInfo { + ServerInfo::default() + } +} + +impl ServerHandler for ClosedSinkServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .build(), + ) + } + + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.supported_by(&self.get_info().capabilities)) + } + + async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { + self.sink.lock().await.replace(context.sink().clone()); + Ok(()) + } +} + +#[derive(Clone)] +struct CancellationServer { + cancelled: Arc, +} + +impl ServerHandler for CancellationServer { + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.clone()) + } + + async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { + context.cancelled().await; + self.cancelled.notify_one(); + Ok(()) + } +} + +#[derive(Clone)] +struct AbruptServer { + started: Arc, +} + +impl ServerHandler for AbruptServer { + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.clone()) + } + + async fn listen(&self, _context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { + self.started.notify_one(); + std::future::pending().await + } +} + +async fn modern_client( + server: S, +) -> anyhow::Result> { + let (server_transport, client_transport) = tokio::io::duplex(16 * 1024); + tokio::spawn(async move { + let server = server.serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + ().serve_with_lifecycle( + client_transport, + rmcp::ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await + .map_err(Into::into) +} + +#[tokio::test] +async fn listen_exposes_acknowledged_filter_and_graceful_result() -> anyhow::Result<()> { + let client = modern_client(ToolsOnlyServer).await?; + let mut subscription = client + .listen( + SubscriptionFilter::builder() + .tools_list_changed() + .prompts_list_changed() + .build(), + ) + .await?; + + assert_eq!( + subscription.acknowledged(), + &SubscriptionFilter::builder().tools_list_changed().build() + ); + + let notification = tokio::time::timeout(Duration::from_secs(5), subscription.next()) + .await?? + .expect("tool notification"); + assert!(matches!( + notification, + ServerNotification::ToolListChangedNotification(_) + )); + assert_eq!( + notification.get_meta().subscription_id(), + Some(subscription.id().clone()) + ); + + assert!( + tokio::time::timeout(Duration::from_secs(5), subscription.next()) + .await?? + .is_none() + ); + let Some(SubscriptionEnd::Graceful(result)) = subscription.end() else { + panic!("expected graceful final result"); + }; + assert_eq!( + result.meta.subscription_id().as_ref(), + Some(subscription.id()) + ); + assert_eq!( + result + .meta + .server_info() + .expect("graceful result should contain valid server info"), + Implementation::new("tools-only-server", "1.0.0") + ); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn typed_subscription_notifications_do_not_reach_handler_callbacks() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(16 * 1024); + tokio::spawn(async move { + let server = ToolsOnlyServer.serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + let tool_changes = Arc::new(AtomicUsize::new(0)); + let client = CountingClient { + tool_changes: tool_changes.clone(), + } + .serve_with_lifecycle( + client_transport, + rmcp::ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await?; + let mut subscription = client + .listen(SubscriptionFilter::builder().tools_list_changed().build()) + .await?; + + assert!(subscription.next().await?.is_some()); + assert!(subscription.next().await?.is_none()); + tokio::task::yield_now().await; + assert_eq!(tool_changes.load(Ordering::Relaxed), 0); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn discover_lifecycle_allows_subscriptions_with_older_application_version() +-> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(16 * 1024); + tokio::spawn(async move { + let server = ToolsOnlyServer.serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + let client = () + .serve_with_lifecycle( + client_transport, + rmcp::ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2025_11_25], + }, + ) + .await?; + let mut subscription = client + .listen(SubscriptionFilter::builder().tools_list_changed().build()) + .await?; + + assert!(subscription.next().await?.is_some()); + assert!(subscription.next().await?.is_none()); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn concurrent_subscriptions_are_demultiplexed_by_request_id() -> anyhow::Result<()> { + let client = modern_client(ToolsAndPromptsServer).await?; + let (tools, prompts) = tokio::join!( + client.listen(SubscriptionFilter::builder().tools_list_changed().build()), + client.listen(SubscriptionFilter::builder().prompts_list_changed().build()) + ); + let mut tools = tools?; + let mut prompts = prompts?; + + let tool_notification = tools.next().await?.expect("tool notification"); + let prompt_notification = prompts.next().await?.expect("prompt notification"); + assert!(matches!( + tool_notification, + ServerNotification::ToolListChangedNotification(_) + )); + assert!(matches!( + prompt_notification, + ServerNotification::PromptListChangedNotification(_) + )); + assert_ne!(tools.id(), prompts.id()); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn stdio_cancellation_sends_cancelled_for_the_listen_request() -> anyhow::Result<()> { + let cancelled = Arc::new(Notify::new()); + let client = modern_client(CancellationServer { + cancelled: cancelled.clone(), + }) + .await?; + let mut subscription = client.listen(SubscriptionFilter::new()).await?; + + subscription.cancel().await?; + assert!(matches!( + subscription.end(), + Some(SubscriptionEnd::Cancelled) + )); + tokio::time::timeout(Duration::from_secs(5), cancelled.notified()).await?; + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn resource_updates_are_filtered_by_exact_uri_membership() -> anyhow::Result<()> { + let client = modern_client(ResourceSubscriptionServer).await?; + let mut subscription = client + .listen( + SubscriptionFilter::builder() + .resource_subscription("file:///accepted") + .build(), + ) + .await?; + + let notification = subscription.next().await?.expect("resource update"); + let ServerNotification::ResourceUpdatedNotification(update) = notification else { + panic!("expected resource update"); + }; + assert_eq!(update.params.uri, "file:///accepted"); + assert!(subscription.next().await?.is_none()); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn stdio_server_cancellation_ends_the_matching_subscription() -> anyhow::Result<()> { + let client = modern_client(RemoteCancellationServer).await?; + let mut subscription = client.listen(SubscriptionFilter::new()).await?; + + assert!(subscription.next().await?.is_none()); + assert!(matches!( + subscription.end(), + Some(SubscriptionEnd::Cancelled) + )); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn slow_consumer_reports_subscription_lag() -> anyhow::Result<()> { + let client = modern_client(FloodServer).await?; + let mut subscription = client + .listen_with_capacity( + SubscriptionFilter::builder().tools_list_changed().build(), + NonZeroUsize::MIN, + ) + .await?; + + tokio::time::sleep(Duration::from_millis(50)).await; + while subscription.next().await?.is_some() {} + assert!( + matches!( + subscription.end(), + Some(SubscriptionEnd::Lagged { capacity: 1 }) + ), + "unexpected end: {:?}", + subscription.end() + ); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn sink_rejects_notifications_after_graceful_completion() -> anyhow::Result<()> { + let sink = Arc::new(Mutex::new(None)); + let client = modern_client(ClosedSinkServer { sink: sink.clone() }).await?; + let mut subscription = client + .listen(SubscriptionFilter::builder().tools_list_changed().build()) + .await?; + assert!(subscription.next().await?.is_none()); + + let sink = sink.lock().await.clone().expect("captured sink"); + assert!(matches!( + sink.notify_tool_list_changed().await, + Err(SubscriptionSendError::SubscriptionClosed) + )); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn client_rejects_notifications_outside_the_acknowledged_filter() -> anyhow::Result<()> { + let client = modern_client(LeakyServer).await?; + let mut subscription = client + .listen(SubscriptionFilter::builder().tools_list_changed().build()) + .await?; + + assert!(matches!( + subscription.next().await, + Err(rmcp::ServiceError::UnexpectedResponse) + )); + assert!(matches!(subscription.end(), Some(SubscriptionEnd::Abrupt))); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn malformed_acknowledgment_cancels_pending_listen_request() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(16 * 1024); + let cancelled = Arc::new(Notify::new()); + let server_cancelled = cancelled.clone(); + tokio::spawn(async move { + let server = MalformedAcknowledgmentServer { + cancelled: server_cancelled, + } + .serve(server_transport) + .await?; + server.waiting().await?; + anyhow::Ok(()) + }); + let client = () + .serve_with_lifecycle( + client_transport, + rmcp::ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await?; + + assert!(matches!( + client + .listen(SubscriptionFilter::builder().tools_list_changed().build()) + .await, + Err(rmcp::ServiceError::UnexpectedResponse) + )); + tokio::time::timeout(Duration::from_secs(5), cancelled.notified()).await?; + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn transport_close_without_final_result_is_abrupt() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(16 * 1024); + let started = Arc::new(Notify::new()); + let server_started = started.clone(); + tokio::spawn(async move { + let server = AbruptServer { + started: server_started.clone(), + } + .serve(server_transport) + .await?; + server_started.notified().await; + server.cancel().await?; + anyhow::Ok(()) + }); + let client = () + .serve_with_lifecycle( + client_transport, + rmcp::ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await?; + let mut subscription = client.listen(SubscriptionFilter::new()).await?; + + assert!( + tokio::time::timeout(Duration::from_secs(5), subscription.next()) + .await?? + .is_none() + ); + assert!(matches!(subscription.end(), Some(SubscriptionEnd::Abrupt))); + Ok(()) +} diff --git a/vendor/rmcp/tests/test_subscriptions_model.rs b/vendor/rmcp/tests/test_subscriptions_model.rs new file mode 100644 index 000000000..8d79fb119 --- /dev/null +++ b/vendor/rmcp/tests/test_subscriptions_model.rs @@ -0,0 +1,254 @@ +use rmcp::model::{ + ClientJsonRpcMessage, ClientRequest, GetMeta, Implementation, JsonRpcNotification, + JsonRpcRequest, NotificationMetaObject, RequestId, RequestMetaObject, ServerJsonRpcMessage, + ServerNotification, SubscriptionFilter, SubscriptionsAcknowledgedNotification, + SubscriptionsAcknowledgedNotificationParams, SubscriptionsListenRequest, + SubscriptionsListenRequestParams, SubscriptionsListenResult, SubscriptionsListenResultMeta, +}; +use serde_json::json; + +#[test] +fn subscription_filter_serializes_only_opted_in_notifications() { + let filter = SubscriptionFilter::builder() + .tools_list_changed() + .resource_subscription("file:///one") + .resource_subscription("file:///two") + .build(); + + assert_eq!( + serde_json::to_value(filter).expect("serialize filter"), + json!({ + "toolsListChanged": true, + "resourceSubscriptions": ["file:///one", "file:///two"], + }) + ); +} + +#[test] +fn subscription_filter_subset_is_order_independent_and_ignores_false_flags() { + let requested = SubscriptionFilter::builder() + .tools_list_changed() + .resource_subscriptions(["file:///one", "file:///two"]) + .build(); + let mut accepted = SubscriptionFilter::builder() + .resource_subscriptions(["file:///two", "file:///one"]) + .build(); + accepted.tools_list_changed = Some(false); + + assert!(accepted.is_subset_of(&requested)); +} + +#[test] +fn subscription_filter_omits_empty_resource_intersection() { + let requested = SubscriptionFilter::builder() + .resource_subscription("file:///requested") + .build(); + let accepted = SubscriptionFilter::builder() + .resource_subscription("file:///different") + .build(); + + assert_eq!( + serde_json::to_value(requested.intersection(&accepted)).expect("serialize intersection"), + json!({}) + ); +} + +#[test] +fn listen_request_round_trips_required_fields_and_arbitrary_metadata() { + let mut request = SubscriptionsListenRequest::new(SubscriptionsListenRequestParams::new( + SubscriptionFilter::builder().prompts_list_changed().build(), + )); + let mut meta = RequestMetaObject::new(); + meta.insert("com.example/request".into(), json!("value")); + request.extensions.insert(meta); + let message = ClientJsonRpcMessage::request( + ClientRequest::SubscriptionsListenRequest(request), + RequestId::String("subscription-1".into()), + ); + + let value = serde_json::to_value(&message).expect("serialize listen request"); + assert_eq!( + value, + json!({ + "jsonrpc": "2.0", + "id": "subscription-1", + "method": "subscriptions/listen", + "params": { + "_meta": { + "com.example/request": "value", + }, + "notifications": { + "promptsListChanged": true, + }, + }, + }) + ); + + let round_trip: ClientJsonRpcMessage = + serde_json::from_value(value).expect("deserialize listen request"); + let ClientJsonRpcMessage::Request(JsonRpcRequest { request, .. }) = round_trip else { + panic!("expected request"); + }; + let ClientRequest::SubscriptionsListenRequest(request) = request else { + panic!("expected subscriptions/listen request"); + }; + assert_eq!( + request + .extensions + .get::() + .and_then(|meta| meta.get("com.example/request")), + Some(&json!("value")) + ); +} + +#[test] +fn acknowledged_notification_round_trips_numeric_subscription_id_and_metadata() { + let mut notification = SubscriptionsAcknowledgedNotification::new( + SubscriptionsAcknowledgedNotificationParams::new( + SubscriptionFilter::builder() + .resources_list_changed() + .build(), + ), + ); + let mut meta = NotificationMetaObject::new(); + meta.set_subscription_id(RequestId::Number(7)); + meta.insert("com.example/notification".into(), json!(true)); + notification.extensions.insert(meta); + let message = ServerJsonRpcMessage::notification( + ServerNotification::SubscriptionsAcknowledgedNotification(notification), + ); + + let value = serde_json::to_value(&message).expect("serialize acknowledgment"); + assert_eq!( + value, + json!({ + "jsonrpc": "2.0", + "method": "notifications/subscriptions/acknowledged", + "params": { + "_meta": { + "io.modelcontextprotocol/subscriptionId": 7, + "com.example/notification": true, + }, + "notifications": { + "resourcesListChanged": true, + }, + }, + }) + ); + + let round_trip: ServerJsonRpcMessage = + serde_json::from_value(value).expect("deserialize acknowledgment"); + let ServerJsonRpcMessage::Notification(JsonRpcNotification { notification, .. }) = round_trip + else { + panic!("expected notification"); + }; + assert_eq!( + notification.get_meta().subscription_id(), + Some(RequestId::Number(7)) + ); + assert_eq!( + notification.get_meta().get("com.example/notification"), + Some(&json!(true)) + ); +} + +#[test] +fn listen_result_requires_matching_string_subscription_id_and_preserves_metadata() { + let mut meta = SubscriptionsListenResultMeta::new(RequestId::String("subscription-2".into())); + meta.set_server_info(Implementation::new("test-server", "1.0.0")); + meta.insert("com.example/result".into(), json!({ "reason": "shutdown" })); + let result = SubscriptionsListenResult::new(meta); + + let value = serde_json::to_value(&result).expect("serialize listen result"); + assert_eq!( + value, + json!({ + "resultType": "complete", + "_meta": { + "io.modelcontextprotocol/subscriptionId": "subscription-2", + "io.modelcontextprotocol/serverInfo": { + "name": "test-server", + "version": "1.0.0", + }, + "com.example/result": { + "reason": "shutdown", + }, + }, + }) + ); + + let round_trip: SubscriptionsListenResult = + serde_json::from_value(value).expect("deserialize listen result"); + assert_eq!( + round_trip.meta.subscription_id(), + Some(RequestId::String("subscription-2".into())) + ); + assert_eq!( + round_trip.meta.server_info(), + Some(Implementation::new("test-server", "1.0.0")) + ); + assert_eq!( + round_trip.meta.get("com.example/result"), + Some(&json!({ "reason": "shutdown" })) + ); +} + +#[test] +fn listen_result_meta_returns_none_after_required_id_is_removed() { + let mut meta = SubscriptionsListenResultMeta::new(RequestId::Number(1)); + meta.remove("io.modelcontextprotocol/subscriptionId"); + + assert_eq!(meta.subscription_id(), None); +} + +#[cfg(feature = "schemars")] +#[test] +fn subscription_schemas_mark_only_draft_required_fields_as_required() { + let request_schema = + serde_json::to_value(schemars::schema_for!(SubscriptionsListenRequestParams)) + .expect("request schema"); + let filter_schema = + serde_json::to_value(schemars::schema_for!(SubscriptionFilter)).expect("filter schema"); + let acknowledgment_schema = serde_json::to_value(schemars::schema_for!( + SubscriptionsAcknowledgedNotificationParams + )) + .expect("acknowledgment schema"); + let result_schema = serde_json::to_value(schemars::schema_for!(SubscriptionsListenResult)) + .expect("result schema"); + + assert_eq!( + request_schema["required"], + json!(["_meta", "notifications"]) + ); + assert_eq!( + request_schema["properties"]["_meta"]["required"], + json!([ + "io.modelcontextprotocol/protocolVersion", + "io.modelcontextprotocol/clientCapabilities" + ]) + ); + assert!(filter_schema.get("required").is_none()); + assert_eq!( + filter_schema["properties"]["toolsListChanged"]["type"], + "boolean" + ); + assert_eq!( + filter_schema["properties"]["resourceSubscriptions"]["type"], + "array" + ); + assert_eq!(acknowledgment_schema["required"], json!(["notifications"])); + assert_eq!( + acknowledgment_schema["properties"]["_meta"]["$ref"], + "#/$defs/NotificationMetaObject" + ); + let result_meta_schema = &result_schema["$defs"]["SubscriptionsListenResultMeta"]; + assert_eq!( + result_meta_schema["properties"]["io.modelcontextprotocol/serverInfo"]["allOf"][0]["$ref"], + "#/$defs/Implementation" + ); + assert_eq!( + result_meta_schema["required"], + json!(["io.modelcontextprotocol/subscriptionId"]) + ); + assert_eq!(result_schema["required"], json!(["resultType", "_meta"])); +} diff --git a/vendor/rmcp/tests/test_subscriptions_streamable_http.rs b/vendor/rmcp/tests/test_subscriptions_streamable_http.rs new file mode 100644 index 000000000..bfb931371 --- /dev/null +++ b/vendor/rmcp/tests/test_subscriptions_streamable_http.rs @@ -0,0 +1,317 @@ +#![cfg(all( + not(feature = "local"), + feature = "client", + feature = "server", + feature = "transport-streamable-http-client-reqwest", + feature = "transport-streamable-http-server" +))] + +use std::{ + borrow::Cow, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use rmcp::{ + ClientLifecycleMode, ClientServiceExt, ServerHandler, + model::{ + ClientInfo, ClientRequest, Implementation, ListToolsRequest, ProtocolVersion, + RequestMetaObject, ServerCapabilities, ServerInfo, ServerNotification, SubscriptionFilter, + }, + service::{PeerRequestOptions, SubscriptionContext, SubscriptionEnd}, + transport::{ + StreamableHttpClientTransport, + streamable_http_client::StreamableHttpClientTransportConfig, + streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, + }, +}; +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; + +#[derive(Clone)] +struct HttpSubscriptionServer { + cancelled: Arc, + started: Arc, + ending: ServerEnding, +} + +#[derive(Clone, Copy)] +enum ServerEnding { + ClientCancellation, + Graceful, + Abrupt, +} + +impl ServerHandler for HttpSubscriptionServer { + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(&[ProtocolVersion::V_2026_07_28, ProtocolVersion::V_2025_11_25]) + } + + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .build(), + ) + .with_server_info(Implementation::new("http-subscription-server", "1.0.0")) + } + + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.supported_by(&self.get_info().capabilities)) + } + + async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { + context + .sink() + .notify_tool_list_changed() + .await + .expect("send tool notification"); + self.started.notify_one(); + match self.ending { + ServerEnding::Graceful => Ok(()), + ServerEnding::ClientCancellation => { + context.cancelled().await; + self.cancelled.notify_one(); + Ok(()) + } + ServerEnding::Abrupt => std::future::pending().await, + } + } +} + +async fn spawn_server( + ending: ServerEnding, +) -> ( + String, + CancellationToken, + Arc, + Arc, + Arc, +) { + let cancellation_token = CancellationToken::new(); + let subscription_cancelled = Arc::new(Notify::new()); + let subscription_started = Arc::new(Notify::new()); + let server = HttpSubscriptionServer { + cancelled: subscription_cancelled.clone(), + started: subscription_started.clone(), + ending, + }; + let service: StreamableHttpService = + StreamableHttpService::new( + move || Ok(server.clone()), + Default::default(), + StreamableHttpServerConfig::default() + .with_legacy_session_mode(true) + .with_json_response(true) + .with_sse_keep_alive(Some(Duration::from_millis(50))) + .with_cancellation_token(cancellation_token.child_token()), + ); + let get_requests = Arc::new(AtomicUsize::new(0)); + let observed_get_requests = get_requests.clone(); + let router = + axum::Router::new() + .nest_service("/mcp", service) + .layer(axum::middleware::from_fn( + move |request: axum::extract::Request, next: axum::middleware::Next| { + let observed_get_requests = observed_get_requests.clone(); + async move { + if request.method() == axum::http::Method::GET { + observed_get_requests.fetch_add(1, Ordering::Relaxed); + } + next.run(request).await + } + }, + )); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let address = listener.local_addr().expect("listener address"); + tokio::spawn({ + let cancellation_token = cancellation_token.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { cancellation_token.cancelled_owned().await }) + .await; + } + }); + ( + format!("http://{address}/mcp"), + cancellation_token, + subscription_cancelled, + subscription_started, + get_requests, + ) +} + +#[tokio::test] +async fn modern_http_listen_uses_post_stream_and_cancels_by_closing_it() -> anyhow::Result<()> { + let (url, server_ct, subscription_cancelled, _, get_requests) = + spawn_server(ServerEnding::ClientCancellation).await; + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(url.clone()), + ); + let client = ClientInfo::default() + .serve_with_lifecycle( + transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await?; + assert_eq!(get_requests.load(Ordering::Relaxed), 0); + let mut subscription = client + .listen(SubscriptionFilter::builder().tools_list_changed().build()) + .await?; + + assert!(matches!( + subscription.next().await?.expect("tool notification"), + ServerNotification::ToolListChangedNotification(_) + )); + let mut older_version = RequestMetaObject::new(); + older_version.set_protocol_version(ProtocolVersion::V_2025_11_25); + client + .send_request_with_option( + ClientRequest::ListToolsRequest(ListToolsRequest { + method: Default::default(), + params: None, + extensions: Default::default(), + }), + PeerRequestOptions::no_options().with_meta(older_version), + ) + .await? + .await_response() + .await?; + subscription.cancel().await?; + tokio::time::timeout(Duration::from_secs(5), subscription_cancelled.notified()).await?; + + client.cancel().await?; + server_ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn modern_http_graceful_close_returns_final_listen_result() -> anyhow::Result<()> { + let (url, server_ct, _, _, _) = spawn_server(ServerEnding::Graceful).await; + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(url), + ); + let client = ClientInfo::default() + .serve_with_lifecycle( + transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await?; + let mut subscription = client + .listen(SubscriptionFilter::builder().tools_list_changed().build()) + .await?; + + assert!(subscription.next().await?.is_some()); + assert!(subscription.next().await?.is_none()); + let Some(SubscriptionEnd::Graceful(result)) = subscription.end() else { + panic!("expected graceful final result"); + }; + assert_eq!( + result + .meta + .server_info() + .expect("graceful result should contain valid server info"), + Implementation::new("http-subscription-server", "1.0.0") + ); + + client.cancel().await?; + server_ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn modern_http_stream_close_without_result_is_abrupt() -> anyhow::Result<()> { + let (url, server_ct, _, subscription_started, _) = spawn_server(ServerEnding::Abrupt).await; + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(url), + ); + let client = ClientInfo::default() + .serve_with_lifecycle( + transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await?; + let mut subscription = client + .listen(SubscriptionFilter::builder().tools_list_changed().build()) + .await?; + assert!(subscription.next().await?.is_some()); + subscription_started.notified().await; + + server_ct.cancel(); + assert!( + tokio::time::timeout(Duration::from_secs(5), subscription.next()) + .await?? + .is_none() + ); + assert!(matches!(subscription.end(), Some(SubscriptionEnd::Abrupt))); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn modern_http_lifecycle_stays_sessionless_for_older_application_version() +-> anyhow::Result<()> { + let (url, server_ct, _, _, get_requests) = spawn_server(ServerEnding::ClientCancellation).await; + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(url), + ); + let client = ClientInfo::default() + .serve_with_lifecycle( + transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2025_11_25], + }, + ) + .await?; + + client.list_tools(None).await?; + assert_eq!(get_requests.load(Ordering::Relaxed), 0); + + client.cancel().await?; + server_ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn modern_http_get_and_delete_are_method_not_allowed_in_legacy_session_mode() { + let (url, server_ct, _, _, _) = spawn_server(ServerEnding::ClientCancellation).await; + let client = reqwest::Client::new(); + + for method in [reqwest::Method::GET, reqwest::Method::DELETE] { + let response = client + .request(method, &url) + .header("Accept", "text/event-stream") + .header("MCP-Protocol-Version", "2026-07-28") + .send() + .await + .expect("request"); + assert_eq!(response.status(), reqwest::StatusCode::METHOD_NOT_ALLOWED); + assert_eq!( + response + .headers() + .get(reqwest::header::ALLOW) + .and_then(|value| value.to_str().ok()), + Some("POST") + ); + } + + server_ct.cancel(); +} diff --git a/vendor/rmcp/tests/test_task.rs b/vendor/rmcp/tests/test_task.rs new file mode 100644 index 000000000..ea1a2595e --- /dev/null +++ b/vendor/rmcp/tests/test_task.rs @@ -0,0 +1,442 @@ +//! End-to-end tests for the MCP Tasks extension (SEP-2663, +//! `io.modelcontextprotocol/tasks`). +#![cfg(all(feature = "server", feature = "client", not(feature = "local")))] + +use rmcp::{ + ErrorData as McpError, ServerHandler, ServiceExt, + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::*, + service::{RequestContext, RoleServer}, + task_manager::{TaskExit, TaskManager, TaskOptions}, + tool, tool_router, +}; +use serde_json::json; + +#[derive(Debug, serde::Deserialize, rmcp::schemars::JsonSchema)] +pub struct SumArgs { + pub a: i32, + pub b: i32, +} + +#[derive(Clone)] +struct TaskServer { + tool_router: ToolRouter, + tasks: TaskManager, +} + +#[tool_router] +impl TaskServer { + fn new() -> Self { + Self { + tool_router: Self::tool_router(), + tasks: TaskManager::new(), + } + } + + #[tool(description = "Sum two numbers")] + async fn sum( + &self, + Parameters(SumArgs { a, b }): Parameters, + ) -> Result { + Ok(CallToolResult::success(vec![ContentBlock::text( + (a + b).to_string(), + )])) + } +} + +impl ServerHandler for TaskServer { + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + let client_supports_tasks = context + .client_capabilities() + .is_some_and(|caps| caps.supports_tasks()); + + if request.name == "sum" && client_supports_tasks { + let args: SumArgs = serde_json::from_value(serde_json::Value::Object( + request.arguments.clone().unwrap_or_default(), + )) + .map_err(|e| McpError::invalid_params(e.to_string(), None))?; + let task = self + .tasks + .spawn(TaskOptions::new().with_poll_interval_ms(10), move |ctx| { + Box::pin(async move { + tokio::select! { + _ = ctx.cancelled() => { + Err(TaskExit::Cancelled) + } + _ = tokio::time::sleep(std::time::Duration::from_millis(50)) => { + Ok(CallToolResult::success(vec![ContentBlock::text( + (args.a + args.b).to_string(), + )])) + } + } + }) + }); + return Ok(CallToolResponse::Task(CreateTaskResult::new(task))); + } + + let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); + self.tool_router.call(tcc).await + } + + async fn get_task( + &self, + request: GetTaskParams, + _context: RequestContext, + ) -> Result { + Ok(GetTaskResult::new(self.tasks.get_task(&request.task_id)?)) + } + + async fn update_task( + &self, + request: UpdateTaskParams, + _context: RequestContext, + ) -> Result<(), McpError> { + self.tasks + .update_task(&request.task_id, request.input_responses) + } + + async fn cancel_task( + &self, + request: CancelTaskParams, + _context: RequestContext, + ) -> Result<(), McpError> { + self.tasks.cancel_task(&request.task_id) + } + + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tasks() + .build(), + ) + } +} + +fn tasks_client_info() -> ClientInfo { + ClientInfo::new( + ClientCapabilities::builder().enable_tasks().build(), + Implementation::from_build_env(), + ) +} + +#[tokio::test] +async fn task_lifecycle_create_poll_complete() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + let service = TaskServer::new().serve(server_transport).await?; + service.waiting().await?; + anyhow::Ok(()) + }); + + let client = tasks_client_info().serve(client_transport).await.unwrap(); + + // Server materializes a task because we declared the extension. + let response = client + .call_tool_once( + CallToolRequestParams::new("sum") + .with_arguments(serde_json::from_value(json!({"a": 40, "b": 2})).unwrap()), + ) + .await + .unwrap(); + let create = match response { + CallToolResponse::Task(create) => create, + other => panic!("expected CreateTaskResult, got {other:?}"), + }; + assert_eq!(create.result_type, ResultType::TASK); + let task_id = create.task.task_id.clone(); + + // Poll until terminal. + let final_task = loop { + tokio::time::sleep(std::time::Duration::from_millis( + create.task.poll_interval_ms.unwrap_or(10), + )) + .await; + let info = client + .peer() + .get_task(GetTaskParams::new(task_id.clone())) + .await + .unwrap(); + if info.task.status().is_terminal() { + break info.task; + } + }; + + match final_task.payload { + TaskPayload::Completed { result } => { + let result: CallToolResult = + serde_json::from_value(serde_json::Value::Object(result)).unwrap(); + let text = result.content[0].as_text().unwrap(); + assert_eq!(text.text, "42"); + } + other => panic!("expected completed task, got {other:?}"), + } + + client.cancel().await.unwrap(); + server.abort(); +} + +#[tokio::test] +async fn task_cancel_acknowledged() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + let service = TaskServer::new().serve(server_transport).await?; + service.waiting().await?; + anyhow::Ok(()) + }); + + let client = tasks_client_info().serve(client_transport).await.unwrap(); + + let response = client + .call_tool_once( + CallToolRequestParams::new("sum") + .with_arguments(serde_json::from_value(json!({"a": 1, "b": 1})).unwrap()), + ) + .await + .unwrap(); + let create = match response { + CallToolResponse::Task(create) => create, + other => panic!("expected CreateTaskResult, got {other:?}"), + }; + + client + .peer() + .cancel_task(CancelTaskParams::new(create.task.task_id.clone())) + .await + .unwrap(); + + // Cancellation is cooperative (SEP-2663): the ack is immediate, and the + // operation settles the terminal status; poll until it does. + let mut final_status = None; + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let info = client + .peer() + .get_task(GetTaskParams::new(create.task.task_id.clone())) + .await + .unwrap(); + if info.task.status().is_terminal() { + final_status = Some(info.task.status()); + break; + } + } + assert_eq!(final_status, Some(TaskStatus::Cancelled)); + + client.cancel().await.unwrap(); + server.abort(); +} + +#[tokio::test] +async fn no_task_without_extension_capability() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + let service = TaskServer::new().serve(server_transport).await?; + service.waiting().await?; + anyhow::Ok(()) + }); + + // Plain client: no tasks extension declared. + let client = ().serve(client_transport).await.unwrap(); + let result = client + .call_tool( + CallToolRequestParams::new("sum") + .with_arguments(serde_json::from_value(json!({"a": 2, "b": 3})).unwrap()), + ) + .await + .unwrap(); + let text = result.content[0].as_text().unwrap(); + assert_eq!(text.text, "5"); + + client.cancel().await.unwrap(); + server.abort(); +} + +/// A misbehaving handler that materializes a task without checking the +/// client's capabilities. The SDK dispatch must catch this and reject with +/// -32021 rather than sending a task handle the client cannot parse. +#[derive(Clone)] +struct AlwaysTaskServer { + tasks: TaskManager, +} + +impl ServerHandler for AlwaysTaskServer { + async fn call_tool( + &self, + _request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + let task = self.tasks.spawn(TaskOptions::default(), |_ctx| { + Box::pin(async { Ok(CallToolResult::success(vec![ContentBlock::text("late")])) }) + }); + Ok(CallToolResponse::Task(CreateTaskResult::new(task))) + } + + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tasks() + .build(), + ) + } +} + +#[tokio::test] +async fn dispatch_rejects_task_result_for_non_declaring_client() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + let service = AlwaysTaskServer { + tasks: TaskManager::new(), + } + .serve(server_transport) + .await?; + service.waiting().await?; + anyhow::Ok(()) + }); + + // Plain client: no tasks extension declared. The handler tries to return + // a CreateTaskResult anyway; dispatch must reject with -32021. + let client = ().serve(client_transport).await.unwrap(); + let err = client + .call_tool(CallToolRequestParams::new("anything")) + .await + .unwrap_err(); + match err { + rmcp::ServiceError::McpError(e) => { + assert_eq!(e.code, ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY); + } + other => panic!("expected McpError, got {other:?}"), + } + + client.cancel().await.unwrap(); + server.abort(); +} + +#[tokio::test] +async fn tasks_methods_without_capability_return_missing_capability_error() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + let service = TaskServer::new().serve(server_transport).await?; + service.waiting().await?; + anyhow::Ok(()) + }); + + // Plain client: no tasks extension declared. tasks/* must be rejected + // with -32021 Missing Required Client Capability (SEP-2663), not -32601. + let client = ().serve(client_transport).await.unwrap(); + let err = client + .peer() + .get_task(GetTaskParams::new("whatever")) + .await + .unwrap_err(); + match err { + rmcp::ServiceError::McpError(e) => { + assert_eq!(e.code, ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY); + let data = e.data.expect("error data should be present"); + assert!( + data["requiredCapabilities"]["extensions"] + .as_object() + .is_some_and(|ext| ext.contains_key("io.modelcontextprotocol/tasks")), + "error data should name the tasks extension: {data}" + ); + } + other => panic!("expected McpError, got {other:?}"), + } + + let err = client + .peer() + .cancel_task(CancelTaskParams::new("whatever")) + .await + .unwrap_err(); + match err { + rmcp::ServiceError::McpError(e) => { + assert_eq!(e.code, ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY); + } + other => panic!("expected McpError, got {other:?}"), + } + + client.cancel().await.unwrap(); + server.abort(); +} + +#[tokio::test] +async fn unknown_task_id_returns_invalid_params() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + let service = TaskServer::new().serve(server_transport).await?; + service.waiting().await?; + anyhow::Ok(()) + }); + + let client = tasks_client_info().serve(client_transport).await.unwrap(); + let err = client + .peer() + .get_task(GetTaskParams::new("no-such-task")) + .await + .unwrap_err(); + match err { + rmcp::ServiceError::McpError(e) => { + // SEP-2663: unknown taskId is -32602 Invalid params. + assert_eq!(e.code, ErrorCode::INVALID_PARAMS); + } + other => panic!("expected McpError, got {other:?}"), + } + + client.cancel().await.unwrap(); + server.abort(); +} + +#[tokio::test] +async fn legacy_task_param_is_ignored() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + let service = TaskServer::new().serve(server_transport).await?; + service.waiting().await?; + anyhow::Ok(()) + }); + + // Plain client sending a legacy 2025-11-25 `task: {...}` param: it must + // be silently ignored and the call answered synchronously (SEP-2663). + let client = ().serve(client_transport).await.unwrap(); + let params: CallToolRequestParams = serde_json::from_value(json!({ + "name": "sum", + "arguments": {"a": 2, "b": 3}, + "task": {"ttl": 60000} + })) + .expect("legacy task param must not break deserialization"); + let result = client.call_tool(params).await.unwrap(); + let text = result.content[0].as_text().unwrap(); + assert_eq!(text.text, "5"); + + client.cancel().await.unwrap(); + server.abort(); +} + +#[test] +fn task_status_notification_params_preserve_meta() { + let raw = json!({ + "_meta": { + "traceId": "trace-1" + }, + "taskId": "task-1", + "status": "working", + "createdAt": "2026-06-24T00:00:00Z", + "lastUpdatedAt": "2026-06-24T00:00:01Z", + "ttlMs": null + }); + + let params: TaskStatusNotificationParams = serde_json::from_value(raw).unwrap(); + + assert_eq!(params.task.task.task_id, "task-1"); + assert_eq!(params.status(), TaskStatus::Working); + assert_eq!(params.meta.as_ref().unwrap().0["traceId"], json!("trace-1")); + + let serialized = serde_json::to_value(¶ms).unwrap(); + assert_eq!(serialized["_meta"]["traceId"], json!("trace-1")); + assert_eq!(serialized["taskId"], json!("task-1")); + assert_eq!(serialized["ttlMs"], serde_json::Value::Null); +} diff --git a/vendor/rmcp/tests/test_tool_builder_methods.rs b/vendor/rmcp/tests/test_tool_builder_methods.rs new file mode 100644 index 000000000..cdb1473f5 --- /dev/null +++ b/vendor/rmcp/tests/test_tool_builder_methods.rs @@ -0,0 +1,105 @@ +#![allow(clippy::exhaustive_structs)] +//cargo test --test test_tool_builder_methods --features "client server macros" +use rmcp::model::{JsonObject, Tool}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct InputData { + pub name: String, + pub age: u32, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct OutputData { + pub greeting: String, + pub is_adult: bool, +} + +#[test] +fn test_with_output_schema() { + let tool = Tool::new("test", "Test tool", JsonObject::new()).with_output_schema::(); + + assert!(tool.output_schema.is_some()); + + let schema = tool.output_schema.as_ref().unwrap(); + assert_eq!(schema.get("type"), Some(&serde_json::json!("object"))); +} + +#[test] +fn test_with_input_schema() { + let tool = Tool::new("test", "Test tool", JsonObject::new()).with_input_schema::(); + + // Verify the schema contains expected fields + let schema_str = serde_json::to_string(&tool.input_schema).unwrap(); + assert!(schema_str.contains("name")); + assert!(schema_str.contains("age")); +} + +#[test] +fn test_chained_builder_methods() { + let tool = Tool::new("test", "Test tool", JsonObject::new()) + .with_input_schema::() + .with_output_schema::() + .annotate(rmcp::model::ToolAnnotations::new().read_only(true)); + + assert!(tool.output_schema.is_some()); + assert!(tool.annotations.is_some()); + assert_eq!( + tool.annotations.as_ref().unwrap().read_only_hint, + Some(true) + ); + + // Verify both schemas are set correctly + let input_schema_str = serde_json::to_string(&tool.input_schema).unwrap(); + assert!(input_schema_str.contains("name")); + assert!(input_schema_str.contains("age")); + + let output_schema = tool.output_schema.as_ref().unwrap(); + assert_eq!( + output_schema.get("type"), + Some(&serde_json::json!("object")) + ); +} + +#[test] +fn test_with_output_schema_primitive() { + let tool = Tool::new("test", "Test tool", JsonObject::new()).with_output_schema::(); + + assert!(tool.output_schema.is_some()); + + let schema = tool.output_schema.as_ref().unwrap(); + assert_eq!(schema.get("type"), Some(&serde_json::json!("integer"))); + // title should be stripped from output schema + assert!(schema.get("title").is_none()); +} + +#[test] +fn test_with_output_schema_array() { + let tool = + Tool::new("test", "Test tool", JsonObject::new()).with_output_schema::>(); + + assert!(tool.output_schema.is_some()); + + let schema_str = serde_json::to_string(tool.output_schema.as_ref().unwrap()).unwrap(); + assert!(schema_str.contains("\"type\":\"array\"")); + assert!(schema_str.contains("items")); + // title should be stripped from output schema + assert!(!schema_str.contains("title")); +} + +#[test] +fn test_with_output_schema_option() { + let tool = + Tool::new("test", "Test tool", JsonObject::new()).with_output_schema::>(); + + assert!(tool.output_schema.is_some()); + + let schema_str = serde_json::to_string(tool.output_schema.as_ref().unwrap()).unwrap(); + // Option generates a composition schema (anyOf/oneOf/type array with null) + assert!( + schema_str.contains("anyOf") || schema_str.contains("oneOf") || schema_str.contains("null"), + "Expected composition schema for Option, got: {schema_str}" + ); + assert!(!schema_str.contains("title")); +} diff --git a/vendor/rmcp/tests/test_tool_disable_notification.rs b/vendor/rmcp/tests/test_tool_disable_notification.rs new file mode 100644 index 000000000..cd8780591 --- /dev/null +++ b/vendor/rmcp/tests/test_tool_disable_notification.rs @@ -0,0 +1,165 @@ +//! Integration tests for tool list change notifications. +#![cfg(all(feature = "client", not(feature = "local")))] + +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use rmcp::{ + ClientHandler, RoleClient, RoleServer, ServerHandler, ServiceExt, + handler::server::{router::tool::ToolRoute, tool::ToolCallContext}, + model::{CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool}, + service::{MaybeSendFuture, NotificationContext}, +}; +use tokio::sync::{Notify, RwLock}; + +#[derive(Clone)] +struct TestToolServer { + router: Arc>>, + trigger_disable: Arc, + trigger_enable: Arc, +} + +impl TestToolServer { + fn new() -> Self { + let mut tool_router = rmcp::handler::server::router::tool::ToolRouter::::new(); + tool_router.add_route(ToolRoute::new_dyn( + Tool::new("tool_a", "Tool A", Arc::new(Default::default())), + |_ctx| Box::pin(async { Ok(CallToolResult::default().into()) }), + )); + tool_router.add_route(ToolRoute::new_dyn( + Tool::new("tool_b", "Tool B", Arc::new(Default::default())), + |_ctx| Box::pin(async { Ok(CallToolResult::default().into()) }), + )); + Self { + router: Arc::new(RwLock::new(tool_router)), + trigger_disable: Arc::new(Notify::new()), + trigger_enable: Arc::new(Notify::new()), + } + } +} + +impl ServerHandler for TestToolServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } + + async fn call_tool( + &self, + request: rmcp::model::CallToolRequestParams, + context: rmcp::service::RequestContext, + ) -> Result { + let router = self.router.read().await; + let tcc = ToolCallContext::new(self, request, context); + router.call(tcc).await + } + + async fn list_tools( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> Result { + let router = self.router.read().await; + Ok(rmcp::model::ListToolsResult { + tools: router.list_all(), + ..Default::default() + }) + } + + fn on_initialized( + &self, + context: NotificationContext, + ) -> impl std::future::Future + MaybeSendFuture + '_ { + let router = self.router.clone(); + let trigger_disable = self.trigger_disable.clone(); + let trigger_enable = self.trigger_enable.clone(); + let peer = context.peer.clone(); + + async move { + router.write().await.bind_peer_notifier(&peer); + + let router = router.clone(); + tokio::spawn(async move { + trigger_disable.notified().await; + { + let mut r = router.write().await; + r.disable_route("tool_a"); + } + + trigger_enable.notified().await; + { + let mut r = router.write().await; + r.enable_route("tool_a"); + } + }); + } + } +} + +#[derive(Clone)] +struct TestToolClient { + notification_count: Arc, + notify: Arc, +} + +impl TestToolClient { + fn new() -> Self { + Self { + notification_count: Arc::new(AtomicUsize::new(0)), + notify: Arc::new(Notify::new()), + } + } +} + +impl ClientHandler for TestToolClient { + fn on_tool_list_changed( + &self, + _context: NotificationContext, + ) -> impl std::future::Future + MaybeSendFuture + '_ { + self.notification_count.fetch_add(1, Ordering::SeqCst); + self.notify.notify_one(); + std::future::ready(()) + } +} + +#[tokio::test] +async fn test_disable_enable_sends_tool_list_changed() { + let server = TestToolServer::new(); + let trigger_disable = server.trigger_disable.clone(); + let trigger_enable = server.trigger_enable.clone(); + + let client = TestToolClient::new(); + let notification_count = client.notification_count.clone(); + let client_notify = client.notify.clone(); + + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let server_handle = tokio::spawn(async move { server.serve(server_transport).await }); + let client_service = client.serve(client_transport).await.unwrap(); + + let tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(tools.tools.len(), 2); + + trigger_disable.notify_one(); + tokio::time::timeout(std::time::Duration::from_secs(5), client_notify.notified()) + .await + .expect("timed out waiting for tool_list_changed"); + assert_eq!(notification_count.load(Ordering::SeqCst), 1); + + let tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(tools.tools.len(), 1); + assert_eq!(tools.tools[0].name, "tool_b"); + + trigger_enable.notify_one(); + tokio::time::timeout(std::time::Duration::from_secs(5), client_notify.notified()) + .await + .expect("timed out waiting for tool_list_changed"); + assert_eq!(notification_count.load(Ordering::SeqCst), 2); + + let tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(tools.tools.len(), 2); + + client_service.cancel().await.unwrap(); + server_handle.abort(); +} diff --git a/vendor/rmcp/tests/test_tool_handler.rs b/vendor/rmcp/tests/test_tool_handler.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/vendor/rmcp/tests/test_tool_handler.rs @@ -0,0 +1 @@ + diff --git a/vendor/rmcp/tests/test_tool_macro_annotations.rs b/vendor/rmcp/tests/test_tool_macro_annotations.rs new file mode 100644 index 000000000..34b17625f --- /dev/null +++ b/vendor/rmcp/tests/test_tool_macro_annotations.rs @@ -0,0 +1,46 @@ +#[cfg(test)] +mod tests { + use rmcp::{ServerHandler, handler::server::router::tool::ToolRouter, tool, tool_handler}; + + #[derive(Debug, Clone, Default)] + pub struct AnnotatedServer { + tool_router: ToolRouter, + } + + impl AnnotatedServer { + // Tool with inline comments for documentation + /// Direct annotation test tool + /// This is used to test tool annotations + #[tool( + name = "direct-annotated-tool", + annotations(title = "Annotated Tool", read_only_hint = true) + )] + pub async fn direct_annotated_tool(&self, input: String) -> String { + format!("Direct: {}", input) + } + } + #[tool_handler(router = self.tool_router)] + impl ServerHandler for AnnotatedServer {} + + #[test] + fn test_direct_tool_attributes() { + // Get the tool definition + let tool = AnnotatedServer::direct_annotated_tool_tool_attr(); + + // Verify basic properties + assert_eq!(tool.name, "direct-annotated-tool"); + + // Verify description is extracted from doc comments + assert!(tool.description.is_some()); + assert!( + tool.description + .as_ref() + .unwrap() + .contains("Direct annotation test tool") + ); + + let annotations = tool.annotations.unwrap(); + assert_eq!(annotations.title.as_ref().unwrap(), "Annotated Tool"); + assert_eq!(annotations.read_only_hint, Some(true)); + } +} diff --git a/vendor/rmcp/tests/test_tool_macros.rs b/vendor/rmcp/tests/test_tool_macros.rs new file mode 100644 index 000000000..9b9530aa8 --- /dev/null +++ b/vendor/rmcp/tests/test_tool_macros.rs @@ -0,0 +1,575 @@ +#![cfg(not(feature = "local"))] +//! Test tool macros, including documentation for generated fns. + +//cargo test --test test_tool_macros --features "client server" +// Enforce that all generated code has sufficient docs to pass missing_docs lint +#![deny(missing_docs)] +#![allow(dead_code)] +use std::sync::Arc; + +use rmcp::{ + ClientHandler, ServerHandler, ServiceExt, + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{CallToolRequestParams, ClientInfo, ServerCapabilities, ServerInfo}, + tool, tool_handler, tool_router, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Parameters for weather tool. +#[derive(Serialize, Deserialize, JsonSchema)] +struct GetWeatherRequest { + /// City of interest. + city: String, + /// Date of interest. + date: String, +} + +#[tool_handler(router = self.tool_router)] +impl ServerHandler for Server {} + +/// Trivial stateless server. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct Server { + tool_router: ToolRouter, +} + +impl Server { + /// Create weather server. + pub fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } +} + +impl Default for Server { + fn default() -> Self { + Self::new() + } +} + +#[tool_router(router = tool_router)] +impl Server { + /// This tool is used to get the weather of a city. + #[tool(name = "get-weather", description = "Get the weather of a city.")] + pub async fn get_weather(&self, city: Parameters) -> String { + drop(city); + "rain".to_string() + } + + #[tool] + async fn empty_param(&self) {} +} + +/// Generic service trait. +pub trait DataService: Send + Sync + 'static { + /// Get data from service. + fn get_data(&self) -> String; +} + +// mock service for test +#[derive(Clone)] +struct MockDataService; +impl DataService for MockDataService { + fn get_data(&self) -> String { + "mock data".to_string() + } +} + +/// Generic server. +#[derive(Debug, Clone)] +pub struct GenericServer { + data_service: Arc, + tool_router: ToolRouter, +} + +#[tool_router] +impl GenericServer { + /// Create data server instance. + pub fn new(data_service: DS) -> Self { + Self { + data_service: Arc::new(data_service), + tool_router: Self::tool_router(), + } + } + + #[tool(description = "Get data from the service")] + async fn get_data(&self) -> String { + self.data_service.get_data() + } +} + +#[tool_handler] +impl ServerHandler for GenericServer {} + +#[tokio::test] +async fn test_tool_macros() { + let server = Server::new(); + let _attr = Server::get_weather_tool_attr(); + let _get_weather_tool_attr_fn = Server::get_weather_tool_attr; + let _get_weather_fn = Server::get_weather; + server + .get_weather(Parameters(GetWeatherRequest { + city: "Harbin".into(), + date: "Yesterday".into(), + })) + .await; +} + +#[tokio::test] +async fn test_tool_macros_with_empty_param() { + let _attr = Server::empty_param_tool_attr(); + println!("{_attr:?}"); + assert_eq!( + _attr.input_schema.get("type"), + Some(&serde_json::Value::String("object".to_string())) + ); + assert_eq!( + _attr.input_schema.get("properties"), + Some(&serde_json::Value::Object(serde_json::Map::new())) + ); +} + +#[tokio::test] +async fn test_tool_macros_with_generics() { + let mock_service = MockDataService; + let server = GenericServer::new(mock_service); + let _attr = GenericServer::::get_data_tool_attr(); + let _get_data_call_fn = GenericServer::::get_data; + let _get_data_fn = GenericServer::::get_data; + assert_eq!(server.get_data().await, "mock data"); +} + +#[tokio::test] +async fn test_tool_macros_with_optional_param() { + let _attr = Server::get_weather_tool_attr(); + // println!("{_attr:?}"); + let attr_type = _attr + .input_schema + .get("properties") + .unwrap() + .get("city") + .unwrap() + .get("type") + .unwrap(); + println!("_attr.input_schema: {:?}", attr_type); + assert_eq!(attr_type.as_str().unwrap(), "string"); +} + +impl GetWeatherRequest {} + +/// Struct defined for testing optional field schema generation. +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +struct OptionalFieldTestSchema { + /// Field description. + #[schemars(description = "An optional description field")] + description: Option, +} + +/// Struct defined for testing optional i64 field schema generation and null handling. +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +struct OptionalI64TestSchema { + /// Optional count field. + #[schemars(description = "An optional i64 field")] + count: Option, + + /// Added to ensure non-empty object schema. + mandatory_field: String, +} + +/// Dummy struct to host the test tool method. +#[derive(Debug, Clone)] +pub struct OptionalSchemaTester { + tool_router: ToolRouter, +} + +impl Default for OptionalSchemaTester { + fn default() -> Self { + Self::new() + } +} + +impl OptionalSchemaTester { + /// Create instance of optional schema tester service. + pub fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } +} + +#[tool_router] +impl OptionalSchemaTester { + // Dummy tool function using the test schema as an aggregated parameter + #[tool(description = "A tool to test optional schema generation")] + async fn test_optional(&self, _req: Parameters) { + // Implementation doesn't matter for schema testing + // Return type changed to () to satisfy IntoCallToolResult + } + + // Tool function to test optional i64 handling + #[tool(description = "A tool to test optional i64 schema generation")] + async fn test_optional_i64( + &self, + Parameters(req): Parameters, + ) -> String { + match req.count { + Some(c) => format!("Received count: {}", c), + None => "Received null count".to_string(), + } + } +} +#[tool_handler] +// Implement ServerHandler to route tool calls for OptionalSchemaTester +impl ServerHandler for OptionalSchemaTester {} + +#[test] +fn test_optional_field_schema_generation_via_macro() { + // tests https://github.com/modelcontextprotocol/rust-sdk/issues/135 + + // Get the attributes generated by the #[tool] macro helper + let tool_attr = OptionalSchemaTester::test_optional_tool_attr(); + + // Print the actual generated schema for debugging + println!( + "Actual input schema generated by macro: {:#?}", + tool_attr.input_schema + ); + + // Verify the schema generated for the aggregated OptionalFieldTestSchema + // by the macro infrastructure using JSON Schema 2020-12 settings. + let input_schema_map = &*tool_attr.input_schema; // Dereference Arc + + // Check the schema for the 'description' property within the input schema + let properties = input_schema_map + .get("properties") + .expect("Schema should have properties") + .as_object() + .unwrap(); + let description_schema = properties + .get("description") + .expect("Properties should include description") + .as_object() + .unwrap(); + + // Assert nullable Option is represented in JSON Schema 2020-12 form. + let type_value = description_schema + .get("type") + .expect("Schema for Option should include a type field"); + let type_array = type_value + .as_array() + .expect("Schema for Option should use a type array [T, null]"); + assert_eq!( + type_array, + &vec![serde_json::json!("string"), serde_json::json!("null")], + "Schema for Option should be type: [\"string\", \"null\"]" + ); + assert!( + description_schema.get("nullable").is_none(), + "Schema for Option should not use OpenAPI nullable in JSON Schema 2020-12" + ); + // We still check the description is correct + assert_eq!( + description_schema + .get("description") + .map(|v| v.as_str().unwrap()), + Some("An optional description field") + ); + + // Ensure no OpenAPI-only nullable extension was emitted. + assert!(description_schema.get("nullable").is_none()); +} + +// Define a dummy client handler +#[derive(Debug, Clone, Default)] +struct DummyClientHandler {} + +impl ClientHandler for DummyClientHandler { + fn get_info(&self) -> ClientInfo { + ClientInfo::default() + } +} + +#[tokio::test] +async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + // Server setup + let server = OptionalSchemaTester::new(); + let server_handle = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + + // Create a simple client handler that just forwards tool calls + let client_handler = DummyClientHandler::default(); + let client = client_handler.serve(client_transport).await?; + + // Test null case + let result = client + .call_tool( + CallToolRequestParams::new("test_optional_i64").with_arguments( + serde_json::json!({ + "count": null, + "mandatory_field": "test_null" + }) + .as_object() + .unwrap() + .clone(), + ), + ) + .await?; + + let result_text = result + .content + .first() + .and_then(|content| content.as_text()) + .map(|text| text.text.as_str()) + .expect("Expected text content"); + + assert_eq!( + result_text, "Received null count", + "Null case should return expected message" + ); + + // Test Some case + let some_result = client + .call_tool( + CallToolRequestParams::new("test_optional_i64").with_arguments( + serde_json::json!({ + "count": 42, + "mandatory_field": "test_some" + }) + .as_object() + .unwrap() + .clone(), + ), + ) + .await?; + + let some_result_text = some_result + .content + .first() + .and_then(|content| content.as_text()) + .map(|text| text.text.as_str()) + .expect("Expected text content"); + + assert_eq!( + some_result_text, "Received count: 42", + "Some case should return expected message" + ); + + client.cancel().await?; + server_handle.await??; + Ok(()) +} + +// --- Tests for field-free minimal server pattern (issue #711) --- + +/// Minimal server: no tool_router field, no new(), no get_info(). +#[derive(Debug, Clone)] +struct MinimalServer; + +#[tool_router] +impl MinimalServer { + #[tool(description = "Say hello")] + fn hello(&self) -> String { + "hello".to_string() + } +} + +#[tool_handler] +impl ServerHandler for MinimalServer {} + +#[test] +fn test_minimal_server_get_info_auto_generated() { + let server = MinimalServer; + let info = server.get_info(); + + assert!( + info.capabilities.tools.is_some(), + "tools capability should be enabled" + ); + assert!( + info.capabilities.prompts.is_none(), + "prompts should not be auto-enabled" + ); + assert!( + !info.capabilities.supports_tasks(), + "tasks extension should not be auto-enabled" + ); + assert!( + !info.server_info.name.is_empty(), + "server name should not be empty" + ); + assert!( + !info.server_info.version.is_empty(), + "server version should not be empty" + ); + assert!( + info.instructions.is_none(), + "instructions should be None by default" + ); +} + +#[tokio::test] +async fn test_minimal_server_tool_call() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let server_handle = tokio::spawn(async move { + MinimalServer + .serve(server_transport) + .await? + .waiting() + .await?; + anyhow::Ok(()) + }); + + let client = DummyClientHandler::default() + .serve(client_transport) + .await?; + + let result = client + .call_tool(CallToolRequestParams::new("hello")) + .await?; + + let text = result + .content + .first() + .and_then(|c| c.as_text()) + .map(|t| t.text.as_str()) + .expect("Expected text content"); + + assert_eq!(text, "hello"); + + client.cancel().await?; + server_handle.await??; + Ok(()) +} + +/// Same minimal pattern as [`MinimalServer`], but `#[tool_handler]` is omitted using +/// `#[tool_router(server_handler)]` (emits `#[tool_handler]` for a second macro pass). +#[derive(Debug, Clone)] +struct ElidedToolHandlerServer; + +#[tool_router(server_handler)] +impl ElidedToolHandlerServer { + #[tool(description = "Say hi")] + fn hi(&self) -> String { + "hi".to_string() + } +} + +#[test] +fn test_tool_router_server_handler_flag_matches_minimal_server_get_info() { + let server = ElidedToolHandlerServer; + let info = server.get_info(); + + assert!(info.capabilities.tools.is_some()); + assert!( + info.capabilities.prompts.is_none(), + "prompts should not be auto-enabled" + ); +} + +#[tokio::test] +async fn test_tool_router_server_handler_flag_end_to_end_tool_call() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let server_handle = tokio::spawn(async move { + ElidedToolHandlerServer + .serve(server_transport) + .await? + .waiting() + .await?; + anyhow::Ok(()) + }); + + let client = DummyClientHandler::default() + .serve(client_transport) + .await?; + + let result = client.call_tool(CallToolRequestParams::new("hi")).await?; + + let text = result + .content + .first() + .and_then(|c| c.as_text()) + .map(|t| t.text.as_str()) + .expect("Expected text content"); + + assert_eq!(text, "hi"); + + client.cancel().await?; + server_handle.await??; + Ok(()) +} + +/// Server with custom name/version/instructions via tool_handler attributes. +#[derive(Debug, Clone)] +struct CustomInfoServer; + +#[tool_router] +impl CustomInfoServer { + #[tool(description = "Ping")] + fn ping(&self) -> String { + "pong".to_string() + } +} + +#[tool_handler( + name = "my-custom-server", + version = "2.0.0", + instructions = "A custom server" +)] +impl ServerHandler for CustomInfoServer {} + +#[test] +fn test_custom_info_server() { + let server = CustomInfoServer; + let info = server.get_info(); + + assert_eq!(info.server_info.name, "my-custom-server"); + assert_eq!(info.server_info.version, "2.0.0"); + assert_eq!(info.instructions.as_deref(), Some("A custom server")); + assert!(info.capabilities.tools.is_some()); +} + +/// Server that provides its own get_info() — macro should not override it. +#[derive(Debug, Clone)] +struct ManualInfoServer; + +#[tool_router] +impl ManualInfoServer { + #[tool(description = "Noop")] + fn noop(&self) {} +} + +#[tool_handler] +impl ServerHandler for ManualInfoServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .build(), + ) + .with_server_info(rmcp::model::Implementation::new("manual", "9.9.9")) + } +} + +#[test] +fn test_manual_get_info_not_overridden() { + let server = ManualInfoServer; + let info = server.get_info(); + + assert_eq!(info.server_info.name, "manual"); + assert_eq!(info.server_info.version, "9.9.9"); + assert!(info.capabilities.tools.is_some()); + assert!( + info.capabilities.resources.is_some(), + "manual resources should be preserved" + ); +} diff --git a/vendor/rmcp/tests/test_tool_result_meta.rs b/vendor/rmcp/tests/test_tool_result_meta.rs new file mode 100644 index 000000000..37585ac76 --- /dev/null +++ b/vendor/rmcp/tests/test_tool_result_meta.rs @@ -0,0 +1,41 @@ +use rmcp::model::{CallToolResult, ContentBlock, MetaObject}; +use serde_json::{Value, json}; + +#[test] +fn serialize_tool_result_with_meta() { + let content = vec![ContentBlock::text("ok")]; + let mut meta = MetaObject::new(); + meta.insert("foo".to_string(), json!("bar")); + let result = CallToolResult::success(content).with_meta(Some(meta)); + let v = serde_json::to_value(&result).unwrap(); + let expected = json!({ + "resultType": "complete", + "content": [{"type":"text","text":"ok"}], + "isError": false, + "_meta": {"foo":"bar"} + }); + assert_eq!(v, expected); +} + +#[test] +fn deserialize_tool_result_with_meta() { + let raw: Value = json!({ + "content": [{"type":"text","text":"hello"}], + "isError": true, + "_meta": {"a": 1, "b": "two"} + }); + let result: CallToolResult = serde_json::from_value(raw).unwrap(); + assert_eq!(result.is_error, Some(true)); + assert_eq!(result.content.len(), 1); + let meta = result.meta.expect("meta should exist"); + assert_eq!(meta.get("a").unwrap(), &json!(1)); + assert_eq!(meta.get("b").unwrap(), &json!("two")); +} + +#[test] +fn serialize_tool_result_without_meta_omits_field() { + let result = CallToolResult::success(vec![ContentBlock::text("no meta")]); + let v = serde_json::to_value(&result).unwrap(); + // Ensure _meta is omitted + assert!(v.get("_meta").is_none()); +} diff --git a/vendor/rmcp/tests/test_tool_routers.rs b/vendor/rmcp/tests/test_tool_routers.rs new file mode 100644 index 000000000..d2bbe8687 --- /dev/null +++ b/vendor/rmcp/tests/test_tool_routers.rs @@ -0,0 +1,372 @@ +#![cfg(not(feature = "local"))] +use std::{ + collections::HashMap, + sync::atomic::{AtomicUsize, Ordering}, +}; + +use futures::future::BoxFuture; +use rmcp::{ + ServerHandler, + handler::server::{router::tool::ToolRouter, tool::CallToolHandler, wrapper::Parameters}, +}; + +#[derive(Debug, Default)] +struct TestHandler { + _marker: std::marker::PhantomData, +} + +impl ServerHandler for TestHandler {} +#[derive(Debug, schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +struct Request { + fields: HashMap, +} + +#[derive(Debug, schemars::JsonSchema, serde::Deserialize, serde::Serialize)] +struct Sum { + a: i32, + b: i32, +} + +#[rmcp::tool_router(router = test_router_1)] +impl TestHandler { + #[rmcp::tool] + async fn async_method(&self, Parameters(Request { fields }): Parameters) { + drop(fields) + } +} + +#[rmcp::tool_router(router = test_router_2)] +impl TestHandler { + #[rmcp::tool] + fn sync_method(&self, Parameters(Request { fields }): Parameters) { + drop(fields) + } +} + +#[rmcp::tool] +async fn async_function(Parameters(Request { fields }): Parameters) { + drop(fields) +} + +#[rmcp::tool] +fn async_function2(_callee: &TestHandler) -> BoxFuture<'_, ()> { + Box::pin(async move {}) +} + +#[test] +fn test_tool_router() { + let test_tool_router: ToolRouter> = ToolRouter::>::new() + .with_route((async_function_tool_attr(), async_function)) + .with_route((async_function2_tool_attr(), async_function2)) + + TestHandler::<()>::test_router_1() + + TestHandler::<()>::test_router_2(); + let tools = test_tool_router.list_all(); + assert_eq!(tools.len(), 4); + assert_handler(TestHandler::<()>::async_method); +} + +fn assert_handler(_handler: H) +where + H: CallToolHandler, +{ +} + +#[test] +fn test_tool_router_list_all_is_sorted() { + let router: ToolRouter> = ToolRouter::>::new() + .with_route((async_function_tool_attr(), async_function)) + .with_route((async_function2_tool_attr(), async_function2)) + + TestHandler::<()>::test_router_1() + + TestHandler::<()>::test_router_2(); + let tools = router.list_all(); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + let mut sorted = names.clone(); + sorted.sort(); + assert_eq!( + names, sorted, + "list_all() should return tools sorted alphabetically by name" + ); +} + +fn build_router() -> ToolRouter> { + ToolRouter::>::new() + .with_route((async_function_tool_attr(), async_function)) + .with_route((async_function2_tool_attr(), async_function2)) + + TestHandler::<()>::test_router_1() + + TestHandler::<()>::test_router_2() +} + +#[test] +fn test_disable_route() { + let mut router = build_router(); + assert_eq!(router.list_all().len(), 4); + assert!(router.has_route("async_function")); + assert!(router.get("async_function").is_some()); + + assert!(router.disable_route("async_function")); + + assert_eq!(router.list_all().len(), 3); + assert!(!router.has_route("async_function")); + assert!(router.get("async_function").is_none()); + assert!(router.is_disabled("async_function")); + + // other tools unaffected + assert!(router.has_route("async_function2")); + assert!(router.get("async_function2").is_some()); + assert!(!router.is_disabled("async_function2")); +} + +#[test] +fn test_enable_route() { + let mut router = build_router(); + assert!(router.disable_route("async_function")); + assert!(!router.has_route("async_function")); + + assert!(router.enable_route("async_function")); + assert!(router.has_route("async_function")); + assert!(router.get("async_function").is_some()); + assert!(!router.is_disabled("async_function")); + assert_eq!(router.list_all().len(), 4); +} + +#[test] +fn test_with_disabled_builder() { + let router = build_router() + .with_disabled("async_function") + .with_disabled("sync_method"); + + assert_eq!(router.list_all().len(), 2); + assert!(!router.has_route("async_function")); + assert!(!router.has_route("sync_method")); + assert!(router.has_route("async_function2")); + assert!(router.has_route("async_method")); +} + +#[test] +fn test_disabled_tools_survive_merge() { + let mut router_a = ToolRouter::>::new() + .with_route((async_function_tool_attr(), async_function)); + assert!(router_a.disable_route("async_function")); + + let router_b = ToolRouter::>::new() + .with_route((async_function2_tool_attr(), async_function2)); + + router_a.merge(router_b); + + assert_eq!(router_a.list_all().len(), 1); + assert!(router_a.is_disabled("async_function")); + assert!(router_a.has_route("async_function2")); +} + +#[test] +fn test_disable_nonexistent_tool() { + let mut router = build_router(); + // should not panic; returns true because the name is newly added to disabled set + assert!(router.disable_route("does_not_exist")); + assert_eq!(router.list_all().len(), 4); + // is_disabled returns false for tools not in the map + assert!(!router.is_disabled("does_not_exist")); +} + +#[test] +fn test_remove_route_preserves_disabled_state() { + let mut router = build_router(); + assert!(router.disable_route("async_function")); + assert!(router.is_disabled("async_function")); + + router.remove_route("async_function"); + assert!(!router.has_route("async_function")); + // Disabled marker is preserved — is_disabled returns false (no route in map) + // but re-adding will inherit the disabled state (tested separately) + assert!(!router.is_disabled("async_function")); +} + +#[test] +fn test_remove_route_then_readd_stays_disabled() { + let mut router = build_router(); + assert!(router.disable_route("async_function")); + + router.remove_route("async_function"); + assert!(!router.has_route("async_function")); + + // Re-add the route — it should inherit the disabled state + let other = ToolRouter::>::new() + .with_route((async_function_tool_attr(), async_function)); + router.merge(other); + + assert!(!router.has_route("async_function")); + assert!(router.is_disabled("async_function")); + assert!(router.get("async_function").is_none()); +} + +#[test] +fn test_into_iter_skips_disabled() { + let router = build_router().with_disabled("async_function"); + let names: Vec<_> = router + .into_iter() + .map(|r| r.attr.name.to_string()) + .collect(); + assert_eq!(names.len(), 3); + assert!(!names.contains(&"async_function".to_string())); +} + +#[test] +fn test_pre_disable_before_add_route() { + // Disabling a name before adding a route with that name should + // result in the route being disabled once added. + let router = ToolRouter::>::new() + .with_disabled("async_function") + .with_route((async_function_tool_attr(), async_function)); + + assert_eq!(router.list_all().len(), 0); + assert!(router.is_disabled("async_function")); + assert!(!router.has_route("async_function")); +} + +#[test] +fn test_disabled_tool_invisible_across_all_queries() { + let router = build_router().with_disabled("async_function"); + + // Not listed + let names: Vec<_> = router.list_all().iter().map(|t| t.name.clone()).collect(); + assert!(!names.contains(&"async_function".into())); + // Not retrievable + assert!(router.get("async_function").is_none()); + // Not routable + assert!(!router.has_route("async_function")); + // But known as disabled + assert!(router.is_disabled("async_function")); +} + +#[test] +fn test_disable_route_then_add_route_blocks_tool() { + // Full pre-disable lifecycle via runtime mutation (not builder) + let mut router = ToolRouter::>::new(); + router.disable_route("async_function"); + + // Add route after disabling — tool should be blocked + let other = ToolRouter::>::new() + .with_route((async_function_tool_attr(), async_function)); + router.merge(other); + + assert!(router.is_disabled("async_function")); + assert!(!router.has_route("async_function")); + assert!(router.get("async_function").is_none()); + assert_eq!(router.list_all().len(), 0); +} + +#[test] +fn test_disable_enable_return_false_cases() { + let mut router = build_router(); + + // Repeated disable returns false + assert!(router.disable_route("async_function")); + assert!(!router.disable_route("async_function")); + + // Enable returns true, then false on repeat + assert!(router.enable_route("async_function")); + assert!(!router.enable_route("async_function")); + + // Enable on name never disabled returns false + assert!(!router.enable_route("async_function2")); + + // Enable on unknown name returns false + assert!(!router.enable_route("unknown")); +} + +// ── Notifier tests ────────────────────────────────────────────────────── + +fn counter_notifier() -> ( + impl Fn() + Send + Sync + 'static, + std::sync::Arc, +) { + let counter = std::sync::Arc::new(AtomicUsize::new(0)); + let c = counter.clone(); + let notifier = move || { + c.fetch_add(1, Ordering::SeqCst); + }; + (notifier, counter) +} + +#[test] +fn test_notifier_fires_on_disable_and_enable() { + let (notifier, counter) = counter_notifier(); + let mut router = build_router(); + router.set_notifier(notifier); + + assert!(router.disable_route("async_function")); + assert_eq!(counter.load(Ordering::SeqCst), 1); + + assert!(!router.disable_route("async_function")); + assert_eq!(counter.load(Ordering::SeqCst), 1); + + assert!(router.enable_route("async_function")); + assert_eq!(counter.load(Ordering::SeqCst), 2); + + assert!(!router.enable_route("async_function")); + assert_eq!(counter.load(Ordering::SeqCst), 2); +} + +#[test] +fn test_notifier_skips_nonexistent_tools() { + let (notifier, counter) = counter_notifier(); + let mut router = build_router(); + router.set_notifier(notifier); + + assert!(router.disable_route("does_not_exist")); + assert_eq!(counter.load(Ordering::SeqCst), 0); + + assert!(router.enable_route("does_not_exist")); + assert_eq!(counter.load(Ordering::SeqCst), 0); + + assert!(router.disable_route("future_tool")); + assert_eq!(counter.load(Ordering::SeqCst), 0); + assert!(router.enable_route("future_tool")); + assert_eq!(counter.load(Ordering::SeqCst), 0); +} + +#[test] +fn test_no_notifier_no_panic() { + let mut router = build_router(); + assert!(router.disable_route("async_function")); + assert!(router.enable_route("async_function")); + assert!(router.disable_route("async_function")); + assert!(!router.disable_route("async_function")); +} + +#[test] +fn test_clone_shares_notifier() { + let (notifier, counter) = counter_notifier(); + let mut router = build_router(); + router.set_notifier(notifier); + let mut cloned = router.clone(); + + assert!(cloned.disable_route("async_function")); + assert_eq!(counter.load(Ordering::SeqCst), 1); + + assert!(router.disable_route("async_function")); + assert_eq!(counter.load(Ordering::SeqCst), 2); + + cloned.clear_notifier(); + assert!(cloned.enable_route("async_function")); + assert_eq!(counter.load(Ordering::SeqCst), 2); + + assert!(router.enable_route("async_function")); + assert_eq!(counter.load(Ordering::SeqCst), 3); +} + +#[test] +fn test_pre_init_disable_silent_but_correct() { + let mut router = build_router(); + + assert!(router.disable_route("async_function")); + assert_eq!(router.list_all().len(), 3); + assert!(!router.has_route("async_function")); + + let (notifier, counter) = counter_notifier(); + router.set_notifier(notifier); + assert_eq!(counter.load(Ordering::SeqCst), 0); + + assert!(router.enable_route("async_function")); + assert_eq!(counter.load(Ordering::SeqCst), 1); +} diff --git a/vendor/rmcp/tests/test_trace_context.rs b/vendor/rmcp/tests/test_trace_context.rs new file mode 100644 index 000000000..15354a497 --- /dev/null +++ b/vendor/rmcp/tests/test_trace_context.rs @@ -0,0 +1,85 @@ +#![cfg(not(feature = "local"))] +//! SEP-414: the reserved trace-context `_meta` keys survive a client→server round trip unchanged. +use std::sync::Arc; + +use rmcp::{ + RoleServer, ServerHandler, ServiceExt, + model::{ClientRequest, CustomRequest, CustomResult, RequestMetaObject}, + service::{PeerRequestOptions, RequestContext}, +}; +use serde_json::json; +use tokio::sync::{Mutex, Notify}; + +const TRACEPARENT: &str = "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"; +const TRACESTATE: &str = "vendor1=value1,vendor2=value2"; +const BAGGAGE: &str = "userId=alice,region=us-east-1"; + +/// Records the `_meta` it receives on the incoming request so the test can assert passthrough. +struct TraceCapturingServer { + receive_signal: Arc, + seen: Arc>>, +} + +impl ServerHandler for TraceCapturingServer { + async fn on_custom_request( + &self, + _request: CustomRequest, + context: RequestContext, + ) -> Result { + *self.seen.lock().await = Some(context.meta); + self.receive_signal.notify_one(); + Ok(CustomResult::new(json!({ "status": "ok" }))) + } +} + +#[tokio::test] +async fn trace_context_meta_survives_round_trip() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let receive_signal = Arc::new(Notify::new()); + let seen = Arc::new(Mutex::new(None)); + + { + let receive_signal = receive_signal.clone(); + let seen = seen.clone(); + tokio::spawn(async move { + let server = TraceCapturingServer { + receive_signal, + seen, + } + .serve(server_transport) + .await?; + server.waiting().await?; + anyhow::Ok(()) + }); + } + + let client = ().serve(client_transport).await?; + + // Client attaches trace context to the outgoing request's `_meta`. + let mut meta = RequestMetaObject::new(); + meta.set_traceparent(TRACEPARENT); + meta.set_tracestate(TRACESTATE); + meta.set_baggage(BAGGAGE); + + let mut options = PeerRequestOptions::no_options(); + options.meta = Some(meta); + client + .send_cancellable_request( + ClientRequest::CustomRequest(CustomRequest::new("requests/trace-test", None)), + options, + ) + .await? + .await_response() + .await?; + + tokio::time::timeout(std::time::Duration::from_secs(5), receive_signal.notified()).await?; + + // Server saw the reserved keys unchanged (alongside the injected progressToken). + let seen = seen.lock().await.take().expect("server observed meta"); + assert_eq!(seen.get_traceparent(), Some(TRACEPARENT)); + assert_eq!(seen.get_tracestate(), Some(TRACESTATE)); + assert_eq!(seen.get_baggage(), Some(BAGGAGE)); + + client.cancel().await?; + Ok(()) +} diff --git a/vendor/rmcp/tests/test_unix_socket_transport.rs b/vendor/rmcp/tests/test_unix_socket_transport.rs new file mode 100644 index 000000000..4c4ad52f1 --- /dev/null +++ b/vendor/rmcp/tests/test_unix_socket_transport.rs @@ -0,0 +1,298 @@ +#![cfg(all( + unix, + feature = "transport-streamable-http-client-unix-socket", + not(feature = "local") +))] + +use std::{collections::HashMap, sync::Arc}; + +use axum::{ + Router, body::Bytes, extract::State, http::StatusCode, response::IntoResponse, routing::post, +}; +use http::{HeaderName, HeaderValue}; +use hyper_util::rt::TokioIo; +use rmcp::{ + ServiceExt, + transport::{ + StreamableHttpClientTransport, UnixSocketHttpClient, + streamable_http_client::StreamableHttpClientTransportConfig, + }, +}; +use serde_json::json; +use tokio::sync::Mutex; + +#[derive(Clone)] +struct ServerState { + received_headers: Arc>>, + initialize_called: Arc, +} + +async fn mcp_handler( + State(state): State, + headers: http::HeaderMap, + body: Bytes, +) -> impl IntoResponse { + let mut headers_map = HashMap::new(); + for (name, value) in headers.iter() { + let name_str = name.as_str(); + if name_str.starts_with("x-") || name_str == "host" { + if let Ok(v) = value.to_str() { + headers_map.insert(name_str.to_string(), v.to_string()); + } + } + } + + let mut stored = state.received_headers.lock().await; + stored.extend(headers_map); + drop(stored); + + if let Ok(json_body) = serde_json::from_slice::(&body) { + if let Some(method) = json_body.get("method").and_then(|m| m.as_str()) { + if method == "initialize" { + state.initialize_called.notify_one(); + let response = json!({ + "jsonrpc": "2.0", + "id": json_body.get("id"), + "result": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "serverInfo": { + "name": "test-unix-server", + "version": "1.0.0" + } + } + }); + return ( + StatusCode::OK, + [ + (http::header::CONTENT_TYPE, "application/json"), + ( + http::HeaderName::from_static("mcp-session-id"), + "unix-test-session", + ), + ], + response.to_string(), + ); + } else if method == "notifications/initialized" { + return ( + StatusCode::ACCEPTED, + [ + (http::header::CONTENT_TYPE, "application/json"), + ( + http::HeaderName::from_static("mcp-session-id"), + "unix-test-session", + ), + ], + String::new(), + ); + } + } + } + + let request_id = serde_json::from_slice::(&body) + .ok() + .and_then(|j| j.get("id").cloned()) + .unwrap_or(serde_json::Value::Null); + let response = json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": {} + }); + ( + StatusCode::OK, + [ + (http::header::CONTENT_TYPE, "application/json"), + ( + http::HeaderName::from_static("mcp-session-id"), + "unix-test-session", + ), + ], + response.to_string(), + ) +} + +/// Spawns an HTTP/1.1 server on a Unix socket using hyper directly. +/// Avoids `axum::serve(UnixListener, ...)` which uses `spawn_local` on Linux. +fn spawn_unix_server( + listener: tokio::net::UnixListener, + app: Router, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let tower_service = app.clone(); + tokio::spawn(async move { + let io = TokioIo::new(stream); + let hyper_service = hyper::service::service_fn( + move |req: hyper::Request| { + let mut tower_service = tower_service.clone(); + async move { + use tower_service::Service; + tower_service.call(req).await + } + }, + ); + hyper::server::conn::http1::Builder::new() + .serve_connection(io, hyper_service) + .await + .ok(); + }); + } + }) +} + +/// Integration test: MCP client connects and completes handshake over a Unix domain socket. +#[tokio::test] +async fn test_unix_socket_mcp_handshake() -> anyhow::Result<()> { + let dir = std::env::temp_dir().join(format!("rmcp-test-{}", std::process::id())); + std::fs::create_dir_all(&dir)?; + let socket_path = dir.join("mcp.sock"); + + let _ = std::fs::remove_file(&socket_path); + + let state = ServerState { + received_headers: Arc::new(Mutex::new(HashMap::new())), + initialize_called: Arc::new(tokio::sync::Notify::new()), + }; + + let app = Router::new() + .route("/mcp", post(mcp_handler)) + .with_state(state.clone()); + + let listener = tokio::net::UnixListener::bind(&socket_path)?; + let server_handle = spawn_unix_server(listener, app); + + let socket_str = socket_path.to_str().unwrap(); + let uri = "http://mcp-server.internal/mcp"; + let client = UnixSocketHttpClient::new(socket_str, uri); + let config = StreamableHttpClientTransportConfig::with_uri(uri); + let transport = StreamableHttpClientTransport::with_client(client, config); + + let mcp_client = ().serve(transport).await.expect("MCP handshake should succeed"); + + tokio::time::timeout( + std::time::Duration::from_secs(5), + state.initialize_called.notified(), + ) + .await + .expect("Initialize request should be received"); + + let headers = state.received_headers.lock().await; + assert_eq!( + headers.get("host"), + Some(&"mcp-server.internal".to_string()), + "Host header should be derived from URI" + ); + + drop(mcp_client); + server_handle.abort(); + let _ = std::fs::remove_file(&socket_path); + let _ = std::fs::remove_dir(&dir); + + Ok(()) +} + +/// Integration test: Custom headers are sent through the Unix socket transport. +#[tokio::test] +async fn test_unix_socket_custom_headers() -> anyhow::Result<()> { + let dir = std::env::temp_dir().join(format!("rmcp-test-headers-{}", std::process::id())); + std::fs::create_dir_all(&dir)?; + let socket_path = dir.join("mcp.sock"); + let _ = std::fs::remove_file(&socket_path); + + let state = ServerState { + received_headers: Arc::new(Mutex::new(HashMap::new())), + initialize_called: Arc::new(tokio::sync::Notify::new()), + }; + + let app = Router::new() + .route("/mcp", post(mcp_handler)) + .with_state(state.clone()); + + let listener = tokio::net::UnixListener::bind(&socket_path)?; + let server_handle = spawn_unix_server(listener, app); + + let mut custom_headers = HashMap::new(); + custom_headers.insert( + HeaderName::from_static("x-test-header"), + HeaderValue::from_static("test-value-123"), + ); + custom_headers.insert( + HeaderName::from_static("x-client-id"), + HeaderValue::from_static("unix-test-client"), + ); + + let socket_str = socket_path.to_str().unwrap(); + let uri = "http://mcp-server.internal/mcp"; + let client = UnixSocketHttpClient::new(socket_str, uri); + let config = StreamableHttpClientTransportConfig::with_uri(uri).custom_headers(custom_headers); + let transport = StreamableHttpClientTransport::with_client(client, config); + + let mcp_client = ().serve(transport).await.expect("MCP handshake should succeed"); + + tokio::time::timeout( + std::time::Duration::from_secs(5), + state.initialize_called.notified(), + ) + .await + .expect("Initialize request should be received"); + + let headers = state.received_headers.lock().await; + assert_eq!( + headers.get("x-test-header"), + Some(&"test-value-123".to_string()), + "Custom header x-test-header should be received" + ); + assert_eq!( + headers.get("x-client-id"), + Some(&"unix-test-client".to_string()), + "Custom header x-client-id should be received" + ); + + drop(mcp_client); + server_handle.abort(); + let _ = std::fs::remove_file(&socket_path); + let _ = std::fs::remove_dir(&dir); + + Ok(()) +} + +/// Integration test: Convenience constructor `from_unix_socket` works end-to-end. +#[tokio::test] +async fn test_unix_socket_convenience_constructor() -> anyhow::Result<()> { + let dir = std::env::temp_dir().join(format!("rmcp-test-conv-{}", std::process::id())); + std::fs::create_dir_all(&dir)?; + let socket_path = dir.join("mcp.sock"); + let _ = std::fs::remove_file(&socket_path); + + let state = ServerState { + received_headers: Arc::new(Mutex::new(HashMap::new())), + initialize_called: Arc::new(tokio::sync::Notify::new()), + }; + + let app = Router::new() + .route("/mcp", post(mcp_handler)) + .with_state(state.clone()); + + let listener = tokio::net::UnixListener::bind(&socket_path)?; + let server_handle = spawn_unix_server(listener, app); + + let socket_str = socket_path.to_str().unwrap(); + let transport = + StreamableHttpClientTransport::from_unix_socket(socket_str, "http://localhost/mcp"); + + let mcp_client = ().serve(transport).await.expect("MCP handshake should succeed"); + + tokio::time::timeout( + std::time::Duration::from_secs(5), + state.initialize_called.notified(), + ) + .await + .expect("Initialize request should be received"); + + drop(mcp_client); + server_handle.abort(); + let _ = std::fs::remove_file(&socket_path); + let _ = std::fs::remove_dir(&dir); + + Ok(()) +} diff --git a/vendor/rmcp/tests/test_with_js.rs b/vendor/rmcp/tests/test_with_js.rs new file mode 100644 index 000000000..8992f8161 --- /dev/null +++ b/vendor/rmcp/tests/test_with_js.rs @@ -0,0 +1,136 @@ +#![cfg(not(feature = "local"))] +use rmcp::{ + ServiceExt, + service::QuitReason, + transport::{ + ConfigureCommandExt, StreamableHttpClientTransport, StreamableHttpServerConfig, + TokioChildProcess, + streamable_http_server::{ + session::local::LocalSessionManager, tower::StreamableHttpService, + }, + }, +}; +use tokio_util::sync::CancellationToken; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +mod common; +use common::calculator::Calculator; + +const STREAMABLE_HTTP_BIND_ADDRESS: &str = "127.0.0.1:8001"; +const STREAMABLE_HTTP_JS_BIND_ADDRESS: &str = "127.0.0.1:8002"; +// These tests run concurrently, while npm mutates their shared node_modules directory. +static JS_DEPENDENCIES_INSTALLED: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new(); + +async fn install_js_dependencies() -> anyhow::Result<()> { + JS_DEPENDENCIES_INSTALLED + .get_or_try_init(|| async { + let status = tokio::process::Command::new("npm") + .arg("install") + .current_dir("tests/test_with_js") + .status() + .await?; + anyhow::ensure!(status.success(), "npm install failed with {status}"); + Ok(()) + }) + .await?; + Ok(()) +} + +#[tokio::test] +async fn test_with_js_stdio_server() -> anyhow::Result<()> { + let _ = tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "debug".to_string().into()), + ) + .with(tracing_subscriber::fmt::layer()) + .try_init(); + install_js_dependencies().await?; + let transport = + TokioChildProcess::new(tokio::process::Command::new("node").configure(|cmd| { + cmd.arg("tests/test_with_js/server.js"); + }))?; + + let client = ().serve(transport).await?; + let resources = client.list_all_resources().await?; + tracing::info!("{:#?}", resources); + let tools = client.list_all_tools().await?; + tracing::info!("{:#?}", tools); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn test_with_js_streamable_http_client() -> anyhow::Result<()> { + let _ = tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "debug".to_string().into()), + ) + .with(tracing_subscriber::fmt::layer()) + .try_init(); + install_js_dependencies().await?; + + let ct = CancellationToken::new(); + let service: StreamableHttpService = + StreamableHttpService::new( + || Ok(Calculator::new()), + Default::default(), + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ); + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind(STREAMABLE_HTTP_BIND_ADDRESS).await?; + + let handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + let exit_status = tokio::process::Command::new("node") + .arg("tests/test_with_js/streamable_client.js") + .spawn()? + .wait() + .await?; + assert!(exit_status.success()); + ct.cancel(); + handle.await?; + Ok(()) +} + +#[tokio::test] +async fn test_with_js_streamable_http_server() -> anyhow::Result<()> { + let _ = tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "debug".to_string().into()), + ) + .with(tracing_subscriber::fmt::layer()) + .try_init(); + install_js_dependencies().await?; + + let transport = StreamableHttpClientTransport::from_uri(format!( + "http://{STREAMABLE_HTTP_JS_BIND_ADDRESS}/mcp" + )); + + let mut server = tokio::process::Command::new("node") + .arg("tests/test_with_js/streamable_server.js") + .spawn()?; + + // waiting for server up + tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; + + let client = ().serve(transport).await?; + let resources = client.list_all_resources().await?; + tracing::info!("{:#?}", resources); + let tools = client.list_all_tools().await?; + tracing::info!("{:#?}", tools); + let quit_reason = client.cancel().await?; + server.kill().await?; + assert!(matches!(quit_reason, QuitReason::Cancelled)); + Ok(()) +} diff --git a/vendor/rmcp/tests/test_with_js/.gitignore b/vendor/rmcp/tests/test_with_js/.gitignore new file mode 100644 index 000000000..572406bfd --- /dev/null +++ b/vendor/rmcp/tests/test_with_js/.gitignore @@ -0,0 +1,2 @@ +/node_modules +package-lock.json \ No newline at end of file diff --git a/vendor/rmcp/tests/test_with_js/client.js b/vendor/rmcp/tests/test_with_js/client.js new file mode 100644 index 000000000..17b189a74 --- /dev/null +++ b/vendor/rmcp/tests/test_with_js/client.js @@ -0,0 +1,29 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; + +const transport = new SSEClientTransport(new URL(`http://127.0.0.1:8000/sse`)); + +const client = new Client( + { + name: "example-client", + version: "1.0.0" + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {} + } + } +); +await client.connect(transport); +const tools = await client.listTools(); +console.log(tools); +const resources = await client.listResources(); +console.log(resources); +const templates = await client.listResourceTemplates(); +console.log(templates); +const prompts = await client.listPrompts(); +console.log(prompts); +await client.close(); +await transport.close(); \ No newline at end of file diff --git a/vendor/rmcp/tests/test_with_js/package.json b/vendor/rmcp/tests/test_with_js/package.json new file mode 100644 index 000000000..d8ecfbfb9 --- /dev/null +++ b/vendor/rmcp/tests/test_with_js/package.json @@ -0,0 +1,17 @@ +{ + "dependencies": { + "@modelcontextprotocol/sdk": "^1.10", + "eventsource-parser": "^3.0.1", + "express": "^5.1.0" + }, + "type": "module", + "name": "test_with_ts", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "description": "" +} diff --git a/vendor/rmcp/tests/test_with_js/server.js b/vendor/rmcp/tests/test_with_js/server.js new file mode 100644 index 000000000..d959224db --- /dev/null +++ b/vendor/rmcp/tests/test_with_js/server.js @@ -0,0 +1,35 @@ +import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; + +const server = new McpServer({ + name: "Demo", + version: "1.0.0" +}); + +server.resource( + "greeting", + new ResourceTemplate("greeting://{name}", { list: undefined }), + async (uri, { name }) => ({ + contents: [{ + uri: uri.href, + text: `Hello, ${name}` + }] + }) +); + +server.tool( + "add", + { a: z.number(), b: z.number() }, + async ({ a, b }) => ({ + "content": [ + { + "type": "text", + "text": `${a + b}` + } + ] + }) +); + +const transport = new StdioServerTransport(); +await server.connect(transport); \ No newline at end of file diff --git a/vendor/rmcp/tests/test_with_js/streamable_client.js b/vendor/rmcp/tests/test_with_js/streamable_client.js new file mode 100644 index 000000000..998261312 --- /dev/null +++ b/vendor/rmcp/tests/test_with_js/streamable_client.js @@ -0,0 +1,28 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:8001/mcp/`)); + +const client = new Client( + { + name: "example-client", + version: "1.0.0" + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {} + } + } +); +await client.connect(transport); +const tools = await client.listTools(); +console.log(tools); +const resources = await client.listResources(); +console.log(resources); +const templates = await client.listResourceTemplates(); +console.log(templates); +const prompts = await client.listPrompts(); +console.log(prompts); +await client.close(); diff --git a/vendor/rmcp/tests/test_with_js/streamable_server.js b/vendor/rmcp/tests/test_with_js/streamable_server.js new file mode 100644 index 000000000..3f87ccf01 --- /dev/null +++ b/vendor/rmcp/tests/test_with_js/streamable_server.js @@ -0,0 +1,105 @@ +import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js" +import { randomUUID } from "node:crypto" +import { z } from "zod"; +import express from "express" + +const app = express(); +app.use(express.json()); + +// Map to store transports by session ID +const transports = {}; + +// Handle POST requests for client-to-server communication +app.post('/mcp', async (req, res) => { + // Check for existing session ID + const sessionId = req.headers['mcp-session-id']; + let transport; + + if (sessionId && transports[sessionId]) { + // Reuse existing transport + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // New initialization request + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID().toString(), + onsessioninitialized: (sessionId) => { + // Store the transport by session ID + transports[sessionId] = transport; + } + }); + + // Clean up transport when closed + transport.onclose = () => { + if (transport.sessionId) { + delete transports[transport.sessionId]; + } + }; + const server = new McpServer({ + name: "example-server", + version: "1.0.0" + }); + + server.resource( + "greeting", + new ResourceTemplate("greeting://{name}", { list: undefined }), + async (uri, { name }) => ({ + contents: [{ + uri: uri.href, + text: `Hello, ${name}` + }] + }) + ); + + server.tool( + "add", + { a: z.number(), b: z.number() }, + async ({ a, b }) => ({ + "content": [ + { + "type": "text", + "text": `${a + b}` + } + ] + }) + ); + + // Connect to the MCP server + await server.connect(transport); + } else { + // Invalid request + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided', + }, + id: null, + }); + return; + } + + // Handle the request + await transport.handleRequest(req, res, req.body); +}); + +// Reusable handler for GET and DELETE requests +const handleSessionRequest = async (req, res) => { + const sessionId = req.headers['mcp-session-id']; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + const transport = transports[sessionId]; + await transport.handleRequest(req, res); +}; + +// Handle GET requests for server-to-client notifications via SSE +app.get('/mcp', handleSessionRequest); + +// Handle DELETE requests for session termination +app.delete('/mcp', handleSessionRequest); +console.log("Listening on port 8002"); +app.listen(8002); \ No newline at end of file diff --git a/vendor/rmcp/tests/test_with_python.rs b/vendor/rmcp/tests/test_with_python.rs new file mode 100644 index 000000000..c905e1b5b --- /dev/null +++ b/vendor/rmcp/tests/test_with_python.rs @@ -0,0 +1,78 @@ +#![cfg(not(feature = "local"))] +use std::process::Stdio; + +use rmcp::{ + ServiceExt, + transport::{ConfigureCommandExt, TokioChildProcess}, +}; +use tokio::io::AsyncReadExt; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +mod common; + +async fn init() -> anyhow::Result<()> { + let _ = tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "debug".to_string().into()), + ) + .with(tracing_subscriber::fmt::layer()) + .try_init(); + tokio::process::Command::new("uv") + .args(["sync"]) + .current_dir("tests/test_with_python") + .spawn()? + .wait() + .await?; + Ok(()) +} + +#[tokio::test] +async fn test_with_python_server() -> anyhow::Result<()> { + init().await?; + + let transport = TokioChildProcess::new(tokio::process::Command::new("uv").configure(|cmd| { + cmd.arg("run") + .arg("server.py") + .current_dir("tests/test_with_python"); + }))?; + + let client = ().serve(transport).await?; + let resources = client.list_all_resources().await?; + tracing::info!("{:#?}", resources); + let tools = client.list_all_tools().await?; + tracing::info!("{:#?}", tools); + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn test_with_python_server_stderr() -> anyhow::Result<()> { + init().await?; + + let (transport, stderr) = + TokioChildProcess::builder(tokio::process::Command::new("uv").configure(|cmd| { + cmd.arg("run") + .arg("server.py") + .current_dir("tests/test_with_python"); + })) + .stderr(Stdio::piped()) + .spawn()?; + + let mut stderr = stderr.expect("stderr must be piped"); + + let stderr_task = tokio::spawn(async move { + let mut buffer = String::new(); + stderr.read_to_string(&mut buffer).await?; + Ok::<_, std::io::Error>(buffer) + }); + + let client = ().serve(transport).await?; + let _ = client.list_all_resources().await?; + let _ = client.list_all_tools().await?; + client.cancel().await?; + + let stderr_output = stderr_task.await??; + assert!(stderr_output.contains("server starting up...")); + + Ok(()) +} diff --git a/vendor/rmcp/tests/test_with_python/.gitignore b/vendor/rmcp/tests/test_with_python/.gitignore new file mode 100644 index 000000000..2eb000890 --- /dev/null +++ b/vendor/rmcp/tests/test_with_python/.gitignore @@ -0,0 +1,22 @@ +# Lock files +*.lock + +# Python build artifacts +*.egg-info/ +build/ +dist/ +__pycache__/ +*.py[cod] +*$py.class + +# Virtual environments +venv/ +env/ +.venv/ +.env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo diff --git a/vendor/rmcp/tests/test_with_python/client.py b/vendor/rmcp/tests/test_with_python/client.py new file mode 100644 index 000000000..83d2c35b0 --- /dev/null +++ b/vendor/rmcp/tests/test_with_python/client.py @@ -0,0 +1,28 @@ +from mcp import ClientSession, StdioServerParameters, types +from mcp.client.sse import sse_client +import sys + +async def run(): + url = sys.argv[1] + async with sse_client(url) as (read, write): + async with ClientSession( + read, write + ) as session: + # Initialize the connection + await session.initialize() + + # List available prompts + prompts = await session.list_prompts() + print(prompts) + # List available resources + resources = await session.list_resources() + print(resources) + + # List available tools + tools = await session.list_tools() + print(tools) + +if __name__ == "__main__": + import asyncio + + asyncio.run(run()) diff --git a/vendor/rmcp/tests/test_with_python/pyproject.toml b/vendor/rmcp/tests/test_with_python/pyproject.toml new file mode 100644 index 000000000..9c8ce6ae9 --- /dev/null +++ b/vendor/rmcp/tests/test_with_python/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "test_with_python" +version = "0.1.0" +description = "Test Python client for RMCP" +dependencies = [ + "fastmcp", +] + +[tool.setuptools] +py-modules = ["client", "server"] diff --git a/vendor/rmcp/tests/test_with_python/server.py b/vendor/rmcp/tests/test_with_python/server.py new file mode 100644 index 000000000..1c8ee6986 --- /dev/null +++ b/vendor/rmcp/tests/test_with_python/server.py @@ -0,0 +1,25 @@ +from fastmcp import FastMCP + +import sys + +mcp = FastMCP("Demo") + +print("server starting up...", file=sys.stderr) + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers""" + return a + b + + +# Add a dynamic greeting resource +@mcp.resource("greeting://{name}") +def get_greeting(name: str) -> str: + """Get a personalized greeting""" + return f"Hello, {name}!" + + + +if __name__ == "__main__": + mcp.run() \ No newline at end of file From f5badaa1d5d8b00637eceb34db76867420332e6b Mon Sep 17 00:00:00 2001 From: Jake Magar Date: Thu, 20 Aug 2026 07:44:17 -0400 Subject: [PATCH 9/9] build(rmcp): pin audited decoder fork --- Cargo.lock | 1 + Cargo.toml | 6 +- deny.toml | 5 +- vendor/README.md | 17 - vendor/rmcp/CHANGELOG.md | 883 -- vendor/rmcp/Cargo.toml | 904 -- vendor/rmcp/Cargo.toml.orig | 463 - vendor/rmcp/README.md | 66 - vendor/rmcp/build.rs | 23 - vendor/rmcp/src/error.rs | 72 - vendor/rmcp/src/handler.rs | 4 - vendor/rmcp/src/handler/client.rs | 433 - vendor/rmcp/src/handler/client/progress.rs | 100 - vendor/rmcp/src/handler/server.rs | 817 -- vendor/rmcp/src/handler/server/common.rs | 404 - vendor/rmcp/src/handler/server/prompt.rs | 383 - vendor/rmcp/src/handler/server/resource.rs | 1 - vendor/rmcp/src/handler/server/router.rs | 235 - .../rmcp/src/handler/server/router/prompt.rs | 217 - vendor/rmcp/src/handler/server/router/tool.rs | 726 -- .../handler/server/router/tool/tool_traits.rs | 339 - vendor/rmcp/src/handler/server/tool.rs | 367 - .../handler/server/tool_name_validation.rs | 261 - vendor/rmcp/src/handler/server/wrapper.rs | 4 - .../rmcp/src/handler/server/wrapper/json.rs | 43 - .../src/handler/server/wrapper/parameters.rs | 56 - vendor/rmcp/src/lib.rs | 44 - vendor/rmcp/src/model.rs | 5326 ----------- vendor/rmcp/src/model/annotated.rs | 119 - vendor/rmcp/src/model/capabilities.rs | 680 -- vendor/rmcp/src/model/content.rs | 464 - vendor/rmcp/src/model/elicitation_schema.rs | 2237 ----- vendor/rmcp/src/model/extension.rs | 337 - vendor/rmcp/src/model/meta.rs | 859 -- vendor/rmcp/src/model/mrtr.rs | 558 -- vendor/rmcp/src/model/prompt.rs | 367 - vendor/rmcp/src/model/request_state.rs | 577 -- vendor/rmcp/src/model/resource.rs | 310 - vendor/rmcp/src/model/serde_impl.rs | 851 -- vendor/rmcp/src/model/task.rs | 544 -- vendor/rmcp/src/model/tool.rs | 259 - vendor/rmcp/src/service.rs | 1889 ---- vendor/rmcp/src/service/client.rs | 2302 ----- vendor/rmcp/src/service/client/cache.rs | 401 - vendor/rmcp/src/service/server.rs | 1314 --- vendor/rmcp/src/service/tower.rs | 54 - vendor/rmcp/src/task_manager.rs | 998 -- vendor/rmcp/src/transport.rs | 310 - vendor/rmcp/src/transport/async_rw.rs | 779 -- vendor/rmcp/src/transport/auth.rs | 8097 ----------------- vendor/rmcp/src/transport/child_process.rs | 321 - vendor/rmcp/src/transport/common.rs | 22 - vendor/rmcp/src/transport/common/auth.rs | 2 - .../common/auth/streamable_http_client.rs | 210 - .../src/transport/common/client_side_sse.rs | 738 -- .../rmcp/src/transport/common/http_header.rs | 140 - .../rmcp/src/transport/common/mcp_headers.rs | 658 -- vendor/rmcp/src/transport/common/reqwest.rs | 2 - .../common/reqwest/streamable_http_client.rs | 593 -- .../src/transport/common/server_side_http.rs | 378 - .../rmcp/src/transport/common/unix_socket.rs | 614 -- vendor/rmcp/src/transport/io.rs | 6 - vendor/rmcp/src/transport/sink_stream.rs | 80 - .../src/transport/streamable_http_client.rs | 2186 ----- .../src/transport/streamable_http_server.rs | 6 - .../streamable_http_server/session.rs | 174 - .../streamable_http_server/session/local.rs | 1374 --- .../streamable_http_server/session/never.rs | 126 - .../streamable_http_server/session/store.rs | 126 - .../transport/streamable_http_server/tower.rs | 2151 ----- vendor/rmcp/src/transport/worker.rs | 217 - vendor/rmcp/src/transport/ws.rs | 1 - vendor/rmcp/tests/common/calculator.rs | 59 - vendor/rmcp/tests/common/handlers.rs | 190 - vendor/rmcp/tests/common/mod.rs | 2 - .../tests/test_auth_error_classification.rs | 85 - vendor/rmcp/tests/test_cache_hints.rs | 79 - vendor/rmcp/tests/test_cancelled_response.rs | 212 - vendor/rmcp/tests/test_client_credentials.rs | 258 - .../rmcp/tests/test_client_initialization.rs | 152 - .../rmcp/tests/test_client_lifecycle_modes.rs | 463 - vendor/rmcp/tests/test_close_connection.rs | 128 - vendor/rmcp/tests/test_completion.rs | 208 - vendor/rmcp/tests/test_complex_schema.rs | 105 - vendor/rmcp/tests/test_custom_headers.rs | 1231 --- vendor/rmcp/tests/test_custom_request.rs | 190 - vendor/rmcp/tests/test_deserialization.rs | 238 - .../tool_list_result.json | 31 - .../test_discover_http_client_startup.rs | 137 - vendor/rmcp/tests/test_elicitation.rs | 2077 ----- .../rmcp/tests/test_embedded_resource_meta.rs | 114 - vendor/rmcp/tests/test_handler_wrappers.rs | 31 - .../tests/test_inflight_response_drain.rs | 159 - .../rmcp/tests/test_json_schema_detection.rs | 188 - vendor/rmcp/tests/test_list_tools_result.rs | 48 - .../list_tools_result.json | 47 - .../rmcp/tests/test_local_client_handler.rs | 16 - vendor/rmcp/tests/test_logging.rs | 344 - vendor/rmcp/tests/test_message_protocol.rs | 426 - vendor/rmcp/tests/test_message_schema.rs | 139 - .../client_json_rpc_message_schema.json | 2447 ----- ...lient_json_rpc_message_schema_current.json | 2447 ----- .../server_json_rpc_message_schema.json | 4113 --------- ...erver_json_rpc_message_schema_current.json | 4113 --------- vendor/rmcp/tests/test_meta_helpers.rs | 98 - vendor/rmcp/tests/test_mrtr_behavior.rs | 595 -- vendor/rmcp/tests/test_notification.rs | 234 - vendor/rmcp/tests/test_progress_subscriber.rs | 134 - vendor/rmcp/tests/test_prompt_handler.rs | 155 - .../tests/test_prompt_macro_annotations.rs | 274 - vendor/rmcp/tests/test_prompt_macros.rs | 375 - vendor/rmcp/tests/test_prompt_routers.rs | 140 - .../test_protocol_version_negotiation.rs | 171 - .../tests/test_request_timeout_progress.rs | 214 - vendor/rmcp/tests/test_resource_link.rs | 86 - .../tests/test_resource_link_integration.rs | 114 - .../tests/test_resource_not_found_version.rs | 91 - vendor/rmcp/tests/test_result_type_version.rs | 82 - vendor/rmcp/tests/test_result_type_wire.rs | 126 - vendor/rmcp/tests/test_sampling.rs | 744 -- .../test_sep_2260_request_association.rs | 273 - .../tests/test_sep_2260_stream_enforcement.rs | 309 - .../tests/test_sep_2260_stream_routing.rs | 165 - vendor/rmcp/tests/test_server_discover.rs | 255 - .../rmcp/tests/test_server_discover_client.rs | 77 - .../rmcp/tests/test_server_discover_http.rs | 380 - .../rmcp/tests/test_server_initialization.rs | 396 - .../rmcp/tests/test_sse_concurrent_streams.rs | 775 -- .../tests/test_stateless_protocol_version.rs | 226 - .../tests/test_stateless_server_requests.rs | 196 - .../tests/test_stdio_response_concurrency.rs | 199 - .../test_streamable_http_4xx_error_body.rs | 121 - .../test_streamable_http_connection_reuse.rs | 122 - .../test_streamable_http_disconnect_cancel.rs | 196 - ..._streamable_http_empty_2xx_notification.rs | 48 - .../tests/test_streamable_http_event_store.rs | 372 - .../test_streamable_http_idle_timeout_log.rs | 252 - .../test_streamable_http_init_timeout.rs | 63 - .../test_streamable_http_json_response.rs | 405 - .../tests/test_streamable_http_priming.rs | 499 - .../test_streamable_http_protocol_version.rs | 853 -- .../test_streamable_http_session_store.rs | 398 - .../test_streamable_http_stale_session.rs | 464 - .../test_streamable_http_standard_headers.rs | 315 - vendor/rmcp/tests/test_structured_output.rs | 422 - vendor/rmcp/tests/test_subscriptions.rs | 727 -- vendor/rmcp/tests/test_subscriptions_model.rs | 254 - .../test_subscriptions_streamable_http.rs | 317 - vendor/rmcp/tests/test_task.rs | 442 - .../rmcp/tests/test_tool_builder_methods.rs | 105 - .../tests/test_tool_disable_notification.rs | 165 - vendor/rmcp/tests/test_tool_handler.rs | 1 - .../rmcp/tests/test_tool_macro_annotations.rs | 46 - vendor/rmcp/tests/test_tool_macros.rs | 575 -- vendor/rmcp/tests/test_tool_result_meta.rs | 41 - vendor/rmcp/tests/test_tool_routers.rs | 372 - vendor/rmcp/tests/test_trace_context.rs | 85 - .../rmcp/tests/test_unix_socket_transport.rs | 298 - vendor/rmcp/tests/test_with_js.rs | 136 - vendor/rmcp/tests/test_with_js/.gitignore | 2 - vendor/rmcp/tests/test_with_js/client.js | 29 - vendor/rmcp/tests/test_with_js/package.json | 17 - vendor/rmcp/tests/test_with_js/server.js | 35 - .../tests/test_with_js/streamable_client.js | 28 - .../tests/test_with_js/streamable_server.js | 105 - vendor/rmcp/tests/test_with_python.rs | 78 - vendor/rmcp/tests/test_with_python/.gitignore | 22 - vendor/rmcp/tests/test_with_python/client.py | 28 - .../tests/test_with_python/pyproject.toml | 14 - vendor/rmcp/tests/test_with_python/server.py | 25 - 170 files changed, 8 insertions(+), 82461 deletions(-) delete mode 100644 vendor/README.md delete mode 100644 vendor/rmcp/CHANGELOG.md delete mode 100644 vendor/rmcp/Cargo.toml delete mode 100644 vendor/rmcp/Cargo.toml.orig delete mode 100644 vendor/rmcp/README.md delete mode 100644 vendor/rmcp/build.rs delete mode 100644 vendor/rmcp/src/error.rs delete mode 100644 vendor/rmcp/src/handler.rs delete mode 100644 vendor/rmcp/src/handler/client.rs delete mode 100644 vendor/rmcp/src/handler/client/progress.rs delete mode 100644 vendor/rmcp/src/handler/server.rs delete mode 100644 vendor/rmcp/src/handler/server/common.rs delete mode 100644 vendor/rmcp/src/handler/server/prompt.rs delete mode 100644 vendor/rmcp/src/handler/server/resource.rs delete mode 100644 vendor/rmcp/src/handler/server/router.rs delete mode 100644 vendor/rmcp/src/handler/server/router/prompt.rs delete mode 100644 vendor/rmcp/src/handler/server/router/tool.rs delete mode 100644 vendor/rmcp/src/handler/server/router/tool/tool_traits.rs delete mode 100644 vendor/rmcp/src/handler/server/tool.rs delete mode 100644 vendor/rmcp/src/handler/server/tool_name_validation.rs delete mode 100644 vendor/rmcp/src/handler/server/wrapper.rs delete mode 100644 vendor/rmcp/src/handler/server/wrapper/json.rs delete mode 100644 vendor/rmcp/src/handler/server/wrapper/parameters.rs delete mode 100644 vendor/rmcp/src/lib.rs delete mode 100644 vendor/rmcp/src/model.rs delete mode 100644 vendor/rmcp/src/model/annotated.rs delete mode 100644 vendor/rmcp/src/model/capabilities.rs delete mode 100644 vendor/rmcp/src/model/content.rs delete mode 100644 vendor/rmcp/src/model/elicitation_schema.rs delete mode 100644 vendor/rmcp/src/model/extension.rs delete mode 100644 vendor/rmcp/src/model/meta.rs delete mode 100644 vendor/rmcp/src/model/mrtr.rs delete mode 100644 vendor/rmcp/src/model/prompt.rs delete mode 100644 vendor/rmcp/src/model/request_state.rs delete mode 100644 vendor/rmcp/src/model/resource.rs delete mode 100644 vendor/rmcp/src/model/serde_impl.rs delete mode 100644 vendor/rmcp/src/model/task.rs delete mode 100644 vendor/rmcp/src/model/tool.rs delete mode 100644 vendor/rmcp/src/service.rs delete mode 100644 vendor/rmcp/src/service/client.rs delete mode 100644 vendor/rmcp/src/service/client/cache.rs delete mode 100644 vendor/rmcp/src/service/server.rs delete mode 100644 vendor/rmcp/src/service/tower.rs delete mode 100644 vendor/rmcp/src/task_manager.rs delete mode 100644 vendor/rmcp/src/transport.rs delete mode 100644 vendor/rmcp/src/transport/async_rw.rs delete mode 100644 vendor/rmcp/src/transport/auth.rs delete mode 100644 vendor/rmcp/src/transport/child_process.rs delete mode 100644 vendor/rmcp/src/transport/common.rs delete mode 100644 vendor/rmcp/src/transport/common/auth.rs delete mode 100644 vendor/rmcp/src/transport/common/auth/streamable_http_client.rs delete mode 100644 vendor/rmcp/src/transport/common/client_side_sse.rs delete mode 100644 vendor/rmcp/src/transport/common/http_header.rs delete mode 100644 vendor/rmcp/src/transport/common/mcp_headers.rs delete mode 100644 vendor/rmcp/src/transport/common/reqwest.rs delete mode 100644 vendor/rmcp/src/transport/common/reqwest/streamable_http_client.rs delete mode 100644 vendor/rmcp/src/transport/common/server_side_http.rs delete mode 100644 vendor/rmcp/src/transport/common/unix_socket.rs delete mode 100644 vendor/rmcp/src/transport/io.rs delete mode 100644 vendor/rmcp/src/transport/sink_stream.rs delete mode 100644 vendor/rmcp/src/transport/streamable_http_client.rs delete mode 100644 vendor/rmcp/src/transport/streamable_http_server.rs delete mode 100644 vendor/rmcp/src/transport/streamable_http_server/session.rs delete mode 100644 vendor/rmcp/src/transport/streamable_http_server/session/local.rs delete mode 100644 vendor/rmcp/src/transport/streamable_http_server/session/never.rs delete mode 100644 vendor/rmcp/src/transport/streamable_http_server/session/store.rs delete mode 100644 vendor/rmcp/src/transport/streamable_http_server/tower.rs delete mode 100644 vendor/rmcp/src/transport/worker.rs delete mode 100644 vendor/rmcp/src/transport/ws.rs delete mode 100644 vendor/rmcp/tests/common/calculator.rs delete mode 100644 vendor/rmcp/tests/common/handlers.rs delete mode 100644 vendor/rmcp/tests/common/mod.rs delete mode 100644 vendor/rmcp/tests/test_auth_error_classification.rs delete mode 100644 vendor/rmcp/tests/test_cache_hints.rs delete mode 100644 vendor/rmcp/tests/test_cancelled_response.rs delete mode 100644 vendor/rmcp/tests/test_client_credentials.rs delete mode 100644 vendor/rmcp/tests/test_client_initialization.rs delete mode 100644 vendor/rmcp/tests/test_client_lifecycle_modes.rs delete mode 100644 vendor/rmcp/tests/test_close_connection.rs delete mode 100644 vendor/rmcp/tests/test_completion.rs delete mode 100644 vendor/rmcp/tests/test_complex_schema.rs delete mode 100644 vendor/rmcp/tests/test_custom_headers.rs delete mode 100644 vendor/rmcp/tests/test_custom_request.rs delete mode 100644 vendor/rmcp/tests/test_deserialization.rs delete mode 100644 vendor/rmcp/tests/test_deserialization/tool_list_result.json delete mode 100644 vendor/rmcp/tests/test_discover_http_client_startup.rs delete mode 100644 vendor/rmcp/tests/test_elicitation.rs delete mode 100644 vendor/rmcp/tests/test_embedded_resource_meta.rs delete mode 100644 vendor/rmcp/tests/test_handler_wrappers.rs delete mode 100644 vendor/rmcp/tests/test_inflight_response_drain.rs delete mode 100644 vendor/rmcp/tests/test_json_schema_detection.rs delete mode 100644 vendor/rmcp/tests/test_list_tools_result.rs delete mode 100644 vendor/rmcp/tests/test_list_tools_result/list_tools_result.json delete mode 100644 vendor/rmcp/tests/test_local_client_handler.rs delete mode 100644 vendor/rmcp/tests/test_logging.rs delete mode 100644 vendor/rmcp/tests/test_message_protocol.rs delete mode 100644 vendor/rmcp/tests/test_message_schema.rs delete mode 100644 vendor/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json delete mode 100644 vendor/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json delete mode 100644 vendor/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json delete mode 100644 vendor/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json delete mode 100644 vendor/rmcp/tests/test_meta_helpers.rs delete mode 100644 vendor/rmcp/tests/test_mrtr_behavior.rs delete mode 100644 vendor/rmcp/tests/test_notification.rs delete mode 100644 vendor/rmcp/tests/test_progress_subscriber.rs delete mode 100644 vendor/rmcp/tests/test_prompt_handler.rs delete mode 100644 vendor/rmcp/tests/test_prompt_macro_annotations.rs delete mode 100644 vendor/rmcp/tests/test_prompt_macros.rs delete mode 100644 vendor/rmcp/tests/test_prompt_routers.rs delete mode 100644 vendor/rmcp/tests/test_protocol_version_negotiation.rs delete mode 100644 vendor/rmcp/tests/test_request_timeout_progress.rs delete mode 100644 vendor/rmcp/tests/test_resource_link.rs delete mode 100644 vendor/rmcp/tests/test_resource_link_integration.rs delete mode 100644 vendor/rmcp/tests/test_resource_not_found_version.rs delete mode 100644 vendor/rmcp/tests/test_result_type_version.rs delete mode 100644 vendor/rmcp/tests/test_result_type_wire.rs delete mode 100644 vendor/rmcp/tests/test_sampling.rs delete mode 100644 vendor/rmcp/tests/test_sep_2260_request_association.rs delete mode 100644 vendor/rmcp/tests/test_sep_2260_stream_enforcement.rs delete mode 100644 vendor/rmcp/tests/test_sep_2260_stream_routing.rs delete mode 100644 vendor/rmcp/tests/test_server_discover.rs delete mode 100644 vendor/rmcp/tests/test_server_discover_client.rs delete mode 100644 vendor/rmcp/tests/test_server_discover_http.rs delete mode 100644 vendor/rmcp/tests/test_server_initialization.rs delete mode 100644 vendor/rmcp/tests/test_sse_concurrent_streams.rs delete mode 100644 vendor/rmcp/tests/test_stateless_protocol_version.rs delete mode 100644 vendor/rmcp/tests/test_stateless_server_requests.rs delete mode 100644 vendor/rmcp/tests/test_stdio_response_concurrency.rs delete mode 100644 vendor/rmcp/tests/test_streamable_http_4xx_error_body.rs delete mode 100644 vendor/rmcp/tests/test_streamable_http_connection_reuse.rs delete mode 100644 vendor/rmcp/tests/test_streamable_http_disconnect_cancel.rs delete mode 100644 vendor/rmcp/tests/test_streamable_http_empty_2xx_notification.rs delete mode 100644 vendor/rmcp/tests/test_streamable_http_event_store.rs delete mode 100644 vendor/rmcp/tests/test_streamable_http_idle_timeout_log.rs delete mode 100644 vendor/rmcp/tests/test_streamable_http_init_timeout.rs delete mode 100644 vendor/rmcp/tests/test_streamable_http_json_response.rs delete mode 100644 vendor/rmcp/tests/test_streamable_http_priming.rs delete mode 100644 vendor/rmcp/tests/test_streamable_http_protocol_version.rs delete mode 100644 vendor/rmcp/tests/test_streamable_http_session_store.rs delete mode 100644 vendor/rmcp/tests/test_streamable_http_stale_session.rs delete mode 100644 vendor/rmcp/tests/test_streamable_http_standard_headers.rs delete mode 100644 vendor/rmcp/tests/test_structured_output.rs delete mode 100644 vendor/rmcp/tests/test_subscriptions.rs delete mode 100644 vendor/rmcp/tests/test_subscriptions_model.rs delete mode 100644 vendor/rmcp/tests/test_subscriptions_streamable_http.rs delete mode 100644 vendor/rmcp/tests/test_task.rs delete mode 100644 vendor/rmcp/tests/test_tool_builder_methods.rs delete mode 100644 vendor/rmcp/tests/test_tool_disable_notification.rs delete mode 100644 vendor/rmcp/tests/test_tool_handler.rs delete mode 100644 vendor/rmcp/tests/test_tool_macro_annotations.rs delete mode 100644 vendor/rmcp/tests/test_tool_macros.rs delete mode 100644 vendor/rmcp/tests/test_tool_result_meta.rs delete mode 100644 vendor/rmcp/tests/test_tool_routers.rs delete mode 100644 vendor/rmcp/tests/test_trace_context.rs delete mode 100644 vendor/rmcp/tests/test_unix_socket_transport.rs delete mode 100644 vendor/rmcp/tests/test_with_js.rs delete mode 100644 vendor/rmcp/tests/test_with_js/.gitignore delete mode 100644 vendor/rmcp/tests/test_with_js/client.js delete mode 100644 vendor/rmcp/tests/test_with_js/package.json delete mode 100644 vendor/rmcp/tests/test_with_js/server.js delete mode 100644 vendor/rmcp/tests/test_with_js/streamable_client.js delete mode 100644 vendor/rmcp/tests/test_with_js/streamable_server.js delete mode 100644 vendor/rmcp/tests/test_with_python.rs delete mode 100644 vendor/rmcp/tests/test_with_python/.gitignore delete mode 100644 vendor/rmcp/tests/test_with_python/client.py delete mode 100644 vendor/rmcp/tests/test_with_python/pyproject.toml delete mode 100644 vendor/rmcp/tests/test_with_python/server.py diff --git a/Cargo.lock b/Cargo.lock index 3c40723cd..d5bd8d0e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2801,6 +2801,7 @@ dependencies = [ [[package]] name = "rmcp" version = "3.1.0" +source = "git+https://github.com/dinglebear-ai/rust-sdk?rev=c9c3e518ce94b8d715519657c88195515978bda4#c9c3e518ce94b8d715519657c88195515978bda4" dependencies = [ "async-trait", "base64 0.23.0", diff --git a/Cargo.toml b/Cargo.toml index 2986874ac..141ee0972 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -272,10 +272,10 @@ redundant_explicit_links = "warn" unescaped_backticks = "warn" # Temporary rmcp decoder fix for custom extension results carrying protocol-generic -# `resultType` / `_meta`. The vendored crate is the normalized rmcp 3.1.0 -# package plus the decoder fix; remove once the fix ships in an upstream release. +# `resultType` / `_meta`. Pinned to one immutable fork commit; remove once the +# equivalent fix ships in an upstream rmcp release. [patch.crates-io] -rmcp = { path = "vendor/rmcp" } +rmcp = { git = "https://github.com/dinglebear-ai/rust-sdk", rev = "c9c3e518ce94b8d715519657c88195515978bda4" } [profile.release] lto = "thin" diff --git a/deny.toml b/deny.toml index f9f7a7db0..45b574938 100644 --- a/deny.toml +++ b/deny.toml @@ -76,4 +76,7 @@ reason = "Binary-only: table formatting happens in `crates/labby/src/output/`, n unknown-registry = "deny" unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] -allow-git = [] +# Temporary, exact-source exception for the pinned rmcp decoder fix. The +# dependency itself is rev-pinned in Cargo.toml/Cargo.lock; remove this entry +# with the patch as soon as upstream rmcp publishes the equivalent fix. +allow-git = ["https://github.com/dinglebear-ai/rust-sdk"] diff --git a/vendor/README.md b/vendor/README.md deleted file mode 100644 index 58c4d5743..000000000 --- a/vendor/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Vendored dependency patches - -## rmcp 3.1.0 - -`vendor/rmcp` is the Cargo-normalized source package for the official crates.io `rmcp` 3.1.0 release, with one temporary decoder correction in `src/model.rs`. - -Provenance: - -- Upstream package: `rmcp` 3.1.0, Apache-2.0 -- Upstream repository: `https://github.com/modelcontextprotocol/rust-sdk` -- Patch development fork: `dinglebear-ai/rust-sdk` -- Patch commit: `c9c3e518ce94b8d715519657c88195515978bda4` -- Package construction: `cargo package -p rmcp --allow-dirty --no-verify`, then extracted locally - -The patch changes `CallToolResult` deserialization so protocol-generic `resultType` and `_meta` fields are not sufficient to classify an arbitrary custom extension response as a tool result. At least one tool-specific field (`content`, `structuredContent`, or `isError`) must be present. Two regression tests preserve both sides of the boundary: a Depot-shaped Skills result remains `CustomResult`, while a real tool result containing `_meta` remains `CallToolResult`. - -The vendored tree is intentionally used instead of allowing Git dependencies in `deny.toml`; Labby keeps its `unknown-git = "deny"` / empty `allow-git` supply-chain policy. Remove this vendor override as soon as an upstream rmcp release contains the equivalent decoder fix, then restore the normal crates.io dependency and regenerate `Cargo.lock`. diff --git a/vendor/rmcp/CHANGELOG.md b/vendor/rmcp/CHANGELOG.md deleted file mode 100644 index b4e8cf612..000000000 --- a/vendor/rmcp/CHANGELOG.md +++ /dev/null @@ -1,883 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -## [3.1.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.1...rmcp-v3.1.0) - 2026-07-31 - -### Added - -- classify authorization-required errors ([#1056](https://github.com/modelcontextprotocol/rust-sdk/pull/1056)) -- add strict stateless protocol metadata validation ([#1091](https://github.com/modelcontextprotocol/rust-sdk/pull/1091)) -- SEP-2260 stream-based enforcement of client receive-side request association ([#1055](https://github.com/modelcontextprotocol/rust-sdk/pull/1055)) - -### Fixed - -- *(model)* decode metadata-bearing input-required results affecting mrtr ([#1097](https://github.com/modelcontextprotocol/rust-sdk/pull/1097)) -- require metadata for modern HTTP requests ([#1089](https://github.com/modelcontextprotocol/rust-sdk/pull/1089)) -- honor supported_protocol_versions when negotiating initialize ([#1093](https://github.com/modelcontextprotocol/rust-sdk/pull/1093)) - -### Other - -- document the ping utility with examples ([#1106](https://github.com/modelcontextprotocol/rust-sdk/pull/1106)) -- complete Tier 1 feature docs and finalize roadmap ([#1101](https://github.com/modelcontextprotocol/rust-sdk/pull/1101)) -- *(conformance)* meeting requirements for tier 1 ([#1087](https://github.com/modelcontextprotocol/rust-sdk/pull/1087)) - -## [3.0.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0...rmcp-v3.0.1) - 2026-07-29 - -### Fixed - -- *(auth)* use discovered resource for token refresh ([#1084](https://github.com/modelcontextprotocol/rust-sdk/pull/1084)) -- return header mismatch for missing protocol header ([#1083](https://github.com/modelcontextprotocol/rust-sdk/pull/1083)) -- negotiate stateless initialize versions ([#1080](https://github.com/modelcontextprotocol/rust-sdk/pull/1080)) -- stamp server info on graceful subscription results ([#1078](https://github.com/modelcontextprotocol/rust-sdk/pull/1078)) - -## [3.0.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0-beta.5...rmcp-v3.0.0) - 2026-07-28 - -### Fixed - -- recognize 2026 MCP methods ([#1076](https://github.com/modelcontextprotocol/rust-sdk/pull/1076)) - -## [3.0.0-beta.5](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0-beta.4...rmcp-v3.0.0-beta.5) - 2026-07-28 - -### Fixed - -- [**breaking**] remove server_info from DiscoverResult ([#1065](https://github.com/modelcontextprotocol/rust-sdk/pull/1065)) -- preserve transient OAuth discovery HTTP errors ([#1071](https://github.com/modelcontextprotocol/rust-sdk/pull/1071)) -- [**breaking**] preserve OAuth discovery transport errors ([#1069](https://github.com/modelcontextprotocol/rust-sdk/pull/1069)) -- gate client handler bounds for local ([#1068](https://github.com/modelcontextprotocol/rust-sdk/pull/1068)) - -### Other - -- prepare for stable 3.0 release ([#1073](https://github.com/modelcontextprotocol/rust-sdk/pull/1073)) -- RFC 9728 resource is used instead of base url when possible ([#962](https://github.com/modelcontextprotocol/rust-sdk/pull/962)) -- [**breaking**] remove deprecated v3 APIs ([#1066](https://github.com/modelcontextprotocol/rust-sdk/pull/1066)) - -## [3.0.0-beta.4](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0-beta.3...rmcp-v3.0.0-beta.4) - 2026-07-28 - -### Fixed - -- accept namespaced discovery server information ([#1044](https://github.com/modelcontextprotocol/rust-sdk/pull/1044)) - -### Other - -- *(deps)* update base64 requirement from 0.22 to 0.23 ([#1059](https://github.com/modelcontextprotocol/rust-sdk/pull/1059)) -- *(deps)* update jsonwebtoken requirement from 10 to 11 ([#1058](https://github.com/modelcontextprotocol/rust-sdk/pull/1058)) - -## [3.0.0-beta.3](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0-beta.2...rmcp-v3.0.0-beta.3) - 2026-07-27 - -### Fixed - -- [**breaking**] reject missing `issuer` in authorization server metadata by default ([#1054](https://github.com/modelcontextprotocol/rust-sdk/pull/1054)) -- default to allowing missing `issuer` ([#1051](https://github.com/modelcontextprotocol/rust-sdk/pull/1051)) - -### Other - -- make oauth discovery reactive ([#1052](https://github.com/modelcontextprotocol/rust-sdk/pull/1052)) - -## [3.0.0-beta.2](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0-beta.1...rmcp-v3.0.0-beta.2) - 2026-07-24 - -### Added - -- expose oauth discovery metadata source ([#1041](https://github.com/modelcontextprotocol/rust-sdk/pull/1041)) - -### Fixed - -- [**breaking**] omit resultType for legacy protocol sessions ([#1038](https://github.com/modelcontextprotocol/rust-sdk/pull/1038)) - -### Other - -- declare and check MSRV ([#1034](https://github.com/modelcontextprotocol/rust-sdk/pull/1034)) - -## [3.0.0-beta.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v2.2.0...rmcp-v3.0.0-beta.1) - 2026-07-23 - -### Added - -- route SEP-2260 associated server requests to the originating SSE stream ([#1029](https://github.com/modelcontextprotocol/rust-sdk/pull/1029)) -- [**breaking**] add distributed SSE event store ([#1024](https://github.com/modelcontextprotocol/rust-sdk/pull/1024)) -- add client-side TTL-honoring response cache (SEP-2549) ([#1025](https://github.com/modelcontextprotocol/rust-sdk/pull/1025)) -- [**breaking**] Implement SEP-2663 Tasks Extension ([#1020](https://github.com/modelcontextprotocol/rust-sdk/pull/1020)) -- add subscription listen streams (SEP-2575) ([#1000](https://github.com/modelcontextprotocol/rust-sdk/pull/1000)) -- *(auth)* bind DCR client credentials to issuing authorization server (SEP-2352) ([#998](https://github.com/modelcontextprotocol/rust-sdk/pull/998)) -- add modern client lifecycle modes (SEP-2575) ([#995](https://github.com/modelcontextprotocol/rust-sdk/pull/995)) -- *(auth)* accumulate client-side scopes during step-up authorization ([#888](https://github.com/modelcontextprotocol/rust-sdk/pull/888)) -- [**breaking**] add server discovery and negotiation (SEP-2575) ([#973](https://github.com/modelcontextprotocol/rust-sdk/pull/973)) -- *(conformance)* add SEP-2243 header validation tool ([#997](https://github.com/modelcontextprotocol/rust-sdk/pull/997)) -- [**breaking**] align metadata models with draft schema ([#993](https://github.com/modelcontextprotocol/rust-sdk/pull/993)) -- [**breaking**] implement SEP-2549 cache hints ([#889](https://github.com/modelcontextprotocol/rust-sdk/pull/889)) -- [**breaking**] add MRTR behavior support (SEP-2322) ([#929](https://github.com/modelcontextprotocol/rust-sdk/pull/929)) -- [**breaking**] type Annotations.lastModified as a string ([#956](https://github.com/modelcontextprotocol/rust-sdk/pull/956)) -- [**breaking**] add SEP-2243 HTTP standard headers ([#907](https://github.com/modelcontextprotocol/rust-sdk/pull/907)) -- relax outputSchema to accept non-object JSON Schema types (SEP-2106) ([#895](https://github.com/modelcontextprotocol/rust-sdk/pull/895)) -- [**breaking**] relax tool result structuredContent type (SEP-2106) ([#933](https://github.com/modelcontextprotocol/rust-sdk/pull/933)) -- [**breaking**] add MRTR model types (SEP-2322) ([#915](https://github.com/modelcontextprotocol/rust-sdk/pull/915)) - -### Fixed - -- reap completed response send tasks ([#1026](https://github.com/modelcontextprotocol/rust-sdk/pull/1026)) -- accept stringified numeric response IDs ([#1021](https://github.com/modelcontextprotocol/rust-sdk/pull/1021)) -- re-register after auth server change ([#1011](https://github.com/modelcontextprotocol/rust-sdk/pull/1011)) -- .with_stateful_mode -> .with_legacy_session_mode ([#1015](https://github.com/modelcontextprotocol/rust-sdk/pull/1015)) -- preserve negotiated progress responses ([#1005](https://github.com/modelcontextprotocol/rust-sdk/pull/1005)) -- *(server)* serve draft-version requests statelessly per SEP-2567 ([#999](https://github.com/modelcontextprotocol/rust-sdk/pull/999)) -- pass client header conformance ([#1012](https://github.com/modelcontextprotocol/rust-sdk/pull/1012)) -- *(auth)* add an SDK path for pre-registered OAuth clients ([#994](https://github.com/modelcontextprotocol/rust-sdk/pull/994)) -- *(transport)* cancel in-flight request on stateless streamable-HTTP client disconnect ([#857](https://github.com/modelcontextprotocol/rust-sdk/pull/857)) ([#967](https://github.com/modelcontextprotocol/rust-sdk/pull/967)) -- *(auth)* distinguish rejected refresh tokens from transient failures ([#963](https://github.com/modelcontextprotocol/rust-sdk/pull/963)) -- *(auth)* validate discovered metadata issuer ([#996](https://github.com/modelcontextprotocol/rust-sdk/pull/996)) -- bound streamable HTTP memory usage ([#970](https://github.com/modelcontextprotocol/rust-sdk/pull/970)) -- *(streamable-http)* preserve progress in JSON mode ([#990](https://github.com/modelcontextprotocol/rust-sdk/pull/990)) -- specify compatible sse-stream version ([#968](https://github.com/modelcontextprotocol/rust-sdk/pull/968)) -- flag schema derive on schemars feature ([#966](https://github.com/modelcontextprotocol/rust-sdk/pull/966)) - -### Other - -- refactor OAuth client authorization api ([#1009](https://github.com/modelcontextprotocol/rust-sdk/pull/1009)) -- update for 2026-07-28 version ([#1032](https://github.com/modelcontextprotocol/rust-sdk/pull/1032)) -- *(deps)* update hmac requirement from 0.12 to 0.13 ([#988](https://github.com/modelcontextprotocol/rust-sdk/pull/988)) -- serialize JavaScript dependency install ([#972](https://github.com/modelcontextprotocol/rust-sdk/pull/972)) - -### Changed - -- **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 - -- reject auth servers lacking S256 PKCE support ([#955](https://github.com/modelcontextprotocol/rust-sdk/pull/955)) - -### Fixed - -- pass client conformance suite ([#960](https://github.com/modelcontextprotocol/rust-sdk/pull/960)) -- don't respond to cancelled requests ([#957](https://github.com/modelcontextprotocol/rust-sdk/pull/957)) -- fail orphaned streamable HTTP responses on reinit ([#914](https://github.com/modelcontextprotocol/rust-sdk/pull/914)) -- address 2025-11-25 conformance audit findings ([#951](https://github.com/modelcontextprotocol/rust-sdk/pull/951)) - -## [2.1.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v2.0.0...rmcp-v2.1.0) - 2026-07-02 - -### Added - -- add SEP-414 trace context meta accessors ([#910](https://github.com/modelcontextprotocol/rust-sdk/pull/910)) -- add SEP-2575 meta helpers ([#942](https://github.com/modelcontextprotocol/rust-sdk/pull/942)) - -### Fixed - -- *(transport)* make AsyncRwTransport::receive cancel-safe ([#941](https://github.com/modelcontextprotocol/rust-sdk/pull/941)) ([#947](https://github.com/modelcontextprotocol/rust-sdk/pull/947)) -- *(auth)* preserve refresh_token when refresh response omits it ([#949](https://github.com/modelcontextprotocol/rust-sdk/pull/949)) -- block redirect header leaks ([#936](https://github.com/modelcontextprotocol/rust-sdk/pull/936)) -- don't respond to unparsable messages ([#940](https://github.com/modelcontextprotocol/rust-sdk/pull/940)) -- negotiate protocol version in handler ([#930](https://github.com/modelcontextprotocol/rust-sdk/pull/930)) - -## [2.0.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.8.0...rmcp-v2.0.0) - 2026-06-27 - -### Added - -- [**breaking**] relax tool result structuredContent type ([#919](https://github.com/modelcontextprotocol/rust-sdk/pull/919)) -- deprecate roots/sampling/logging types ([#923](https://github.com/modelcontextprotocol/rust-sdk/pull/923)) -- [**breaking**] align model types with MCP 2025-11-25 spec ([#927](https://github.com/modelcontextprotocol/rust-sdk/pull/927)) - -### Fixed - -- prevent OAuth resource spoofing ([#937](https://github.com/modelcontextprotocol/rust-sdk/pull/937)) -- block oauth metadata ssrf ([#935](https://github.com/modelcontextprotocol/rust-sdk/pull/935)) -- prevent streamable HTTP session leak ([#934](https://github.com/modelcontextprotocol/rust-sdk/pull/934)) -- fill missing fully qualified syntax in prompt_handler macros ([#866](https://github.com/modelcontextprotocol/rust-sdk/pull/866)) -- *(rmcp)* add Audio variant to PromptMessageContent ([#865](https://github.com/modelcontextprotocol/rust-sdk/pull/865)) - -### Other - -- consolidate repeated rmcp tests ([#931](https://github.com/modelcontextprotocol/rust-sdk/pull/931)) -- Revert "feat!: relax tool result structuredContent type ([#919](https://github.com/modelcontextprotocol/rust-sdk/pull/919))" ([#932](https://github.com/modelcontextprotocol/rust-sdk/pull/932)) -- align README examples with v2 model API ([#928](https://github.com/modelcontextprotocol/rust-sdk/pull/928)) - -## [1.8.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.7.0...rmcp-v1.8.0) - 2026-06-22 - -### Added - -- standardize resource-not-found error code (SEP-2164) ([#899](https://github.com/modelcontextprotocol/rust-sdk/pull/899)) -- validate OAuth authorization response issuer ([#896](https://github.com/modelcontextprotocol/rust-sdk/pull/896)) -- specify OIDC application_type during dynamic client registration (SEP-837) ([#883](https://github.com/modelcontextprotocol/rust-sdk/pull/883)) -- deprecate roots, sampling, and logging (SEP-2577) ([#884](https://github.com/modelcontextprotocol/rust-sdk/pull/884)) - -### Fixed - -- *(auth)* preserve configured reqwest client ([#917](https://github.com/modelcontextprotocol/rust-sdk/pull/917)) -- *(auth)* align OAuth metadata discovery ordering ([#887](https://github.com/modelcontextprotocol/rust-sdk/pull/887)) -- align progress timeout token ([#909](https://github.com/modelcontextprotocol/rust-sdk/pull/909)) -- *(elicitation)* preserve enumNames through ElicitationSchema serde round-trip ([#905](https://github.com/modelcontextprotocol/rust-sdk/pull/905)) -- return tool errors for invalid arguments ([#894](https://github.com/modelcontextprotocol/rust-sdk/pull/894)) -- *(auth)* apply offline_access to reauth paths ([#897](https://github.com/modelcontextprotocol/rust-sdk/pull/897)) -- update peer info on duplicate initialize ([#862](https://github.com/modelcontextprotocol/rust-sdk/pull/862)) -- strip and validate tool outputSchema and inputSchema ([#860](https://github.com/modelcontextprotocol/rust-sdk/pull/860)) -- remove unnecessary fields from tools' inputSchema ([#856](https://github.com/modelcontextprotocol/rust-sdk/pull/856)) -- reject init header/body version mismatch ([#853](https://github.com/modelcontextprotocol/rust-sdk/pull/853)) -- align protocol version negotiation ([#855](https://github.com/modelcontextprotocol/rust-sdk/pull/855)) -- accept 200 with empty body in response to notifications in addition to 202 ([#849](https://github.com/modelcontextprotocol/rust-sdk/pull/849)) - -### Other - -- Allow custom HTTP clients for OAuth ([#908](https://github.com/modelcontextprotocol/rust-sdk/pull/908)) -- Add progress-aware request timeout reset ([#858](https://github.com/modelcontextprotocol/rust-sdk/pull/858)) -- *(server)* document Err vs Ok(CallToolResult::error) visibility contract on ServerHandler::call_tool ([#854](https://github.com/modelcontextprotocol/rust-sdk/pull/854)) -- refine mcpmate listing copy ([#885](https://github.com/modelcontextprotocol/rust-sdk/pull/885)) -- added jilebi-mcp to the list of built with rmcp ([#861](https://github.com/modelcontextprotocol/rust-sdk/pull/861)) - -## [1.7.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.6.0...rmcp-v1.7.0) - 2026-05-13 - -### Added - -- add task-based stdio examples ([#839](https://github.com/modelcontextprotocol/rust-sdk/pull/839)) - -### Fixed - -- *(rmcp)* flatten Resource variant of PromptMessageContent ([#843](https://github.com/modelcontextprotocol/rust-sdk/pull/843)) -- reply -32700 on stdio parse errors instead of closing ([#833](https://github.com/modelcontextprotocol/rust-sdk/pull/833)) - -### Other - -- *(rmcp)* remove dependency on chrono default features ([#829](https://github.com/modelcontextprotocol/rust-sdk/pull/829)) -- Fix/issue 817 idle timeout log level ([#824](https://github.com/modelcontextprotocol/rust-sdk/pull/824)) - -## [1.6.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.5.0...rmcp-v1.6.0) - 2026-05-01 - -### Added - -- *(http)* log Host/Origin rejections ([#826](https://github.com/modelcontextprotocol/rust-sdk/pull/826)) -- *(http)* add Origin header validation ([#823](https://github.com/modelcontextprotocol/rust-sdk/pull/823)) -- *(router)* support runtime disabling of tools ([#809](https://github.com/modelcontextprotocol/rust-sdk/pull/809)) -- optional session store (resumabillity support) ([#775](https://github.com/modelcontextprotocol/rust-sdk/pull/775)) - -### Fixed - -- add init_timeout for streamable-http sessions ([#811](https://github.com/modelcontextprotocol/rust-sdk/pull/811)) -- *(http)* fall back to :authority for HTTP/2 ([#827](https://github.com/modelcontextprotocol/rust-sdk/pull/827)) -- *(docs)* use correct Parameters syntax in tool examples ([#814](https://github.com/modelcontextprotocol/rust-sdk/pull/814)) - -### Other - -- add systemprompt-template to Built with rmcp ([#820](https://github.com/modelcontextprotocol/rust-sdk/pull/820)) - -## [1.5.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.4.0...rmcp-v1.5.0) - 2026-04-16 - -### Added - -- *(transport)* add constructors for non_exhaustive error types ([#806](https://github.com/modelcontextprotocol/rust-sdk/pull/806)) -- add 2025-11-25 protocol version support ([#802](https://github.com/modelcontextprotocol/rust-sdk/pull/802)) - -### Fixed - -- treat resource metadata JSON parse failure as soft error ([#810](https://github.com/modelcontextprotocol/rust-sdk/pull/810)) -- include http_request_id in request-wise priming event IDs ([#799](https://github.com/modelcontextprotocol/rust-sdk/pull/799)) -- *(http)* drain SSE stream for connection reuse ([#790](https://github.com/modelcontextprotocol/rust-sdk/pull/790)) - -### Other - -- *(deps)* update which requirement from 7 to 8 ([#807](https://github.com/modelcontextprotocol/rust-sdk/pull/807)) - -## [1.4.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.3.0...rmcp-v1.4.0) - 2026-04-09 - -### Added - -- add Default and constructors to ServerSseMessage ([#794](https://github.com/modelcontextprotocol/rust-sdk/pull/794)) -- add meta to elicitation results ([#792](https://github.com/modelcontextprotocol/rust-sdk/pull/792)) -- *(macros)* auto-generate get_info and default router ([#785](https://github.com/modelcontextprotocol/rust-sdk/pull/785)) -- *(transport)* add which_command for cross-platform executable resolution ([#774](https://github.com/modelcontextprotocol/rust-sdk/pull/774)) -- *(auth)* add StoredCredentials::new() constructor ([#778](https://github.com/modelcontextprotocol/rust-sdk/pull/778)) - -### Fixed - -- *(server)* remove initialized notification gate to support Streamable HTTP ([#788](https://github.com/modelcontextprotocol/rust-sdk/pull/788)) -- default session keep_alive to 5 minutes ([#780](https://github.com/modelcontextprotocol/rust-sdk/pull/780)) -- *(http)* add host check ([#764](https://github.com/modelcontextprotocol/rust-sdk/pull/764)) -- exclude local feature from docs.rs build ([#782](https://github.com/modelcontextprotocol/rust-sdk/pull/782)) - -### Other - -- update Rust toolchain to 1.92 ([#797](https://github.com/modelcontextprotocol/rust-sdk/pull/797)) -- unify IntoCallToolResult Result impls ([#787](https://github.com/modelcontextprotocol/rust-sdk/pull/787)) - -## [1.3.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.2.0...rmcp-v1.3.0) - 2026-03-24 - -### Added - -- *(transport)* add Unix domain socket client for streamable HTTP ([#749](https://github.com/modelcontextprotocol/rust-sdk/pull/749)) -- *(auth)* implement SEP-2207 OIDC-flavored refresh token guidance ([#676](https://github.com/modelcontextprotocol/rust-sdk/pull/676)) -- add configuration for transparent session re-init ([#760](https://github.com/modelcontextprotocol/rust-sdk/pull/760)) -- add local feature for !Send tool handler support ([#740](https://github.com/modelcontextprotocol/rust-sdk/pull/740)) - -### Fixed - -- prevent CallToolResult and GetTaskPayloadResult from shadowing CustomResult in untagged enums ([#771](https://github.com/modelcontextprotocol/rust-sdk/pull/771)) -- drain in-flight responses on stdin EOF ([#759](https://github.com/modelcontextprotocol/rust-sdk/pull/759)) -- remove default type param from StreamableHttpService ([#758](https://github.com/modelcontextprotocol/rust-sdk/pull/758)) -- use cfg-gated Send+Sync supertraits to avoid semver break ([#757](https://github.com/modelcontextprotocol/rust-sdk/pull/757)) -- *(rmcp)* surface JSON-RPC error bodies on HTTP 4xx responses ([#748](https://github.com/modelcontextprotocol/rust-sdk/pull/748)) -- default CallToolResult content to empty vec on missing field ([#752](https://github.com/modelcontextprotocol/rust-sdk/pull/752)) -- *(auth)* redact secrets in Debug output for StoredCredentials and StoredAuthorizationState ([#744](https://github.com/modelcontextprotocol/rust-sdk/pull/744)) - -### Other - -- fix all clippy warnings across workspace ([#746](https://github.com/modelcontextprotocol/rust-sdk/pull/746)) - -## [1.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.1.1...rmcp-v1.2.0) - 2026-03-11 - -### Added - -- add missing constructors for non-exhaustive model types ([#739](https://github.com/modelcontextprotocol/rust-sdk/pull/739)) -- include granted scopes in OAuth refresh token request ([#731](https://github.com/modelcontextprotocol/rust-sdk/pull/731)) - -### Fixed - -- handle ping requests sent before initialize handshake ([#745](https://github.com/modelcontextprotocol/rust-sdk/pull/745)) -- allow deserializing notifications without params field ([#729](https://github.com/modelcontextprotocol/rust-sdk/pull/729)) - -### Other - -- *(deps)* update jsonwebtoken requirement from 9 to 10 ([#737](https://github.com/modelcontextprotocol/rust-sdk/pull/737)) - -## [1.1.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.1.0...rmcp-v1.1.1) - 2026-03-09 - -### Fixed - -- accept logging/setLevel and ping before initialized notification ([#730](https://github.com/modelcontextprotocol/rust-sdk/pull/730)) - -## [1.1.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.0.0...rmcp-v1.1.0) - 2026-03-04 - -### Added - -- implement OAuth 2.0 Client Credentials flow ([#707](https://github.com/modelcontextprotocol/rust-sdk/pull/707)) - -### Other - -- add McpMux to Built with rmcp section ([#717](https://github.com/modelcontextprotocol/rust-sdk/pull/717)) - -## [1.0.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.0.0-alpha...rmcp-v1.0.0) - 2026-03-03 - -### Fixed - -- *(auth)* pass WWW-Authenticate scopes to DCR registration request ([#705](https://github.com/modelcontextprotocol/rust-sdk/pull/705)) -- api ergonomics follow-up ([#720](https://github.com/modelcontextprotocol/rust-sdk/pull/720)) -- *(streamable-http)* map stale session 401 to status-aware error ([#709](https://github.com/modelcontextprotocol/rust-sdk/pull/709)) - -## [1.0.0-alpha](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.17.0...rmcp-v1.0.0-alpha) - 2026-03-03 - -### Added - -- docs update ([#718](https://github.com/modelcontextprotocol/rust-sdk/pull/718)) -- *(auth)* [**breaking**] support returning extra fields from token exchange ([#700](https://github.com/modelcontextprotocol/rust-sdk/pull/700)) - -### Fixed - -- downgrade logging of message to `TRACE` to avoid spamming logs ([#699](https://github.com/modelcontextprotocol/rust-sdk/pull/699)) - -### Other - -- add #[non_exhaustive] and mutation methods to improve compatibility ([#715](https://github.com/modelcontextprotocol/rust-sdk/pull/715)) - -## [0.17.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.16.0...rmcp-v0.17.0) - 2026-02-27 - -### Added - -- *(streamable-http)* add json_response option for stateless server mode ([#683](https://github.com/modelcontextprotocol/rust-sdk/pull/683)) -- mcp sdk conformance ([#687](https://github.com/modelcontextprotocol/rust-sdk/pull/687)) -- add default value support to string, number, and integer schemas ([#686](https://github.com/modelcontextprotocol/rust-sdk/pull/686)) -- add trait-based tool declaration ([#677](https://github.com/modelcontextprotocol/rust-sdk/pull/677)) -- send and validate MCP-Protocol-Version header ([#675](https://github.com/modelcontextprotocol/rust-sdk/pull/675)) - -### Fixed - -- improve error logging and remove token secret from logs ([#685](https://github.com/modelcontextprotocol/rust-sdk/pull/685)) -- refresh token expiry ([#680](https://github.com/modelcontextprotocol/rust-sdk/pull/680)) -- gate optional dependencies behind feature flags ([#672](https://github.com/modelcontextprotocol/rust-sdk/pull/672)) -- allow empty content in CallToolResult ([#681](https://github.com/modelcontextprotocol/rust-sdk/pull/681)) -- *(schema)* remove AddNullable from draft2020_12 settings ([#664](https://github.com/modelcontextprotocol/rust-sdk/pull/664)) - -### Other - -- add prose documentation for core features to meet conformance ([#702](https://github.com/modelcontextprotocol/rust-sdk/pull/702)) -- Fix/sse channel replacement conflict ([#682](https://github.com/modelcontextprotocol/rust-sdk/pull/682)) -- document session management for streamable HTTP transport ([#674](https://github.com/modelcontextprotocol/rust-sdk/pull/674)) - -## [0.16.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.15.0...rmcp-v0.16.0) - 2026-02-17 - -### Added - -- add support for custom HTTP headers in StreamableHttpClient ([#655](https://github.com/modelcontextprotocol/rust-sdk/pull/655)) -- *(auth)* add token_endpoint_auth_method to OAuthClientConfig ([#648](https://github.com/modelcontextprotocol/rust-sdk/pull/648)) - -### Fixed - -- remove unnecessary doc-cfg ([#661](https://github.com/modelcontextprotocol/rust-sdk/pull/661)) -- duplicate meta serialization ([#662](https://github.com/modelcontextprotocol/rust-sdk/pull/662)) -- sort list_all() output in ToolRouter and PromptRouter for deterministic ordering ([#665](https://github.com/modelcontextprotocol/rust-sdk/pull/665)) -- align task response types with MCP spec ([#658](https://github.com/modelcontextprotocol/rust-sdk/pull/658)) - -### Other - -- upgrade reqwest to 0.13.2 ([#669](https://github.com/modelcontextprotocol/rust-sdk/pull/669)) -- include LICENSE in final crate tarball ([#657](https://github.com/modelcontextprotocol/rust-sdk/pull/657)) -- *(deps)* update rand requirement from 0.9 to 0.10 ([#650](https://github.com/modelcontextprotocol/rust-sdk/pull/650)) -- remove unused axum dependency from server-side-http feature ([#642](https://github.com/modelcontextprotocol/rust-sdk/pull/642)) -- 11-25-2025 compliant Auth ([#651](https://github.com/modelcontextprotocol/rust-sdk/pull/651)) -- add rudof-mcp to MCP servers list ([#645](https://github.com/modelcontextprotocol/rust-sdk/pull/645)) - -## [0.15.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.14.0...rmcp-v0.15.0) - 2026-02-10 - -### Added - -- *(elicitation)* add support URL elicitation. SEP-1036 ([#605](https://github.com/modelcontextprotocol/rust-sdk/pull/605)) -- enforce SEP-1577 MUST requirements for sampling with tools ([#646](https://github.com/modelcontextprotocol/rust-sdk/pull/646)) -- add native-tls as an optional TLS backend ([#631](https://github.com/modelcontextprotocol/rust-sdk/pull/631)) -- *(capabilities)* add extensions field for SEP-1724 ([#643](https://github.com/modelcontextprotocol/rust-sdk/pull/643)) - -### Fixed - -- *(tasks)* avoid dropping completed task results during collection ([#639](https://github.com/modelcontextprotocol/rust-sdk/pull/639)) -- *(auth)* oauth metadata discovery ([#641](https://github.com/modelcontextprotocol/rust-sdk/pull/641)) -- compilation with --no-default-features ([#593](https://github.com/modelcontextprotocol/rust-sdk/pull/593)) -- *(tasks)* expose `execution.taskSupport` on tools ([#635](https://github.com/modelcontextprotocol/rust-sdk/pull/635)) -- *(tasks)* correct enum variant ordering for deserialization ([#634](https://github.com/modelcontextprotocol/rust-sdk/pull/634)) - -### Other - -- Add optional description field to Implementation struct ([#649](https://github.com/modelcontextprotocol/rust-sdk/pull/649)) -- Implement SEP-1577: Sampling With Tools ([#628](https://github.com/modelcontextprotocol/rust-sdk/pull/628)) - -## [0.14.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.13.0...rmcp-v0.14.0) - 2026-01-23 - -### Fixed - -- *(tasks)* #626 model task capabilities correctly ([#627](https://github.com/modelcontextprotocol/rust-sdk/pull/627)) -- don't treat non-success HTTP codes as transport errors ([#618](https://github.com/modelcontextprotocol/rust-sdk/pull/618)) - -### Other - -- show README content on docs.rs ([#583](https://github.com/modelcontextprotocol/rust-sdk/pull/583)) -- added hyper-mcp to the list of built with rmcp ([#621](https://github.com/modelcontextprotocol/rust-sdk/pull/621)) -- Implement SEP-1319: Decouple Request Payload from RPC Methods ([#617](https://github.com/modelcontextprotocol/rust-sdk/pull/617)) - -## [0.13.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.12.0...rmcp-v0.13.0) - 2026-01-15 - -### Added - -- provide blanket implementations for ClientHandler and ServerHandler traits ([#609](https://github.com/modelcontextprotocol/rust-sdk/pull/609)) -- *(service)* add close() method for graceful connection shutdown ([#588](https://github.com/modelcontextprotocol/rust-sdk/pull/588)) -- *(auth)* add StateStore trait for pluggable OAuth state storage ([#614](https://github.com/modelcontextprotocol/rust-sdk/pull/614)) -- *(elicitation)* implement SEP-1330 Elicitation Enum Schema Improvements ([#539](https://github.com/modelcontextprotocol/rust-sdk/pull/539)) -- *(task)* add task support (SEP-1686) ([#536](https://github.com/modelcontextprotocol/rust-sdk/pull/536)) - -### Fixed - -- use the json rpc error from the initialize response and bubble it up to the client ([#569](https://github.com/modelcontextprotocol/rust-sdk/pull/569)) -- *(build)* fix build of the project when no features are selected ([#606](https://github.com/modelcontextprotocol/rust-sdk/pull/606)) -- use Semaphore instead of Notify in OneshotTransport to prevent race condition ([#611](https://github.com/modelcontextprotocol/rust-sdk/pull/611)) -- add OpenID Connect discovery support per spec-2025-11-25 4.3 ([#598](https://github.com/modelcontextprotocol/rust-sdk/pull/598)) -- only try to refresh access tokens if we have a refresh token or an expiry time ([#594](https://github.com/modelcontextprotocol/rust-sdk/pull/594)) -- *(docs)* add spreadsheet-mcp to Built with rmcp ([#582](https://github.com/modelcontextprotocol/rust-sdk/pull/582)) - -### Other - -- *(elicitation)* improve enum schema builder, small changes of elicitation builder ([#608](https://github.com/modelcontextprotocol/rust-sdk/pull/608)) -- add pre-commit hook for conventional commit verification ([#619](https://github.com/modelcontextprotocol/rust-sdk/pull/619)) -- clean up optional dependencies ([#546](https://github.com/modelcontextprotocol/rust-sdk/pull/546)) -- re-export ServerSseMessage from session module ([#612](https://github.com/modelcontextprotocol/rust-sdk/pull/612)) -- Implement SEP-1699: Support SSE Polling via Server-Side Disconnect ([#604](https://github.com/modelcontextprotocol/rust-sdk/pull/604)) -- update README external links ([#603](https://github.com/modelcontextprotocol/rust-sdk/pull/603)) -- clarity and formatting ([#602](https://github.com/modelcontextprotocol/rust-sdk/pull/602)) -- Add optional icons field to RawResourceTemplate ([#589](https://github.com/modelcontextprotocol/rust-sdk/pull/589)) - -## [0.12.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.11.0...rmcp-v0.12.0) - 2025-12-18 - -### Added - -- add support for custom requests ([#590](https://github.com/modelcontextprotocol/rust-sdk/pull/590)) -- add support for custom server notifications ([#580](https://github.com/modelcontextprotocol/rust-sdk/pull/580)) - -### Fixed - -- update process-wrap to v9.0 ([#586](https://github.com/modelcontextprotocol/rust-sdk/pull/586)) -- *(oauth)* rfc8414 should judement the response_types ([#485](https://github.com/modelcontextprotocol/rust-sdk/pull/485)) - -### Other - -- Add SEP-991 (CIMD) support for URL-based client IDs ([#570](https://github.com/modelcontextprotocol/rust-sdk/pull/570)) -- merge cached_schema_for_type into schema_for_type ([#581](https://github.com/modelcontextprotocol/rust-sdk/pull/581)) -- Add NexusCore MCP to project list ([#573](https://github.com/modelcontextprotocol/rust-sdk/pull/573)) - -## [0.11.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.10.0...rmcp-v0.11.0) - 2025-12-08 - -### Added - -- *(meta)* add _meta field to prompts, resources and paginated result ([#558](https://github.com/modelcontextprotocol/rust-sdk/pull/558)) -- [**breaking**] remove SSE transport support ([#562](https://github.com/modelcontextprotocol/rust-sdk/pull/562)) - -### Fixed - -- *(streamable-http)* gracefully shutdown while client connected ([#494](https://github.com/modelcontextprotocol/rust-sdk/pull/494)) - -### Other - -- Implements outputSchema validation ([#566](https://github.com/modelcontextprotocol/rust-sdk/pull/566)) -- add video-transcriber-mcp-rs to projects built with rmcp ([#565](https://github.com/modelcontextprotocol/rust-sdk/pull/565)) - -## [0.10.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.9.1...rmcp-v0.10.0) - 2025-12-01 - -### Added - -- add support for custom client notifications ([#556](https://github.com/modelcontextprotocol/rust-sdk/pull/556)) - -### Other - -- replace paste with pastey for macros feature ([#564](https://github.com/modelcontextprotocol/rust-sdk/pull/564)) - -## [0.9.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.9.0...rmcp-v0.9.1) - 2025-11-24 - -### Added - -- *(streamable-http)* support both SSE and JSON response formats ([#540](https://github.com/modelcontextprotocol/rust-sdk/pull/540)) - -### Fixed - -- don't block on creating the SSE stream ([#553](https://github.com/modelcontextprotocol/rust-sdk/pull/553)) -- *(shemars)* use JSON Schema 2020-12 as Default Dialect ([#549](https://github.com/modelcontextprotocol/rust-sdk/pull/549)) -- *(oauth)* let OAuth discovery skip to next well-known URL candidate on JSON parse error. ([#545](https://github.com/modelcontextprotocol/rust-sdk/pull/545)) - -### Other - -- Implementation of SEP-986: Specify Format for Tool Names ([#551](https://github.com/modelcontextprotocol/rust-sdk/pull/551)) - -## [0.9.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.8.5...rmcp-v0.9.0) - 2025-11-17 - -### Added - -- *(auth)* implement CredentialStore trait ([#542](https://github.com/modelcontextprotocol/rust-sdk/pull/542)) -- *(tool)* add _meta to tool definitions ([#534](https://github.com/modelcontextprotocol/rust-sdk/pull/534)) - -## [0.8.5](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.8.4...rmcp-v0.8.5) - 2025-11-05 - -### Fixed - -- *(oauth)* respect oauth-protected-resource discovery ([#511](https://github.com/modelcontextprotocol/rust-sdk/pull/511)) - -## [0.8.4](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.8.3...rmcp-v0.8.4) - 2025-11-04 - -### Fixed - -- *(oauth)* fix oauth credential refresh ([#509](https://github.com/modelcontextprotocol/rust-sdk/pull/509)) -- do not manually construct fallback authorization metadata ([#507](https://github.com/modelcontextprotocol/rust-sdk/pull/507)) -- *(doc)* add stakpak-agent to Built with rmcp section ([#500](https://github.com/modelcontextprotocol/rust-sdk/pull/500)) - -## [0.8.3](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.8.2...rmcp-v0.8.3) - 2025-10-22 - -### Fixed - -- accept 204 in addition to 202 on initialize ([#497](https://github.com/modelcontextprotocol/rust-sdk/pull/497)) - -## [0.8.2](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.8.1...rmcp-v0.8.2) - 2025-10-21 - -### Added - -- add type-safe elicitation schema support ([#465](https://github.com/modelcontextprotocol/rust-sdk/pull/465)) ([#466](https://github.com/modelcontextprotocol/rust-sdk/pull/466)) -- *(SEP-973)* following change Icon.sizes from string to string array ([#479](https://github.com/modelcontextprotocol/rust-sdk/pull/479)) - -### Fixed - -- *(oauth)* three oauth discovery and registration issues ([#489](https://github.com/modelcontextprotocol/rust-sdk/pull/489)) -- *(oauth)* dynamic client registration should be optional ([#463](https://github.com/modelcontextprotocol/rust-sdk/pull/463)) -- *(sse-client)* consume control frames; refresh message endpoint ([#448](https://github.com/modelcontextprotocol/rust-sdk/pull/448)) - -### Other - -- Streamable HTTP: drain SSE frames until the initialize response, ignoring early notifications to prevent handshake timeouts ([#467](https://github.com/modelcontextprotocol/rust-sdk/pull/467)) -- bump crate version in README.md ([#471](https://github.com/modelcontextprotocol/rust-sdk/pull/471)) - -## [0.8.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.8.0...rmcp-v0.8.1) - 2025-10-07 - -### Fixed - -- *(oauth)* pass bearer token to all streamable http requests ([#476](https://github.com/modelcontextprotocol/rust-sdk/pull/476)) -- fix spellcheck on intentional typo in CHANGELOG ([#470](https://github.com/modelcontextprotocol/rust-sdk/pull/470)) - -## [0.8.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.7.0...rmcp-v0.8.0) - 2025-10-04 - -### Added - -- allow clients to override client_name ([#469](https://github.com/modelcontextprotocol/rust-sdk/pull/469)) - -### Fixed - -- *(oauth)* support suffixed and prefixed well-known paths ([#459](https://github.com/modelcontextprotocol/rust-sdk/pull/459)) -- generate default schema for tools with no params ([#446](https://github.com/modelcontextprotocol/rust-sdk/pull/446)) - -### Other - -- bump to rust 1.90.0 ([#453](https://github.com/modelcontextprotocol/rust-sdk/pull/453)) - -## [0.7.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.6.4...rmcp-v0.7.0) - 2025-09-24 - -### Fixed - -- return auth errors ([#451](https://github.com/modelcontextprotocol/rust-sdk/pull/451)) -- *(oauth)* do not treat empty secret as valid for public clients ([#443](https://github.com/modelcontextprotocol/rust-sdk/pull/443)) -- *(clippy)* add doc comment for generated tool attr fn ([#439](https://github.com/modelcontextprotocol/rust-sdk/pull/439)) -- *(oauth)* require CSRF token as part of the OAuth authorization flow. ([#435](https://github.com/modelcontextprotocol/rust-sdk/pull/435)) - -### Other - -- *(root)* Add Terminator to Built with rmcp section ([#437](https://github.com/modelcontextprotocol/rust-sdk/pull/437)) -- Non-empty paths in OAuth2 Authorization Server Metadata URLs ([#441](https://github.com/modelcontextprotocol/rust-sdk/pull/441)) - -## [0.6.4](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.6.3...rmcp-v0.6.4) - 2025-09-11 - -### Added - -- *(SEP-973)* add support for icons and websiteUrl across relevant types ([#432](https://github.com/modelcontextprotocol/rust-sdk/pull/432)) -- implement context-aware completion (MCP 2025-06-18) ([#396](https://github.com/modelcontextprotocol/rust-sdk/pull/396)) -- add `title` field for data types ([#410](https://github.com/modelcontextprotocol/rust-sdk/pull/410)) - -### Fixed - -- crates/rmcp/src/handler/client/progress.rs XXXXXX -> dispatcher ([#429](https://github.com/modelcontextprotocol/rust-sdk/pull/429)) -- build issue due to missing struct field ([#427](https://github.com/modelcontextprotocol/rust-sdk/pull/427)) -- generate simple {} schema for tools with no parameters ([#425](https://github.com/modelcontextprotocol/rust-sdk/pull/425)) - -### Other - -- Skip notification in initialization handshake ([#421](https://github.com/modelcontextprotocol/rust-sdk/pull/421)) -- add nvim-mcp project built by rmcp ([#422](https://github.com/modelcontextprotocol/rust-sdk/pull/422)) - -## [0.6.3](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.6.2...rmcp-v0.6.3) - 2025-09-04 - -### Fixed - -- change JSON-RPC request ID type from u32 to i64 ([#416](https://github.com/modelcontextprotocol/rust-sdk/pull/416)) - -## [0.6.2](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.6.1...rmcp-v0.6.2) - 2025-09-04 - -### Added - -- *(rmcp)* add optional _meta to CallToolResult, EmbeddedResource, and ResourceContents ([#386](https://github.com/modelcontextprotocol/rust-sdk/pull/386)) - -### Fixed - -- resolve compatibility issues with servers sending LSP notifications ([#413](https://github.com/modelcontextprotocol/rust-sdk/pull/413)) -- remove batched json rpc support ([#408](https://github.com/modelcontextprotocol/rust-sdk/pull/408)) -- transport-streamable-http-server depends on transport-worker ([#405](https://github.com/modelcontextprotocol/rust-sdk/pull/405)) -- *(typo)* correct typo in error message for transport cancellation and field. ([#404](https://github.com/modelcontextprotocol/rust-sdk/pull/404)) - -### Other - -- Spec conformance: meta support and spec updates ([#415](https://github.com/modelcontextprotocol/rust-sdk/pull/415)) -- add the rmcp-openapi and rmcp-actix-web related projects ([#406](https://github.com/modelcontextprotocol/rust-sdk/pull/406)) - -## [0.6.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.6.0...rmcp-v0.6.1) - 2025-08-29 - -### Added - -- *(rmcp)* add authorization header support for the streamable http client ([#390](https://github.com/modelcontextprotocol/rust-sdk/pull/390)) -- *(model)* add helpers to build enum from concrete values ([#393](https://github.com/modelcontextprotocol/rust-sdk/pull/393)) -- *(model)* expose client method name ([#391](https://github.com/modelcontextprotocol/rust-sdk/pull/391)) -- add resource_link support to tools and prompts ([#381](https://github.com/modelcontextprotocol/rust-sdk/pull/381)) -- Add prompt support ([#351](https://github.com/modelcontextprotocol/rust-sdk/pull/351)) -- include reqwest in transport-streamble-http-client feature ([#376](https://github.com/modelcontextprotocol/rust-sdk/pull/376)) - -### Fixed - -- *(auth)* url parse is not correct ([#402](https://github.com/modelcontextprotocol/rust-sdk/pull/402)) -- *(readme)* missing use declarations, more accurate server instructions ([#399](https://github.com/modelcontextprotocol/rust-sdk/pull/399)) -- enhance transport graceful shutdown with proper writer closure ([#392](https://github.com/modelcontextprotocol/rust-sdk/pull/392)) - -### Other - -- simplify remove_route method signature ([#401](https://github.com/modelcontextprotocol/rust-sdk/pull/401)) - -## [0.6.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.5.0...rmcp-v0.6.0) - 2025-08-19 - -### Added - -- Add MCP Elicitation support ([#332](https://github.com/modelcontextprotocol/rust-sdk/pull/332)) -- keep internal error in worker's quit reason ([#372](https://github.com/modelcontextprotocol/rust-sdk/pull/372)) - -### Fixed - -- match shape of the calltoolresult schema ([#377](https://github.com/modelcontextprotocol/rust-sdk/pull/377)) -- make stdio shutdown more graceful ([#364](https://github.com/modelcontextprotocol/rust-sdk/pull/364)) -- *(tool)* remove unnecessary schema validation ([#375](https://github.com/modelcontextprotocol/rust-sdk/pull/375)) -- *(rmcp)* return serialized json with structured content ([#368](https://github.com/modelcontextprotocol/rust-sdk/pull/368)) - -### Other - -- add related project rustfs-mcp ([#378](https://github.com/modelcontextprotocol/rust-sdk/pull/378)) -- *(streamable)* add document for extracting http info ([#373](https://github.com/modelcontextprotocol/rust-sdk/pull/373)) - -## [0.5.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.4.1...rmcp-v0.5.0) - 2025-08-07 - -### Fixed - -- correct numeric types in progress notifications ([#361](https://github.com/modelcontextprotocol/rust-sdk/pull/361)) - -## [0.4.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.4.0...rmcp-v0.4.1) - 2025-08-07 - -### Fixed - -- *(rmcp)* allow both content and structured content ([#359](https://github.com/modelcontextprotocol/rust-sdk/pull/359)) - -## [0.4.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.3.2...rmcp-v0.4.0) - 2025-08-05 - -### Added - -- [**breaking**] Add support for `Tool.outputSchema` and `CallToolResult.structuredContent` ([#316](https://github.com/modelcontextprotocol/rust-sdk/pull/316)) - -### Fixed - -- don't wrap errors in streamable http auth client ([#353](https://github.com/modelcontextprotocol/rust-sdk/pull/353)) -- *(prompt)* remove unused code ([#343](https://github.com/modelcontextprotocol/rust-sdk/pull/343)) - -## [0.3.2](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.3.1...rmcp-v0.3.2) - 2025-07-30 - -### Fixed - -- *(capabilities)* do not serialize None as null for `list_changed` ([#341](https://github.com/modelcontextprotocol/rust-sdk/pull/341)) -- *(Transport)* close oneshot transport on error ([#340](https://github.com/modelcontextprotocol/rust-sdk/pull/340)) -- *(oauth)* expose OAuthTokenResponse publicly ([#335](https://github.com/modelcontextprotocol/rust-sdk/pull/335)) - -## [0.3.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.3.0...rmcp-v0.3.1) - 2025-07-29 - -### Fixed - -- use mimeType instead of mime_type for MCP specification compliance ([#339](https://github.com/modelcontextprotocol/rust-sdk/pull/339)) -- return a 405 for GET and DELETE if stateful_mode=false ([#331](https://github.com/modelcontextprotocol/rust-sdk/pull/331)) -- propagate tracing spans when spawning new tokio tasks ([#334](https://github.com/modelcontextprotocol/rust-sdk/pull/334)) -- Explicitly added client_id as an extra parameter causes bad token requests ([#322](https://github.com/modelcontextprotocol/rust-sdk/pull/322)) - -### Other - -- Fix formatting in crate descriptions in README.md ([#333](https://github.com/modelcontextprotocol/rust-sdk/pull/333)) - -## [0.3.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.2.1...rmcp-v0.3.0) - 2025-07-15 - -### Added - -- unified error type ([#308](https://github.com/modelcontextprotocol/rust-sdk/pull/308)) -- *(transport)* add builder & expose child stderr ([#305](https://github.com/modelcontextprotocol/rust-sdk/pull/305)) - -### Other - -- *(deps)* update schemars requirement from 0.8 to 1.0 ([#258](https://github.com/modelcontextprotocol/rust-sdk/pull/258)) -- *(rmcp)* bump rmcp-macros version to match ([#311](https://github.com/modelcontextprotocol/rust-sdk/pull/311)) -- fix packages used for server example ([#309](https://github.com/modelcontextprotocol/rust-sdk/pull/309)) - -## [0.2.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.2.0...rmcp-v0.2.1) - 2025-07-03 - -### Other - -- *(docs)* Minor README updates ([#301](https://github.com/modelcontextprotocol/rust-sdk/pull/301)) - -## [0.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.1.5...rmcp-v0.2.0) - 2025-07-02 - -### Added - -- mark boxed http body as sync ([#291](https://github.com/modelcontextprotocol/rust-sdk/pull/291)) -- add progress notification handling and related structures ([#282](https://github.com/modelcontextprotocol/rust-sdk/pull/282)) -- allow failable service creation in streamable HTTP tower service ([#244](https://github.com/modelcontextprotocol/rust-sdk/pull/244)) -- provide more context information ([#236](https://github.com/modelcontextprotocol/rust-sdk/pull/236)) -- stateless mode of streamable http client ([#233](https://github.com/modelcontextprotocol/rust-sdk/pull/233)) -- add cancellation_token method to `RunningService` ([#218](https://github.com/modelcontextprotocol/rust-sdk/pull/218)) -- better http server support ([#199](https://github.com/modelcontextprotocol/rust-sdk/pull/199)) -- throw initialize error detail ([#192](https://github.com/modelcontextprotocol/rust-sdk/pull/192)) -- sse client optionally skip the endpoint event ([#187](https://github.com/modelcontextprotocol/rust-sdk/pull/187)) -- *(server)* add annotation to tool macro ([#184](https://github.com/modelcontextprotocol/rust-sdk/pull/184)) -- *(model)* add json schema generation support for all model types ([#176](https://github.com/modelcontextprotocol/rust-sdk/pull/176)) -- *(openapi)* add OpenAPI v3 compatibility and test for nullable field schema workaround ([#135](https://github.com/modelcontextprotocol/rust-sdk/pull/135)) ([#137](https://github.com/modelcontextprotocol/rust-sdk/pull/137)) -- *(extension)* extract http request part into rmcp extension ([#163](https://github.com/modelcontextprotocol/rust-sdk/pull/163)) -- *(transport)* support streamable http server ([#152](https://github.com/modelcontextprotocol/rust-sdk/pull/152)) -- *(oauth)* fixes + cache client credentials ([#157](https://github.com/modelcontextprotocol/rust-sdk/pull/157)) -- allow use of reqwest without ring provider ([#155](https://github.com/modelcontextprotocol/rust-sdk/pull/155)) -- extensions to context ([#102](https://github.com/modelcontextprotocol/rust-sdk/pull/102)) -- revision-2025-03-26 without streamable http ([#84](https://github.com/modelcontextprotocol/rust-sdk/pull/84)) -- *(tool)* allow tool call return a serializable value in json format ([#75](https://github.com/modelcontextprotocol/rust-sdk/pull/75)) ([#78](https://github.com/modelcontextprotocol/rust-sdk/pull/78)) -- Sse server auto ping ([#74](https://github.com/modelcontextprotocol/rust-sdk/pull/74)) -- *(transport)* Sse client transport trait ([#67](https://github.com/modelcontextprotocol/rust-sdk/pull/67)) - -### Fixed - -- let users decide what to wrap in child process command ([#279](https://github.com/modelcontextprotocol/rust-sdk/pull/279)) -- cancellable initialization process ([#280](https://github.com/modelcontextprotocol/rust-sdk/pull/280)) -- inject part into extension when handing init req ([#275](https://github.com/modelcontextprotocol/rust-sdk/pull/275)) -- streamable http server close request channel on response([#266](https://github.com/modelcontextprotocol/rust-sdk/pull/266)) ([#270](https://github.com/modelcontextprotocol/rust-sdk/pull/270)) -- streamable http client close on response ([#268](https://github.com/modelcontextprotocol/rust-sdk/pull/268)) -- expose TokioChildWrapper::id() in TokioChildProcess and TokioChildProcessOut ([#254](https://github.com/modelcontextprotocol/rust-sdk/pull/254)) -- add compatibility handling for non-standard notifications in async_rw ([#247](https://github.com/modelcontextprotocol/rust-sdk/pull/247)) -- allow SSE server router to be nested ([#240](https://github.com/modelcontextprotocol/rust-sdk/pull/240)) -- error for status in post method of streamable http client ([#238](https://github.com/modelcontextprotocol/rust-sdk/pull/238)) -- disable wasmbind in chrono for wasm32-unknown-unknown ([#234](https://github.com/modelcontextprotocol/rust-sdk/pull/234)) -- *(examples)* add clients in examples's readme ([#225](https://github.com/modelcontextprotocol/rust-sdk/pull/225)) -- generic ServerHandler ([#223](https://github.com/modelcontextprotocol/rust-sdk/pull/223)) -- comment error ([#215](https://github.com/modelcontextprotocol/rust-sdk/pull/215)) -- resolve the server 406 error in API calls ([#203](https://github.com/modelcontextprotocol/rust-sdk/pull/203)) -- sse endpoint build follow js's `new URL(url, base)` ([#197](https://github.com/modelcontextprotocol/rust-sdk/pull/197)) -- more friendly interface to get service error ([#190](https://github.com/modelcontextprotocol/rust-sdk/pull/190)) -- cleanup zombie processes for child process client ([#156](https://github.com/modelcontextprotocol/rust-sdk/pull/156)) -- *(schemar)* use self-defined settings ([#180](https://github.com/modelcontextprotocol/rust-sdk/pull/180)) -- *(transport-sse-server)* cleanup on connection drop ([#165](https://github.com/modelcontextprotocol/rust-sdk/pull/165)) -- *(test)* skip serialize tool's annotation if empty ([#160](https://github.com/modelcontextprotocol/rust-sdk/pull/160)) -- fix resource leak ([#136](https://github.com/modelcontextprotocol/rust-sdk/pull/136)) -- *(handler)* do call handler methods when initialize server ([#118](https://github.com/modelcontextprotocol/rust-sdk/pull/118)) -- *(server)* schemars compilation errors ([#104](https://github.com/modelcontextprotocol/rust-sdk/pull/104)) -- *(test)* fix test introduced by #97 ([#101](https://github.com/modelcontextprotocol/rust-sdk/pull/101)) -- *(macro)* add generics marco types support ([#98](https://github.com/modelcontextprotocol/rust-sdk/pull/98)) -- *(typo)* nit language corrections ([#90](https://github.com/modelcontextprotocol/rust-sdk/pull/90)) -- *(typo)* s/marcos/macros/ ([#85](https://github.com/modelcontextprotocol/rust-sdk/pull/85)) -- *(client)* add error enum while deal client info ([#76](https://github.com/modelcontextprotocol/rust-sdk/pull/76)) -- *(notification)* fix wrongly error report in notification ([#70](https://github.com/modelcontextprotocol/rust-sdk/pull/70)) -- *(test)* fix tool deserialization error ([#68](https://github.com/modelcontextprotocol/rust-sdk/pull/68)) -- *(server)* add error enum while deal server info ([#51](https://github.com/modelcontextprotocol/rust-sdk/pull/51)) - -### Other - -- add simpling example and test ([#289](https://github.com/modelcontextprotocol/rust-sdk/pull/289)) -- add update for test_message_schema ([#286](https://github.com/modelcontextprotocol/rust-sdk/pull/286)) -- add notion clear in model.rs ([#284](https://github.com/modelcontextprotocol/rust-sdk/pull/284)) -- cov settings, and fix several building warnings ([#281](https://github.com/modelcontextprotocol/rust-sdk/pull/281)) -- refactor tool macros and router implementation ([#261](https://github.com/modelcontextprotocol/rust-sdk/pull/261)) -- fix regression in URL joining ([#265](https://github.com/modelcontextprotocol/rust-sdk/pull/265)) -- remove erroneous definitions_path ([#264](https://github.com/modelcontextprotocol/rust-sdk/pull/264)) -- allow using a TokioCommandWrap for TokioChildProcess::new closes #243 ([#245](https://github.com/modelcontextprotocol/rust-sdk/pull/245)) -- Fix typo ([#249](https://github.com/modelcontextprotocol/rust-sdk/pull/249)) -- provide http server as tower service ([#228](https://github.com/modelcontextprotocol/rust-sdk/pull/228)) -- *(deps)* update sse-stream requirement from 0.1.4 to 0.2.0 ([#230](https://github.com/modelcontextprotocol/rust-sdk/pull/230)) -- Server info is only retrieved once during initialization ([#214](https://github.com/modelcontextprotocol/rust-sdk/pull/214)) -- *(deps)* update base64 requirement from 0.21 to 0.22 ([#209](https://github.com/modelcontextprotocol/rust-sdk/pull/209)) -- revert badge ([#202](https://github.com/modelcontextprotocol/rust-sdk/pull/202)) -- use hierarchical readme for publishing ([#198](https://github.com/modelcontextprotocol/rust-sdk/pull/198)) -- Ci/coverage badge ([#191](https://github.com/modelcontextprotocol/rust-sdk/pull/191)) -- fix error introduced by merge, and reorganize feature ([#185](https://github.com/modelcontextprotocol/rust-sdk/pull/185)) -- Transport trait and worker transport, and streamable http client with those new features. ([#167](https://github.com/modelcontextprotocol/rust-sdk/pull/167)) -- add oauth2 support ([#130](https://github.com/modelcontextprotocol/rust-sdk/pull/130)) -- remove un-used tower.rs ([#125](https://github.com/modelcontextprotocol/rust-sdk/pull/125)) -- update calculator example description ([#115](https://github.com/modelcontextprotocol/rust-sdk/pull/115)) -- fix the url ([#120](https://github.com/modelcontextprotocol/rust-sdk/pull/120)) -- add a simple chat client for example ([#119](https://github.com/modelcontextprotocol/rust-sdk/pull/119)) -- add an overview to `rmcp/src/lib.rs` ([#116](https://github.com/modelcontextprotocol/rust-sdk/pull/116)) -- *(context)* test context request handling and refactor for reusable client-server tests ([#97](https://github.com/modelcontextprotocol/rust-sdk/pull/97)) -- *(logging)* Add tests for logging ([#96](https://github.com/modelcontextprotocol/rust-sdk/pull/96)) -- Adopt Devcontainer for Development Environment ([#81](https://github.com/modelcontextprotocol/rust-sdk/pull/81)) -- fix typos ([#79](https://github.com/modelcontextprotocol/rust-sdk/pull/79)) -- format and fix typo ([#72](https://github.com/modelcontextprotocol/rust-sdk/pull/72)) -- add documentation generation job ([#59](https://github.com/modelcontextprotocol/rust-sdk/pull/59)) -- add test with js server ([#65](https://github.com/modelcontextprotocol/rust-sdk/pull/65)) -- fmt the project ([#54](https://github.com/modelcontextprotocol/rust-sdk/pull/54)) -- *(sse_server)* separate router and server startup ([#52](https://github.com/modelcontextprotocol/rust-sdk/pull/52)) -- fix broken link ([#53](https://github.com/modelcontextprotocol/rust-sdk/pull/53)) -- fix the branch name for git dependency ([#46](https://github.com/modelcontextprotocol/rust-sdk/pull/46)) -- Move whole rmcp crate to official rust sdk ([#44](https://github.com/modelcontextprotocol/rust-sdk/pull/44)) -- Initial commit diff --git a/vendor/rmcp/Cargo.toml b/vendor/rmcp/Cargo.toml deleted file mode 100644 index bf25fff65..000000000 --- a/vendor/rmcp/Cargo.toml +++ /dev/null @@ -1,904 +0,0 @@ -# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO -# -# When uploading crates to the registry Cargo will automatically -# "normalize" Cargo.toml files for maximal compatibility -# with all versions of Cargo and also rewrite `path` dependencies -# to registry (e.g., crates.io) dependencies. -# -# If you are reading this file be aware that the original Cargo.toml -# will likely look very different (and much more reasonable). -# See Cargo.toml.orig for the original contents. - -[package] -edition = "2024" -rust-version = "1.88" -name = "rmcp" -version = "3.1.0" -build = "build.rs" -autolib = false -autobins = false -autoexamples = false -autotests = false -autobenches = false -description = "Rust SDK for Model Context Protocol" -homepage = "https://github.com/modelcontextprotocol/rust-sdk" -documentation = "https://docs.rs/rmcp" -readme = "README.md" -license = "Apache-2.0" -repository = "https://github.com/modelcontextprotocol/rust-sdk/" -resolver = "2" - -[package.metadata.docs.rs] -features = [ - "auth", - "auth-client-credentials-jwt", - "base64", - "client", - "client-side-sse", - "elicitation", - "macros", - "request-state", - "reqwest", - "reqwest-native-tls", - "reqwest-tls-no-provider", - "schemars", - "server", - "server-side-http", - "tower", - "transport-async-rw", - "transport-child-process", - "transport-io", - "transport-streamable-http-client", - "transport-streamable-http-client-reqwest", - "transport-streamable-http-client-unix-socket", - "transport-streamable-http-server", - "transport-streamable-http-server-session", - "transport-worker", - "uuid", -] -rustdoc-args = [ - "--cfg", - "docsrs", -] - -[features] -__reqwest = ["dep:reqwest"] -auth = [ - "dep:oauth2", - "__reqwest", - "dep:url", -] -auth-client-credentials-jwt = [ - "auth", - "dep:jsonwebtoken", - "uuid", -] -client = ["dep:tokio-stream"] -client-side-sse = [ - "dep:sse-stream", - "dep:http", - "dep:bytes", - "base64", -] -default = [ - "base64", - "macros", - "server", -] -elicitation = ["dep:url"] -local = ["rmcp-macros?/local"] -macros = [ - "dep:rmcp-macros", - "dep:pastey", -] -request-state = [ - "dep:hmac", - "dep:sha2", - "base64", -] -reqwest = [ - "__reqwest", - "reqwest?/rustls", -] -reqwest-native-tls = [ - "__reqwest", - "reqwest?/native-tls", -] -reqwest-tls-no-provider = [ - "__reqwest", - "reqwest?/rustls-no-provider", -] -schemars = ["dep:schemars"] -server = [ - "transport-async-rw", - "schemars", - "dep:pastey", - "uuid", -] -server-side-http = [ - "uuid", - "dep:rand", - "dep:tokio-stream", - "dep:http", - "dep:http-body", - "dep:http-body-util", - "dep:bytes", - "dep:sse-stream", - "tower", - "base64", -] -tower = ["dep:tower-service"] -transport-async-rw = [ - "tokio/io-util", - "tokio-util/codec", -] -transport-child-process = [ - "transport-async-rw", - "tokio/process", - "dep:process-wrap", -] -transport-io = [ - "transport-async-rw", - "tokio/io-std", -] -transport-streamable-http-client = [ - "client-side-sse", - "transport-worker", -] -transport-streamable-http-client-reqwest = [ - "transport-streamable-http-client", - "__reqwest", -] -transport-streamable-http-client-unix-socket = [ - "transport-streamable-http-client", - "dep:hyper", - "dep:hyper-util", - "dep:http-body-util", - "dep:http", - "dep:bytes", - "tokio/net", -] -transport-streamable-http-server = [ - "transport-streamable-http-server-session", - "server-side-http", - "transport-worker", -] -transport-streamable-http-server-session = [ - "transport-async-rw", - "dep:tokio-stream", -] -transport-worker = ["dep:tokio-stream"] -which-command = [ - "transport-child-process", - "dep:which", -] - -[lib] -name = "rmcp" -path = "src/lib.rs" - -[[test]] -name = "test_auth_error_classification" -path = "tests/test_auth_error_classification.rs" -required-features = [ - "auth", - "client", - "transport-streamable-http-client", -] - -[[test]] -name = "test_cache_hints" -path = "tests/test_cache_hints.rs" - -[[test]] -name = "test_cancelled_response" -path = "tests/test_cancelled_response.rs" - -[[test]] -name = "test_client_credentials" -path = "tests/test_client_credentials.rs" -required-features = ["auth"] - -[[test]] -name = "test_client_initialization" -path = "tests/test_client_initialization.rs" - -[[test]] -name = "test_client_lifecycle_modes" -path = "tests/test_client_lifecycle_modes.rs" -required-features = [ - "client", - "server", -] - -[[test]] -name = "test_close_connection" -path = "tests/test_close_connection.rs" -required-features = [ - "server", - "client", -] - -[[test]] -name = "test_completion" -path = "tests/test_completion.rs" - -[[test]] -name = "test_complex_schema" -path = "tests/test_complex_schema.rs" - -[[test]] -name = "test_custom_headers" -path = "tests/test_custom_headers.rs" -required-features = [ - "client", - "server", - "transport-streamable-http-client-reqwest", - "transport-streamable-http-server", -] - -[[test]] -name = "test_custom_request" -path = "tests/test_custom_request.rs" -required-features = [ - "server", - "client", -] - -[[test]] -name = "test_deserialization" -path = "tests/test_deserialization.rs" - -[[test]] -name = "test_discover_http_client_startup" -path = "tests/test_discover_http_client_startup.rs" -required-features = [ - "client", - "reqwest", - "transport-streamable-http-client-reqwest", - "transport-streamable-http-server", -] - -[[test]] -name = "test_elicitation" -path = "tests/test_elicitation.rs" -required-features = [ - "elicitation", - "client", - "server", -] - -[[test]] -name = "test_embedded_resource_meta" -path = "tests/test_embedded_resource_meta.rs" - -[[test]] -name = "test_handler_wrappers" -path = "tests/test_handler_wrappers.rs" - -[[test]] -name = "test_inflight_response_drain" -path = "tests/test_inflight_response_drain.rs" - -[[test]] -name = "test_json_schema_detection" -path = "tests/test_json_schema_detection.rs" - -[[test]] -name = "test_list_tools_result" -path = "tests/test_list_tools_result.rs" - -[[test]] -name = "test_local_client_handler" -path = "tests/test_local_client_handler.rs" -required-features = [ - "client", - "local", -] - -[[test]] -name = "test_logging" -path = "tests/test_logging.rs" -required-features = [ - "server", - "client", -] - -[[test]] -name = "test_message_protocol" -path = "tests/test_message_protocol.rs" -required-features = ["client"] - -[[test]] -name = "test_message_schema" -path = "tests/test_message_schema.rs" -required-features = [ - "server", - "client", - "schemars", -] - -[[test]] -name = "test_meta_helpers" -path = "tests/test_meta_helpers.rs" - -[[test]] -name = "test_mrtr_behavior" -path = "tests/test_mrtr_behavior.rs" -required-features = [ - "server", - "client", -] - -[[test]] -name = "test_notification" -path = "tests/test_notification.rs" -required-features = [ - "server", - "client", -] - -[[test]] -name = "test_progress_subscriber" -path = "tests/test_progress_subscriber.rs" -required-features = [ - "server", - "client", - "macros", -] - -[[test]] -name = "test_prompt_handler" -path = "tests/test_prompt_handler.rs" - -[[test]] -name = "test_prompt_macro_annotations" -path = "tests/test_prompt_macro_annotations.rs" - -[[test]] -name = "test_prompt_macros" -path = "tests/test_prompt_macros.rs" -required-features = [ - "server", - "client", -] - -[[test]] -name = "test_prompt_routers" -path = "tests/test_prompt_routers.rs" - -[[test]] -name = "test_protocol_version_negotiation" -path = "tests/test_protocol_version_negotiation.rs" -required-features = [ - "server", - "client", -] - -[[test]] -name = "test_request_timeout_progress" -path = "tests/test_request_timeout_progress.rs" -required-features = [ - "server", - "client", - "macros", -] - -[[test]] -name = "test_resource_link" -path = "tests/test_resource_link.rs" - -[[test]] -name = "test_resource_link_integration" -path = "tests/test_resource_link_integration.rs" - -[[test]] -name = "test_resource_not_found_version" -path = "tests/test_resource_not_found_version.rs" - -[[test]] -name = "test_result_type_version" -path = "tests/test_result_type_version.rs" - -[[test]] -name = "test_result_type_wire" -path = "tests/test_result_type_wire.rs" - -[[test]] -name = "test_sampling" -path = "tests/test_sampling.rs" -required-features = [ - "server", - "client", -] - -[[test]] -name = "test_sep_2260_request_association" -path = "tests/test_sep_2260_request_association.rs" - -[[test]] -name = "test_sep_2260_stream_enforcement" -path = "tests/test_sep_2260_stream_enforcement.rs" - -[[test]] -name = "test_sep_2260_stream_routing" -path = "tests/test_sep_2260_stream_routing.rs" -required-features = [ - "server", - "elicitation", - "transport-streamable-http-server", - "reqwest", -] - -[[test]] -name = "test_server_discover" -path = "tests/test_server_discover.rs" - -[[test]] -name = "test_server_discover_client" -path = "tests/test_server_discover_client.rs" - -[[test]] -name = "test_server_discover_http" -path = "tests/test_server_discover_http.rs" - -[[test]] -name = "test_server_initialization" -path = "tests/test_server_initialization.rs" - -[[test]] -name = "test_sse_concurrent_streams" -path = "tests/test_sse_concurrent_streams.rs" -required-features = [ - "server", - "client", - "transport-streamable-http-server", - "transport-streamable-http-client", - "reqwest", -] - -[[test]] -name = "test_stateless_protocol_version" -path = "tests/test_stateless_protocol_version.rs" -required-features = [ - "server", - "transport-streamable-http-server", - "reqwest", -] - -[[test]] -name = "test_stateless_server_requests" -path = "tests/test_stateless_server_requests.rs" -required-features = [ - "client", - "server", -] - -[[test]] -name = "test_stdio_response_concurrency" -path = "tests/test_stdio_response_concurrency.rs" - -[[test]] -name = "test_streamable_http_4xx_error_body" -path = "tests/test_streamable_http_4xx_error_body.rs" -required-features = [ - "transport-streamable-http-client", - "transport-streamable-http-client-reqwest", -] - -[[test]] -name = "test_streamable_http_connection_reuse" -path = "tests/test_streamable_http_connection_reuse.rs" -required-features = [ - "server", - "client", - "macros", - "schemars", - "transport-streamable-http-server", - "transport-streamable-http-client", - "transport-streamable-http-client-reqwest", -] - -[[test]] -name = "test_streamable_http_disconnect_cancel" -path = "tests/test_streamable_http_disconnect_cancel.rs" -required-features = [ - "server", - "transport-streamable-http-server", - "reqwest", -] - -[[test]] -name = "test_streamable_http_empty_2xx_notification" -path = "tests/test_streamable_http_empty_2xx_notification.rs" - -[[test]] -name = "test_streamable_http_event_store" -path = "tests/test_streamable_http_event_store.rs" -required-features = [ - "client", - "server", - "transport-streamable-http-client-reqwest", - "transport-streamable-http-server", -] - -[[test]] -name = "test_streamable_http_idle_timeout_log" -path = "tests/test_streamable_http_idle_timeout_log.rs" - -[[test]] -name = "test_streamable_http_init_timeout" -path = "tests/test_streamable_http_init_timeout.rs" - -[[test]] -name = "test_streamable_http_json_response" -path = "tests/test_streamable_http_json_response.rs" -required-features = [ - "server", - "client", - "transport-streamable-http-server", - "reqwest", -] - -[[test]] -name = "test_streamable_http_priming" -path = "tests/test_streamable_http_priming.rs" -required-features = [ - "server", - "client", - "transport-streamable-http-server", - "reqwest", -] - -[[test]] -name = "test_streamable_http_protocol_version" -path = "tests/test_streamable_http_protocol_version.rs" -required-features = [ - "server", - "client", - "transport-streamable-http-server", - "reqwest", -] - -[[test]] -name = "test_streamable_http_session_store" -path = "tests/test_streamable_http_session_store.rs" -required-features = [ - "client", - "server", - "transport-streamable-http-client-reqwest", - "transport-streamable-http-server", -] - -[[test]] -name = "test_streamable_http_stale_session" -path = "tests/test_streamable_http_stale_session.rs" -required-features = [ - "server", - "client", - "transport-streamable-http-server", - "transport-streamable-http-client", - "transport-streamable-http-client-reqwest", -] - -[[test]] -name = "test_streamable_http_standard_headers" -path = "tests/test_streamable_http_standard_headers.rs" -required-features = [ - "server", - "client", - "transport-streamable-http-server", - "reqwest", -] - -[[test]] -name = "test_structured_output" -path = "tests/test_structured_output.rs" - -[[test]] -name = "test_subscriptions" -path = "tests/test_subscriptions.rs" - -[[test]] -name = "test_subscriptions_model" -path = "tests/test_subscriptions_model.rs" - -[[test]] -name = "test_subscriptions_streamable_http" -path = "tests/test_subscriptions_streamable_http.rs" - -[[test]] -name = "test_task" -path = "tests/test_task.rs" -required-features = [ - "server", - "client", - "macros", -] - -[[test]] -name = "test_tool_builder_methods" -path = "tests/test_tool_builder_methods.rs" - -[[test]] -name = "test_tool_disable_notification" -path = "tests/test_tool_disable_notification.rs" - -[[test]] -name = "test_tool_handler" -path = "tests/test_tool_handler.rs" - -[[test]] -name = "test_tool_macro_annotations" -path = "tests/test_tool_macro_annotations.rs" - -[[test]] -name = "test_tool_macros" -path = "tests/test_tool_macros.rs" -required-features = [ - "server", - "client", -] - -[[test]] -name = "test_tool_result_meta" -path = "tests/test_tool_result_meta.rs" - -[[test]] -name = "test_tool_routers" -path = "tests/test_tool_routers.rs" - -[[test]] -name = "test_trace_context" -path = "tests/test_trace_context.rs" -required-features = [ - "server", - "client", -] - -[[test]] -name = "test_unix_socket_transport" -path = "tests/test_unix_socket_transport.rs" -required-features = [ - "client", - "server", - "transport-streamable-http-client-unix-socket", -] - -[[test]] -name = "test_with_js" -path = "tests/test_with_js.rs" -required-features = [ - "server", - "client", - "transport-child-process", - "transport-streamable-http-server", - "transport-streamable-http-client", - "__reqwest", -] - -[[test]] -name = "test_with_python" -path = "tests/test_with_python.rs" -required-features = [ - "reqwest", - "server", - "client", - "transport-child-process", -] - -[dependencies.async-trait] -version = "0.1.89" - -[dependencies.base64] -version = "0.23" -optional = true - -[dependencies.bytes] -version = "1" -optional = true - -[dependencies.futures] -version = "0.3" - -[dependencies.hmac] -version = "0.13" -optional = true - -[dependencies.http] -version = "1" -optional = true - -[dependencies.http-body] -version = "1" -optional = true - -[dependencies.http-body-util] -version = "0.1" -optional = true - -[dependencies.hyper] -version = "1" -features = [ - "client", - "http1", -] -optional = true - -[dependencies.hyper-util] -version = "0.1" -features = ["tokio"] -optional = true - -[dependencies.jsonwebtoken] -version = "11" -features = ["aws_lc_rs"] -optional = true - -[dependencies.oauth2] -version = "5.0" -optional = true -default-features = false - -[dependencies.pastey] -version = "0.2.0" -optional = true - -[dependencies.pin-project-lite] -version = "0.2" - -[dependencies.process-wrap] -version = "9.0" -features = ["tokio1"] -optional = true - -[dependencies.rand] -version = "0.10" -optional = true - -[dependencies.reqwest] -version = "0.13.2" -features = [ - "json", - "stream", -] -optional = true -default-features = false - -[dependencies.rmcp-macros] -version = "3.1.0" -optional = true - -[dependencies.schemars] -version = "1.0" -features = ["chrono04"] -optional = true - -[dependencies.serde] -version = "1.0" -features = [ - "derive", - "rc", -] - -[dependencies.serde_json] -version = "1.0" - -[dependencies.sha2] -version = "0.11" -optional = true - -[dependencies.sse-stream] -version = "0.2.4" -optional = true - -[dependencies.thiserror] -version = "2" - -[dependencies.tokio] -version = "1" -features = [ - "sync", - "macros", - "rt", - "time", -] - -[dependencies.tokio-stream] -version = "0.1" -optional = true - -[dependencies.tokio-util] -version = "0.7" - -[dependencies.tower-service] -version = "0.3" -optional = true - -[dependencies.tracing] -version = "0.1" - -[dependencies.url] -version = "2.4" -optional = true - -[dependencies.uuid] -version = "1" -features = ["v4"] -optional = true - -[dependencies.which] -version = "8" -optional = true - -[dev-dependencies.anyhow] -version = "1.0" - -[dev-dependencies.async-trait] -version = "0.1" - -[dev-dependencies.axum] -version = "0.8" -features = [ - "http1", - "tokio", -] -default-features = false - -[dev-dependencies.hyper] -version = "1" -features = [ - "server", - "http1", -] - -[dev-dependencies.hyper-util] -version = "0.1" -features = ["tokio"] - -[dev-dependencies.rstest] -version = "0.26.1" - -[dev-dependencies.schemars] -version = "1.1.0" -features = ["chrono04"] - -[dev-dependencies.tokio] -version = "1" -features = ["full"] - -[dev-dependencies.tower-service] -version = "0.3" - -[dev-dependencies.tracing-subscriber] -version = "0.3" -features = [ - "env-filter", - "std", - "fmt", -] - -[dev-dependencies.url] -version = "2.4" - -[target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies.chrono] -version = "0.4.38" -features = [ - "serde", - "clock", - "std", - "oldtime", -] -default-features = false - -[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies.chrono] -version = "0.4.38" -features = [ - "serde", - "now", -] -default-features = false - -[lints.clippy] -exhaustive_enums = "warn" -exhaustive_structs = "warn" diff --git a/vendor/rmcp/Cargo.toml.orig b/vendor/rmcp/Cargo.toml.orig deleted file mode 100644 index 7ce31aa0c..000000000 --- a/vendor/rmcp/Cargo.toml.orig +++ /dev/null @@ -1,463 +0,0 @@ -[package] -name = "rmcp" -license = { workspace = true } -version = { workspace = true } -edition = { workspace = true } -rust-version = { workspace = true } -repository = { workspace = true } -homepage = { workspace = true } -readme = { workspace = true } -description = "Rust SDK for Model Context Protocol" -documentation = "https://docs.rs/rmcp" - -[lints.clippy] -exhaustive_structs = "warn" -exhaustive_enums = "warn" - -[package.metadata.docs.rs] -features = [ - "auth", - "auth-client-credentials-jwt", - "base64", - "client", - "client-side-sse", - "elicitation", - "macros", - "request-state", - "reqwest", - "reqwest-native-tls", - "reqwest-tls-no-provider", - "schemars", - "server", - "server-side-http", - "tower", - "transport-async-rw", - "transport-child-process", - "transport-io", - "transport-streamable-http-client", - "transport-streamable-http-client-reqwest", - "transport-streamable-http-client-unix-socket", - "transport-streamable-http-server", - "transport-streamable-http-server-session", - "transport-worker", - "uuid", -] -rustdoc-args = ["--cfg", "docsrs"] - -[dependencies] -async-trait = "0.1.89" -serde = { version = "1.0", features = ["derive", "rc"] } -serde_json = "1.0" -thiserror = "2" -tokio = { version = "1", features = ["sync", "macros", "rt", "time"] } -futures = "0.3" -tracing = { version = "0.1" } -tokio-util = { version = "0.7" } -pin-project-lite = "0.2" -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 = "11", optional = true, features = ["aws_lc_rs"] } - -# for auto generate schema -schemars = { version = "1.0", optional = true, features = ["chrono04"] } - -# for image encoding -base64 = { version = "0.23", optional = true } - -# for SEP-2322 requestState integrity sealing (opt-in via the `request-state` feature) -hmac = { version = "0.13", optional = true } -sha2 = { version = "0.11", optional = true } - -# for HTTP client -reqwest = { version = "0.13.2", default-features = false, features = [ - "json", - "stream", -], optional = true } - -sse-stream = { version = "0.2.4", optional = true } - -http = { version = "1", optional = true } -url = { version = "2.4", optional = true } - -# For tower compatibility -tower-service = { version = "0.3", optional = true } - -# for child process transport -process-wrap = { version = "9.0", features = ["tokio1"], optional = true } - -# for cross-platform executable path resolution -which = { version = "8", optional = true } - -# for ws transport -# tokio-tungstenite ={ version = "0.26", optional = true } - -# for http-server transport -rand = { version = "0.10", optional = true } -tokio-stream = { version = "0.1", optional = true } -uuid = { version = "1", features = ["v4"], optional = true } -http-body = { version = "1", optional = true } -http-body-util = { version = "0.1", optional = true } -bytes = { version = "1", optional = true } - -# for unix socket transport -hyper = { version = "1", features = ["client", "http1"], optional = true } -hyper-util = { version = "0.1", features = ["tokio"], optional = true } - -# macro -rmcp-macros = { version = "3.1.0", optional = true } -[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies] -chrono = { version = "0.4.38", default-features = false, features = ["serde", "now"] } - -[target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] -chrono = { version = "0.4.38", default-features = false, features = [ - "serde", - "clock", - "std", - "oldtime", -] } - -[features] -default = ["base64", "macros", "server"] -local = ["rmcp-macros?/local"] -client = ["dep:tokio-stream"] -server = ["transport-async-rw", "schemars", "dep:pastey", "uuid"] -macros = ["dep:rmcp-macros", "dep:pastey"] -elicitation = ["dep:url"] - -# SEP-2322 requestState integrity helper (HMAC-SHA256 seal/open codec) -request-state = ["dep:hmac", "dep:sha2", "base64"] - -# reqwest http client -__reqwest = ["dep:reqwest"] - -reqwest = ["__reqwest", "reqwest?/rustls"] - -reqwest-tls-no-provider = ["__reqwest", "reqwest?/rustls-no-provider"] - -reqwest-native-tls = ["__reqwest", "reqwest?/native-tls"] - -server-side-http = [ - "uuid", - "dep:rand", - "dep:tokio-stream", - "dep:http", - "dep:http-body", - "dep:http-body-util", - "dep:bytes", - "dep:sse-stream", - "tower", - "base64", -] - -transport-worker = ["dep:tokio-stream"] - -# SSE stream parsing utilities (used by streamable HTTP client for SSE-formatted responses) -client-side-sse = ["dep:sse-stream", "dep:http", "dep:bytes", "base64"] - -# Streamable HTTP client -transport-streamable-http-client = ["client-side-sse", "transport-worker"] -transport-streamable-http-client-reqwest = ["transport-streamable-http-client", "__reqwest"] -transport-streamable-http-client-unix-socket = [ - "transport-streamable-http-client", - "dep:hyper", - "dep:hyper-util", - "dep:http-body-util", - "dep:http", - "dep:bytes", - "tokio/net", -] - -transport-async-rw = ["tokio/io-util", "tokio-util/codec"] -transport-io = ["transport-async-rw", "tokio/io-std"] -transport-child-process = [ - "transport-async-rw", - "tokio/process", - "dep:process-wrap", -] -which-command = [ - "transport-child-process", - "dep:which", -] -transport-streamable-http-server = [ - "transport-streamable-http-server-session", - "server-side-http", - "transport-worker", -] -transport-streamable-http-server-session = [ - "transport-async-rw", - "dep:tokio-stream", -] -# transport-ws = ["transport-io", "dep:tokio-tungstenite"] -tower = ["dep:tower-service"] -auth = ["dep:oauth2", "__reqwest", "dep:url"] -auth-client-credentials-jwt = ["auth", "dep:jsonwebtoken", "uuid"] -schemars = ["dep:schemars"] - -[dev-dependencies] -tokio = { version = "1", features = ["full"] } -schemars = { version = "1.1.0", features = ["chrono04"] } -axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] } -hyper = { version = "1", features = ["server", "http1"] } -hyper-util = { version = "0.1", features = ["tokio"] } -tower-service = "0.3" -url = "2.4" -anyhow = "1.0" -tracing-subscriber = { version = "0.3", features = [ - "env-filter", - "std", - "fmt", -] } -async-trait = "0.1" -rstest = "0.26.1" -[[test]] -name = "test_tool_macros" -required-features = ["server", "client"] -path = "tests/test_tool_macros.rs" - -[[test]] -name = "test_with_python" -required-features = [ - "reqwest", - "server", - "client", - "transport-child-process", -] -path = "tests/test_with_python.rs" - -[[test]] -name = "test_with_js" -required-features = [ - "server", - "client", - "transport-child-process", - "transport-streamable-http-server", - "transport-streamable-http-client", - "__reqwest", -] -path = "tests/test_with_js.rs" - -[[test]] -name = "test_local_client_handler" -required-features = ["client", "local"] -path = "tests/test_local_client_handler.rs" - -[[test]] -name = "test_notification" -required-features = ["server", "client"] -path = "tests/test_notification.rs" - -[[test]] -name = "test_logging" -required-features = ["server", "client"] -path = "tests/test_logging.rs" - -[[test]] -name = "test_message_protocol" -required-features = ["client"] -path = "tests/test_message_protocol.rs" - -[[test]] -name = "test_message_schema" -required-features = ["server", "client", "schemars"] -path = "tests/test_message_schema.rs" - -[[test]] -name = "test_progress_subscriber" -required-features = ["server", "client", "macros"] -path = "tests/test_progress_subscriber.rs" - -[[test]] -name = "test_request_timeout_progress" -required-features = ["server", "client", "macros"] -path = "tests/test_request_timeout_progress.rs" - -[[test]] -name = "test_elicitation" -required-features = ["elicitation", "client", "server"] -path = "tests/test_elicitation.rs" - -[[test]] -name = "test_task" -required-features = ["server", "client", "macros"] -path = "tests/test_task.rs" - -[[test]] -name = "test_streamable_http_priming" -required-features = ["server", "client", "transport-streamable-http-server", "reqwest"] -path = "tests/test_streamable_http_priming.rs" - -[[test]] -name = "test_streamable_http_json_response" -required-features = ["server", "client", "transport-streamable-http-server", "reqwest"] -path = "tests/test_streamable_http_json_response.rs" - -[[test]] -name = "test_streamable_http_protocol_version" -required-features = ["server", "client", "transport-streamable-http-server", "reqwest"] -path = "tests/test_streamable_http_protocol_version.rs" - -[[test]] -name = "test_stateless_protocol_version" -required-features = ["server", "transport-streamable-http-server", "reqwest"] -path = "tests/test_stateless_protocol_version.rs" - -[[test]] -name = "test_protocol_version_negotiation" -required-features = ["server", "client"] -path = "tests/test_protocol_version_negotiation.rs" - -[[test]] -name = "test_client_lifecycle_modes" -required-features = ["client", "server"] -path = "tests/test_client_lifecycle_modes.rs" - -[[test]] -name = "test_stateless_server_requests" -required-features = ["client", "server"] -path = "tests/test_stateless_server_requests.rs" - -[[test]] -name = "test_discover_http_client_startup" -required-features = [ - "client", - "reqwest", - "transport-streamable-http-client-reqwest", - "transport-streamable-http-server", -] -path = "tests/test_discover_http_client_startup.rs" - -[[test]] -name = "test_streamable_http_standard_headers" -required-features = ["server", "client", "transport-streamable-http-server", "reqwest"] -path = "tests/test_streamable_http_standard_headers.rs" - -[[test]] -name = "test_streamable_http_4xx_error_body" -required-features = ["transport-streamable-http-client", "transport-streamable-http-client-reqwest"] -path = "tests/test_streamable_http_4xx_error_body.rs" - - -[[test]] -name = "test_custom_request" -required-features = ["server", "client"] -path = "tests/test_custom_request.rs" - -[[test]] -name = "test_trace_context" -required-features = ["server", "client"] -path = "tests/test_trace_context.rs" - -[[test]] -name = "test_mrtr_behavior" -required-features = ["server", "client"] -path = "tests/test_mrtr_behavior.rs" - -[[test]] -name = "test_prompt_macros" -required-features = ["server", "client"] -path = "tests/test_prompt_macros.rs" - -[[test]] -name = "test_sampling" -required-features = ["server", "client"] -path = "tests/test_sampling.rs" - - -[[test]] -name = "test_close_connection" -required-features = ["server", "client"] -path = "tests/test_close_connection.rs" - -[[test]] -name = "test_custom_headers" -required-features = [ - "client", - "server", - "transport-streamable-http-client-reqwest", - "transport-streamable-http-server", -] -path = "tests/test_custom_headers.rs" - -[[test]] -name = "test_sse_concurrent_streams" -required-features = ["server", "client", "transport-streamable-http-server", "transport-streamable-http-client", "reqwest"] -path = "tests/test_sse_concurrent_streams.rs" - -[[test]] -name = "test_client_credentials" -required-features = ["auth"] -path = "tests/test_client_credentials.rs" - -[[test]] -name = "test_auth_error_classification" -required-features = ["auth", "client", "transport-streamable-http-client"] -path = "tests/test_auth_error_classification.rs" - -[[test]] -name = "test_unix_socket_transport" -required-features = [ - "client", - "server", - "transport-streamable-http-client-unix-socket", -] -path = "tests/test_unix_socket_transport.rs" - -[[test]] -name = "test_streamable_http_stale_session" -required-features = [ - "server", - "client", - "transport-streamable-http-server", - "transport-streamable-http-client", - "transport-streamable-http-client-reqwest" -] -path = "tests/test_streamable_http_stale_session.rs" - -[[test]] -name = "test_streamable_http_session_store" -required-features = [ - "client", - "server", - "transport-streamable-http-client-reqwest", - "transport-streamable-http-server", -] -path = "tests/test_streamable_http_session_store.rs" - -[[test]] -name = "test_streamable_http_event_store" -required-features = [ - "client", - "server", - "transport-streamable-http-client-reqwest", - "transport-streamable-http-server", -] -path = "tests/test_streamable_http_event_store.rs" - -[[test]] -name = "test_streamable_http_connection_reuse" -required-features = [ - "server", - "client", - "macros", - "schemars", - "transport-streamable-http-server", - "transport-streamable-http-client", - "transport-streamable-http-client-reqwest", -] -path = "tests/test_streamable_http_connection_reuse.rs" - -[[test]] -name = "test_streamable_http_disconnect_cancel" -required-features = [ - "server", - "transport-streamable-http-server", - "reqwest", -] -path = "tests/test_streamable_http_disconnect_cancel.rs" - -[[test]] -name = "test_sep_2260_stream_routing" -required-features = ["server", "elicitation", "transport-streamable-http-server", "reqwest"] -path = "tests/test_sep_2260_stream_routing.rs" diff --git a/vendor/rmcp/README.md b/vendor/rmcp/README.md deleted file mode 100644 index bb7837e84..000000000 --- a/vendor/rmcp/README.md +++ /dev/null @@ -1,66 +0,0 @@ - - -

- -# rmcp - -[![Crates.io](https://img.shields.io/crates/v/rmcp.svg)](https://crates.io/crates/rmcp) -[![Documentation](https://docs.rs/rmcp/badge.svg)](https://docs.rs/rmcp) - -
- -The official Rust SDK for the [Model Context Protocol](https://modelcontextprotocol.io/specification/2026-07-28). Build MCP servers that expose tools, resources, and prompts to AI assistants — or build clients that connect to them. - -For **getting started**, **usage guides**, and **full MCP feature documentation** (resources, prompts, sampling, roots, logging, completions, subscriptions, etc.), see the [main README](../../README.md). - -## Feature Flags - -| Feature | Description | Default | -|---------|-------------|---------| -| `server` | Server functionality and the tool system | ✅ | -| `client` | Client functionality | | -| `macros` | `#[tool]` / `#[prompt]` macros (re-exports [`rmcp-macros`](../rmcp-macros)) | ✅ | -| `schemars` | JSON Schema generation for tool definitions | | -| `auth` | OAuth 2.0 authentication support | | -| `elicitation` | Elicitation support | | - -### Transport features - -| Feature | Description | -|---------|-------------| -| `transport-io` | Server-side stdio transport | -| `transport-child-process` | Client-side stdio transport (spawns a child process) | -| `transport-async-rw` | Generic async read/write transport | -| `transport-streamable-http-client` | Streamable HTTP client (transport-agnostic) | -| `transport-streamable-http-client-reqwest` | Streamable HTTP client with default `reqwest` backend | -| `transport-streamable-http-server` | Streamable HTTP server transport | - -### TLS backend options (for HTTP transports) - -| Feature | Description | -|---------|-------------| -| `reqwest` | Uses rustls — pure Rust TLS (recommended default) | -| `reqwest-native-tls` | Uses platform-native TLS (OpenSSL / Secure Transport / SChannel) | -| `reqwest-tls-no-provider` | Uses rustls without a default crypto provider (bring your own) | - -## Transports - -The transport layer is pluggable. Two built-in pairs cover the most common cases: - -| | Client | Server | -|:-:|:-:|:-:| -| **stdio** | [`TokioChildProcess`](crate::transport::TokioChildProcess) | [`stdio`](crate::transport::stdio) | -| **Streamable HTTP** | [`StreamableHttpClientTransport`](crate::transport::StreamableHttpClientTransport) | `StreamableHttpService` | - -Any type that implements the [`Transport`](crate::transport::Transport) trait can be used. The [`IntoTransport`](crate::transport::IntoTransport) helper trait provides automatic conversions from: - -1. `(Sink, Stream)` or a combined `Sink + Stream` -2. `(AsyncRead, AsyncWrite)` or a combined `AsyncRead + AsyncWrite` -3. A [`Worker`](crate::transport::worker::Worker) implementation -4. A [`Transport`](crate::transport::Transport) implementation directly - -## License - -This project is licensed under the terms specified in the repository's [LICENSE](../../LICENSE) file. diff --git a/vendor/rmcp/build.rs b/vendor/rmcp/build.rs deleted file mode 100644 index 05702b405..000000000 --- a/vendor/rmcp/build.rs +++ /dev/null @@ -1,23 +0,0 @@ -// Install git hooks on build -fn main() { - // Only run in the workspace root (not when building as a dependency) - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); - let workspace_root = std::path::Path::new(&manifest_dir) - .parent() - .and_then(|p| p.parent()); - - if let Some(root) = workspace_root { - let githooks_dir = root.join(".githooks"); - let git_dir = root.join(".git"); - - // Only configure if we're in the actual workspace (not a dependency) - // and git directory exists - if githooks_dir.exists() && git_dir.exists() { - // Configure git to use our hooks directory - let _ = std::process::Command::new("git") - .args(["config", "core.hooksPath", ".githooks"]) - .current_dir(root) - .output(); - } - } -} diff --git a/vendor/rmcp/src/error.rs b/vendor/rmcp/src/error.rs deleted file mode 100644 index 0940f29c2..000000000 --- a/vendor/rmcp/src/error.rs +++ /dev/null @@ -1,72 +0,0 @@ -use std::{borrow::Cow, fmt::Display}; - -pub use crate::model::ErrorData; -impl Display for ErrorData { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}: {}", self.code.0, self.message)?; - if let Some(data) = &self.data { - write!(f, "({})", data)?; - } - Ok(()) - } -} - -impl std::error::Error for ErrorData {} - -#[cfg(all(feature = "auth", any(feature = "client", feature = "server")))] -pub(crate) struct ErrorChain<'a>(pub(crate) &'a (dyn std::error::Error + 'static)); - -#[cfg(all(feature = "auth", any(feature = "client", feature = "server")))] -impl Display for ErrorChain<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0)?; - for source in std::iter::successors(self.0.source(), |source| source.source()) { - write!(f, "\n Caused by: {source}")?; - } - Ok(()) - } -} - -/// This is an unified error type for the errors could be returned by the service. -#[derive(Debug, thiserror::Error)] -#[allow(clippy::large_enum_variant)] -#[non_exhaustive] -pub enum RmcpError { - #[cfg(any(feature = "client", feature = "server"))] - #[error("Service error: {0}")] - Service(#[from] crate::ServiceError), - #[cfg(feature = "client")] - #[error("Client initialization error: {0}")] - ClientInitialize(#[from] crate::service::ClientInitializeError), - #[cfg(feature = "server")] - #[error("Server initialization error: {0}")] - ServerInitialize(#[from] crate::service::ServerInitializeError), - #[error("Runtime error: {0}")] - Runtime(#[from] tokio::task::JoinError), - #[error("Transport creation error: {error}")] - // TODO: Maybe we can introduce something like `TryIntoTransport` to auto wrap transport type, - // but it could be an breaking change, so we could do it in the future. - TransportCreation { - into_transport_type_name: Cow<'static, str>, - into_transport_type_id: std::any::TypeId, - #[source] - error: Box, - }, - // and cancellation shouldn't be an error? - - // TODO: add more error variants as needed - #[error("Task error: {0}")] - TaskError(String), -} - -impl RmcpError { - pub fn transport_creation( - error: impl Into>, - ) -> Self { - RmcpError::TransportCreation { - into_transport_type_id: std::any::TypeId::of::(), - into_transport_type_name: std::any::type_name::().into(), - error: error.into(), - } - } -} diff --git a/vendor/rmcp/src/handler.rs b/vendor/rmcp/src/handler.rs deleted file mode 100644 index 6b848cd2c..000000000 --- a/vendor/rmcp/src/handler.rs +++ /dev/null @@ -1,4 +0,0 @@ -#[cfg(feature = "client")] -pub mod client; -#[cfg(feature = "server")] -pub mod server; diff --git a/vendor/rmcp/src/handler/client.rs b/vendor/rmcp/src/handler/client.rs deleted file mode 100644 index 414387028..000000000 --- a/vendor/rmcp/src/handler/client.rs +++ /dev/null @@ -1,433 +0,0 @@ -// Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected. -#![expect(deprecated)] -pub mod progress; -use std::sync::Arc; - -use crate::{ - error::ErrorData as McpError, - model::*, - service::{ - MaybeSendFuture, NotificationContext, RequestContext, RoleClient, Service, ServiceRole, - }, -}; - -impl Service for H { - async fn handle_request( - &self, - request: ::PeerReq, - context: RequestContext, - ) -> Result<::Resp, McpError> { - match request { - ServerRequest::PingRequest(_) => self.ping(context).await.map(ClientResult::empty), - ServerRequest::CreateMessageRequest(request) => self - .create_message(request.params, context) - .await - .map(Box::new) - .map(ClientResult::CreateMessageResult), - ServerRequest::ListRootsRequest(_) => self - .list_roots(context) - .await - .map(ClientResult::ListRootsResult), - ServerRequest::ElicitRequest(request) => self - .create_elicitation(request.params, context) - .await - .map(ClientResult::ElicitResult), - ServerRequest::CustomRequest(request) => self - .on_custom_request(request, context) - .await - .map(ClientResult::CustomResult), - } - } - - async fn handle_notification( - &self, - notification: ::PeerNot, - context: NotificationContext, - ) -> Result<(), McpError> { - match notification { - ServerNotification::CancelledNotification(notification) => { - self.on_cancelled(notification.params, context).await - } - ServerNotification::ProgressNotification(notification) => { - self.on_progress(notification.params, context).await - } - ServerNotification::LoggingMessageNotification(notification) => { - self.on_logging_message(notification.params, context).await - } - ServerNotification::ResourceUpdatedNotification(notification) => { - self.on_resource_updated(notification.params, context).await - } - ServerNotification::ResourceListChangedNotification(_notification_no_param) => { - self.on_resource_list_changed(context).await - } - ServerNotification::ToolListChangedNotification(_notification_no_param) => { - self.on_tool_list_changed(context).await - } - ServerNotification::PromptListChangedNotification(_notification_no_param) => { - self.on_prompt_list_changed(context).await - } - ServerNotification::SubscriptionsAcknowledgedNotification(notification) => { - self.on_subscriptions_acknowledged(notification.params, context) - .await - } - ServerNotification::TaskStatusNotification(notification) => { - self.on_task_status(notification.params, context).await - } - ServerNotification::CustomNotification(notification) => { - self.on_custom_notification(notification, context).await - } - }; - Ok(()) - } - - fn get_info(&self) -> ::Info { - self.get_info() - } -} - -macro_rules! client_handler_methods { - () => { - fn ping( - &self, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Ok(())) - } - - fn create_message( - &self, - params: CreateMessageRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Err( - McpError::method_not_found::(), - )) - } - - fn list_roots( - &self, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Ok(ListRootsResult::default())) - } - - /// Handle an elicitation request from a server asking for user input. - /// - /// This method is called when a server needs interactive input from the user - /// during tool execution. Implementations should present the message to the user, - /// collect their input according to the requested schema, and return the result. - /// - /// # Arguments - /// * `request` - The elicitation request with message and schema - /// * `context` - The request context - /// - /// # Returns - /// The user's response including action (accept/decline/cancel) and optional data - /// - /// # Default Behavior - /// The default implementation automatically declines all elicitation requests. - /// Real clients should override this to provide user interaction. - /// - /// # Example - /// ```rust,no_run - /// use rmcp::{ - /// ClientHandler, - /// model::{ - /// ElicitRequestParams, ElicitResult, ElicitationAction, ElicitationSchema, - /// ErrorData as McpError, - /// }, - /// service::{RequestContext, RoleClient}, - /// }; - /// - /// # struct MyClient; - /// # - /// # async fn get_user_input( - /// # _message: String, - /// # _schema: ElicitationSchema, - /// # ) -> Result { - /// # std::future::pending().await - /// # } - /// # - /// # async fn open_url_in_browser(_url: String) -> Result<(), McpError> { - /// # Ok(()) - /// # } - /// # - /// impl ClientHandler for MyClient { - /// async fn create_elicitation( - /// &self, - /// request: ElicitRequestParams, - /// _context: RequestContext, - /// ) -> Result { - /// match request { - /// ElicitRequestParams::FormElicitationParams { - /// message, - /// requested_schema, - /// .. - /// } => { - /// let input = get_user_input(message, requested_schema).await?; - /// Ok(ElicitResult::new(ElicitationAction::Accept).with_content(input)) - /// } - /// ElicitRequestParams::UrlElicitationParams { url, .. } => { - /// open_url_in_browser(url).await?; - /// Ok(ElicitResult::new(ElicitationAction::Accept)) - /// } - /// _ => Ok(ElicitResult::new(ElicitationAction::Decline)), - /// } - /// } - /// } - /// ``` - fn create_elicitation( - &self, - request: ElicitRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - // Default implementation declines all requests - real clients should override this - let _ = (request, context); - std::future::ready(Ok(ElicitResult { - action: ElicitationAction::Decline, - content: None, - meta: None, - })) - } - - fn on_custom_request( - &self, - request: CustomRequest, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - let CustomRequest { method, .. } = request; - let _ = context; - std::future::ready(Err(McpError::new( - ErrorCode::METHOD_NOT_FOUND, - method, - None, - ))) - } - - fn on_cancelled( - &self, - params: CancelledNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - fn on_progress( - &self, - params: ProgressNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - fn on_logging_message( - &self, - params: LoggingMessageNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - fn on_resource_updated( - &self, - params: ResourceUpdatedNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - fn on_resource_list_changed( - &self, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - fn on_tool_list_changed( - &self, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - fn on_prompt_list_changed( - &self, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - fn on_subscriptions_acknowledged( - &self, - params: SubscriptionsAcknowledgedNotificationParams, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - - fn on_task_status( - &self, - params: TaskStatusNotificationParams, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - fn on_custom_notification( - &self, - notification: CustomNotification, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - let _ = (notification, context); - std::future::ready(()) - } - - fn get_info(&self) -> ClientInfo { - ClientInfo::default() - } - }; -} - -#[allow(unused_variables)] -#[cfg(not(feature = "local"))] -pub trait ClientHandler: Sized + Send + Sync + 'static { - client_handler_methods!(); -} - -#[allow(unused_variables)] -#[cfg(feature = "local")] -pub trait ClientHandler: Sized + 'static { - client_handler_methods!(); -} - -/// Do nothing, with default client info. -impl ClientHandler for () {} - -/// Do nothing, with a specific client info. -impl ClientHandler for ClientInfo { - fn get_info(&self) -> ClientInfo { - self.clone() - } -} - -macro_rules! impl_client_handler_for_wrapper { - ($wrapper:ident) => { - impl ClientHandler for $wrapper { - fn ping( - &self, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).ping(context) - } - - fn create_message( - &self, - params: CreateMessageRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ - { - (**self).create_message(params, context) - } - - fn list_roots( - &self, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ - { - (**self).list_roots(context) - } - - fn create_elicitation( - &self, - request: ElicitRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).create_elicitation(request, context) - } - - fn on_custom_request( - &self, - request: CustomRequest, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).on_custom_request(request, context) - } - - fn on_cancelled( - &self, - params: CancelledNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - (**self).on_cancelled(params, context) - } - - fn on_progress( - &self, - params: ProgressNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - (**self).on_progress(params, context) - } - - fn on_logging_message( - &self, - params: LoggingMessageNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - (**self).on_logging_message(params, context) - } - - fn on_resource_updated( - &self, - params: ResourceUpdatedNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - (**self).on_resource_updated(params, context) - } - - fn on_resource_list_changed( - &self, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - (**self).on_resource_list_changed(context) - } - - fn on_tool_list_changed( - &self, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - (**self).on_tool_list_changed(context) - } - - fn on_prompt_list_changed( - &self, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - (**self).on_prompt_list_changed(context) - } - - fn on_subscriptions_acknowledged( - &self, - params: SubscriptionsAcknowledgedNotificationParams, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - (**self).on_subscriptions_acknowledged(params, context) - } - - fn on_task_status( - &self, - params: TaskStatusNotificationParams, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - (**self).on_task_status(params, context) - } - - fn on_custom_notification( - &self, - notification: CustomNotification, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - (**self).on_custom_notification(notification, context) - } - - fn get_info(&self) -> ClientInfo { - (**self).get_info() - } - } - }; -} - -impl_client_handler_for_wrapper!(Box); -impl_client_handler_for_wrapper!(Arc); diff --git a/vendor/rmcp/src/handler/client/progress.rs b/vendor/rmcp/src/handler/client/progress.rs deleted file mode 100644 index 04f31610f..000000000 --- a/vendor/rmcp/src/handler/client/progress.rs +++ /dev/null @@ -1,100 +0,0 @@ -use std::{collections::HashMap, sync::Arc}; - -use futures::{Stream, StreamExt}; -use tokio::sync::RwLock; -use tokio_stream::wrappers::ReceiverStream; - -use crate::model::{ProgressNotificationParam, ProgressToken}; -type Dispatcher = - Arc>>>; - -/// A dispatcher for progress notifications. -#[derive(Debug, Clone, Default)] -pub struct ProgressDispatcher { - pub(crate) dispatcher: Dispatcher, -} - -impl ProgressDispatcher { - const CHANNEL_SIZE: usize = 16; - pub fn new() -> Self { - Self::default() - } - - /// Handle a progress notification by sending it to the appropriate subscriber - pub async fn handle_notification(&self, notification: ProgressNotificationParam) { - let token = ¬ification.progress_token; - if let Some(sender) = self.dispatcher.read().await.get(token).cloned() { - let send_result = sender.send(notification).await; - if let Err(e) = send_result { - tracing::warn!("Failed to send progress notification: {e}"); - } - } - } - - /// Subscribe to progress notifications for a specific token. - /// - /// If you drop the returned `ProgressSubscriber`, it will automatically unsubscribe from notifications for that token. - pub async fn subscribe(&self, progress_token: ProgressToken) -> ProgressSubscriber { - let (sender, receiver) = tokio::sync::mpsc::channel(Self::CHANNEL_SIZE); - self.dispatcher - .write() - .await - .insert(progress_token.clone(), sender); - let receiver = ReceiverStream::new(receiver); - ProgressSubscriber { - progress_token, - receiver, - dispatcher: self.dispatcher.clone(), - } - } - - /// Unsubscribe from progress notifications for a specific token. - pub async fn unsubscribe(&self, token: &ProgressToken) { - self.dispatcher.write().await.remove(token); - } - - /// Clear all dispatcher. - pub async fn clear(&self) { - let mut dispatcher = self.dispatcher.write().await; - dispatcher.clear(); - } -} - -pub struct ProgressSubscriber { - pub(crate) progress_token: ProgressToken, - pub(crate) receiver: ReceiverStream, - pub(crate) dispatcher: Dispatcher, -} - -impl ProgressSubscriber { - pub fn progress_token(&self) -> &ProgressToken { - &self.progress_token - } -} - -impl Stream for ProgressSubscriber { - type Item = ProgressNotificationParam; - - fn poll_next( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - self.receiver.poll_next_unpin(cx) - } - - fn size_hint(&self) -> (usize, Option) { - self.receiver.size_hint() - } -} - -impl Drop for ProgressSubscriber { - fn drop(&mut self) { - let token = self.progress_token.clone(); - self.receiver.close(); - let dispatcher = self.dispatcher.clone(); - tokio::spawn(async move { - let mut dispatcher = dispatcher.write_owned().await; - dispatcher.remove(&token); - }); - } -} diff --git a/vendor/rmcp/src/handler/server.rs b/vendor/rmcp/src/handler/server.rs deleted file mode 100644 index 6dc7883ed..000000000 --- a/vendor/rmcp/src/handler/server.rs +++ /dev/null @@ -1,817 +0,0 @@ -// Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected. -#![expect(deprecated)] -use std::{borrow::Cow, sync::Arc}; - -use crate::{ - error::ErrorData as McpError, - model::*, - service::{ - MaybeSendFuture, NotificationContext, RequestContext, RoleServer, Service, ServiceRole, - SubscriptionContext, negotiate_protocol_version, uses_legacy_lifecycle, - }, -}; - -pub mod common; -pub mod prompt; -mod resource; -pub mod router; -pub mod tool; -pub mod tool_name_validation; -pub mod wrapper; - -/// SEP-2663: gate `tasks/*` methods on the client's declared tasks-extension -/// capability. -/// -/// - If the server does not advertise the tasks extension, the methods are -/// simply unimplemented: `-32601` Method not found. -/// - If the server advertises it but the client did not declare it (either in -/// the request's `_meta` per-request capabilities or, for session-mode -/// peers, at `initialize` time), the spec requires `-32021` Missing -/// Required Client Capability with the required capability in `data`. -fn validate_tasks_capability( - handler: &H, - context: &RequestContext, -) -> Result<(), McpError> { - if !handler.get_info().capabilities.supports_tasks() { - return Err(McpError::method_not_found::()); - } - let client_declared = context - .client_capabilities() - .is_some_and(|caps| caps.supports_tasks()); - if client_declared { - Ok(()) - } else { - Err(McpError::missing_required_client_capability( - ClientCapabilities::builder().enable_tasks().build(), - )) - } -} - -impl Service for H { - async fn handle_request( - &self, - request: ::PeerReq, - context: RequestContext, - ) -> Result<::Resp, McpError> { - // `context` is moved into the dispatch below, so read the negotiated version first. - let protocol_version = context.protocol_version(); - // SEP-2322 (`resultType` discriminator, MRTR) exists from 2026-07-28. - let sep_2322_supported = protocol_version - .as_ref() - .is_some_and(|v| v.as_str() >= ProtocolVersion::V_2026_07_28.as_str()); - let requested_version = context.meta.protocol_version(); - let uses_inline_negotiation = !matches!(&request, ClientRequest::InitializeRequest(_)); - if uses_inline_negotiation && let Some(requested_version) = requested_version.as_ref() { - let supported_versions = self.supported_protocol_versions(); - if !supported_versions.contains(requested_version) { - return Err(McpError::unsupported_protocol_version( - requested_version.clone(), - &supported_versions, - )); - } - } - // Self-contained metadata is required only when the request itself uses - // the inline lifecycle: a discover opener, a session that started without - // `initialize`, or a request that declares 2026-07-28+ in its own _meta. - // Sessions that negotiated via `initialize` (or `serve_directly`) keep the - // session model and may omit per-request metadata. - let requires_request_metadata = uses_inline_negotiation - && (matches!(&request, ClientRequest::DiscoverRequest(_)) - || context.peer.request_metadata_required() - || requested_version.as_ref().is_some_and(|version| { - version.as_str() >= ProtocolVersion::V_2026_07_28.as_str() - })); - if requires_request_metadata { - // Inline lifecycle requests are defined by the 2026-07-28 protocol. - // Validate that lifecycle contract even when a request selects an - // older application protocol version. - let missing = context - .meta - .missing_required_keys(&ProtocolVersion::V_2026_07_28); - if !missing.is_empty() { - return Err(McpError::invalid_params( - format!( - "request _meta is missing or has malformed required fields: {}", - missing.join(", ") - ), - None, - )); - } - } - let legacy_request = - uses_legacy_lifecycle(protocol_version.as_ref(), requires_request_metadata); - let result = match request { - ClientRequest::InitializeRequest(request) => self - .initialize(request.params, context) - .await - .map(ServerResult::InitializeResult), - ClientRequest::DiscoverRequest(_request) => self - .discover(context) - .await - .map(ServerResult::DiscoverResult), - ClientRequest::PingRequest(_request) => { - if !legacy_request { - Err(McpError::method_not_found::()) - } else { - self.ping(context).await.map(ServerResult::empty) - } - } - ClientRequest::CompleteRequest(request) => self - .complete(request.params, context) - .await - .map(ServerResult::CompleteResult), - ClientRequest::SetLevelRequest(request) => self - .set_level(request.params, context) - .await - .map(ServerResult::empty), - ClientRequest::GetPromptRequest(request) => self - .get_prompt(request.params, context) - .await - .map(ServerResult::from), - ClientRequest::ListPromptsRequest(request) => self - .list_prompts(request.params, context) - .await - .map(ServerResult::ListPromptsResult), - ClientRequest::ListResourcesRequest(request) => self - .list_resources(request.params, context) - .await - .map(ServerResult::ListResourcesResult), - ClientRequest::ListResourceTemplatesRequest(request) => self - .list_resource_templates(request.params, context) - .await - .map(ServerResult::ListResourceTemplatesResult), - ClientRequest::ReadResourceRequest(request) => self - .read_resource(request.params, context) - .await - .map(ServerResult::from), - ClientRequest::SubscriptionsListenRequest(request) => { - if legacy_request { - Err(McpError::method_not_found::()) - } else { - let requested = request.params.notifications; - let Some(candidate) = self.accepted_subscription_filter(&requested) else { - return Err( - McpError::method_not_found::(), - ); - }; - let server_info = self.get_info(); - let advertised = requested.supported_by(&server_info.capabilities); - let handler_accepted = requested.intersection(&candidate); - let accepted = handler_accepted.intersection(&advertised); - if accepted != handler_accepted { - tracing::debug!( - requested_resource_count = requested - .resource_subscriptions - .as_ref() - .map_or(0, Vec::len), - accepted_resource_count = - accepted.resource_subscriptions.as_ref().map_or(0, Vec::len), - "subscription filter reduced to advertised server capabilities" - ); - } - let server_implementation = server_info.server_info; - let subscription_id = context.id.clone(); - let subscription = - SubscriptionContext::establish(context, requested, accepted).await?; - // The 2026-07-28 schema defines a final result for graceful - // server teardown; explicit stdio cancellation remains a notification. - self.listen(subscription).await.map(|()| { - let mut result = SubscriptionsListenResult::complete(subscription_id); - result.meta.set_server_info(server_implementation); - ServerResult::SubscriptionsListenResult(result) - }) - } - } - ClientRequest::SubscribeRequest(request) => { - if !legacy_request { - Err(McpError::method_not_found::()) - } else { - self.subscribe(request.params, context) - .await - .map(ServerResult::empty) - } - } - ClientRequest::UnsubscribeRequest(request) => { - if !legacy_request { - Err(McpError::method_not_found::()) - } else { - self.unsubscribe(request.params, context) - .await - .map(ServerResult::empty) - } - } - ClientRequest::CallToolRequest(request) => { - let client_declared_tasks = context - .client_capabilities() - .is_some_and(|caps| caps.supports_tasks()); - let response = self.call_tool(request.params, context).await?; - // SEP-2663: the server MUST NOT return CreateTaskResult unless - // the request declared the tasks extension capability. Guard - // against handlers that fail to check before materializing a - // task; such clients cannot parse a task handle. - if matches!(response, CallToolResponse::Task(_)) && !client_declared_tasks { - return Err(McpError::missing_required_client_capability( - ClientCapabilities::builder().enable_tasks().build(), - )); - } - Ok(ServerResult::from(response)) - } - ClientRequest::ListToolsRequest(request) => self - .list_tools(request.params, context) - .await - .map(ServerResult::ListToolsResult), - ClientRequest::CustomRequest(request) => self - .on_custom_request(request, context) - .await - .map(ServerResult::CustomResult), - ClientRequest::GetTaskRequest(request) => { - validate_tasks_capability::(self, &context)?; - self.get_task(request.params, context) - .await - .map(ServerResult::GetTaskResult) - } - ClientRequest::UpdateTaskRequest(request) => { - validate_tasks_capability::(self, &context)?; - self.update_task(request.params, context) - .await - .map(ServerResult::task_ack) - } - ClientRequest::CancelTaskRequest(request) => { - validate_tasks_capability::(self, &context)?; - self.cancel_task(request.params, context) - .await - .map(ServerResult::task_ack) - } - }; - let result = result.and_then(|mut result| { - if matches!(result, ServerResult::InputRequiredResult(_)) && !sep_2322_supported { - Err(McpError::invalid_request( - "InputRequiredResult requires negotiated protocol version 2026-07-28 or newer", - None, - )) - } else { - // Peers on protocol versions older than 2026-07-28 keep the - // legacy wire shape without `resultType: "complete"`. - if !sep_2322_supported { - result.strip_result_type_for_legacy_peer(); - } - Ok(result) - } - }); - - // SEP-2164: peers negotiating 2026-07-28+ get the standard INVALID_PARAMS code for - // resource-not-found; older peers keep RESOURCE_NOT_FOUND. ISO `YYYY-MM-DD` versions - // compare lexically the same as chronologically. - let use_invalid_params = - protocol_version.is_some_and(|v| v.as_str() >= ProtocolVersion::V_2026_07_28.as_str()); - result.map_err(|mut error| { - if use_invalid_params && error.code == ErrorCode::RESOURCE_NOT_FOUND { - error.code = ErrorCode::INVALID_PARAMS; - } - error - }) - } - - async fn handle_notification( - &self, - notification: ::PeerNot, - context: NotificationContext, - ) -> Result<(), McpError> { - match notification { - ClientNotification::CancelledNotification(notification) => { - self.on_cancelled(notification.params, context).await - } - ClientNotification::ProgressNotification(notification) => { - self.on_progress(notification.params, context).await - } - ClientNotification::InitializedNotification(_notification) => { - self.on_initialized(context).await - } - ClientNotification::RootsListChangedNotification(_notification) => { - self.on_roots_list_changed(context).await - } - ClientNotification::CustomNotification(notification) => { - self.on_custom_notification(notification, context).await - } - }; - Ok(()) - } - - fn get_info(&self) -> ::Info { - self.get_info() - } - - fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { - ServerHandler::supported_protocol_versions(self) - } -} - -macro_rules! server_handler_methods { - () => { - fn ping( - &self, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Ok(())) - } - // handle requests - fn initialize( - &self, - request: InitializeRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - context.peer.set_peer_info(request.clone()); - let mut info = self.get_info(); - info.protocol_version = negotiate_protocol_version( - &request.protocol_version, - info.protocol_version, - &self.supported_protocol_versions(), - ); - std::future::ready(Ok(info)) - } - /// Return the protocol versions supported by this server. - /// - /// Defaults to every version this SDK knows. Override it to narrow the - /// set to the revisions the server actually implements: the returned - /// list is advertised by [`Self::discover`], bounds what `initialize` - /// negotiation may agree to, and is what per-request versions are - /// validated against. - fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { - Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS) - } - /// Return this server's discovery information. - fn discover( - &self, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Ok(DiscoverResult::from_server_info( - self.supported_protocol_versions().into_owned(), - self.get_info(), - ))) - } - fn complete( - &self, - request: CompleteRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Ok(CompleteResult::default())) - } - fn set_level( - &self, - request: SetLevelRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Err(McpError::method_not_found::())) - } - fn get_prompt( - &self, - request: GetPromptRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Err(McpError::method_not_found::())) - } - fn list_prompts( - &self, - request: Option, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Ok(ListPromptsResult::default())) - } - fn list_resources( - &self, - request: Option, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Ok(ListResourcesResult::default())) - } - fn list_resource_templates( - &self, - request: Option, - context: RequestContext, - ) -> impl Future> - + MaybeSendFuture - + '_ { - std::future::ready(Ok(ListResourceTemplatesResult::default())) - } - fn read_resource( - &self, - request: ReadResourceRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Err( - McpError::method_not_found::(), - )) - } - /// Return the subset of a requested notification filter this server accepts. - /// - /// Returning `None` leaves `subscriptions/listen` unimplemented. The SDK - /// intersects the returned filter with both `requested` and the notification - /// capabilities advertised by [`Self::get_info`] before acknowledging it. - /// Categories that were not requested or advertised are always removed. - fn accepted_subscription_filter( - &self, - requested: &SubscriptionFilter, - ) -> Option { - None - } - /// Run one established subscription until it is cancelled or closed gracefully. - /// - /// The SDK sends the acknowledgment before invoking this method. Returning - /// `Ok(())` sends the final [`SubscriptionsListenResult`] defined by the - /// 2026-07-28 schema, marking graceful server teardown. Explicit - /// stdio cancellation uses `notifications/cancelled` instead. - fn listen( - &self, - context: SubscriptionContext, - ) -> impl Future> + MaybeSendFuture + '_ { - async move { - context.cancelled().await; - Ok(()) - } - } - #[deprecated( - note = "resources/subscribe is legacy-only; implement accepted_subscription_filter and listen for protocol version 2026-07-28" - )] - fn subscribe( - &self, - request: SubscribeRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Err(McpError::method_not_found::())) - } - #[deprecated( - note = "resources/unsubscribe is legacy-only; subscriptions/listen is cancelled through its request lifecycle" - )] - fn unsubscribe( - &self, - request: UnsubscribeRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Err( - McpError::method_not_found::(), - )) - } - /// Handle a `tools/call` request from a client. - /// - /// # Choosing a return value - /// - /// MCP distinguishes two failure modes; the API forces you to pick - /// the right one explicitly because they reach the caller's UI very - /// differently: - /// - /// - `Ok(`[`CallToolResult::error`]`(...))` — the tool ran (or tried - /// to) and produced a failure the caller should see. The - /// `content` you supply is rendered in the caller's MCP client, - /// so the user gets your message. **This is the right return - /// value for almost every "the tool didn't work" path** — empty - /// results, validation failures the user can fix, downstream - /// service unavailability, etc. - /// - /// - `Err(`[`McpError`]`)` — a JSON-RPC protocol error. Use this - /// only when the request itself is unroutable: unknown tool - /// ([`ErrorCode::METHOD_NOT_FOUND`]), malformed request shape that - /// cannot be treated as a valid `tools/call`, or a server-internal - /// failure that means the server cannot serve any request right now - /// ([`ErrorCode::INTERNAL_ERROR`], `-32603`). MCP clients - /// typically render protocol errors opaquely; **the caller will - /// not see your message** — they see something like "Tool result - /// missing due to internal error". If you want the caller to read - /// your error, use `Ok(CallToolResult::error(...))`. - /// - /// See [`CallToolResult::error`] for a worked example. - fn call_tool( - &self, - request: CallToolRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Err(McpError::method_not_found::())) - } - fn list_tools( - &self, - request: Option, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Ok(ListToolsResult::default())) - } - /// Get a tool definition by name. - /// - /// The default implementation returns `None`, which bypasses validation. - /// When using `#[tool_handler]`, this method is automatically implemented. - fn get_tool(&self, _name: &str) -> Option { - None - } - fn on_custom_request( - &self, - request: CustomRequest, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - let CustomRequest { method, .. } = request; - let _ = context; - std::future::ready(Err(McpError::new( - ErrorCode::METHOD_NOT_FOUND, - method, - None, - ))) - } - - fn on_cancelled( - &self, - notification: CancelledNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - fn on_progress( - &self, - notification: ProgressNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - fn on_initialized( - &self, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - tracing::info!("client initialized"); - std::future::ready(()) - } - fn on_roots_list_changed( - &self, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - fn on_custom_notification( - &self, - notification: CustomNotification, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - let _ = (notification, context); - std::future::ready(()) - } - - fn get_info(&self) -> ServerInfo { - ServerInfo::default() - } - - /// SEP-2663 `tasks/get`: return the current [`DetailedTask`] state. - fn get_task( - &self, - request: GetTaskParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - let _ = (request, context); - std::future::ready(Err(McpError::method_not_found::())) - } - - /// SEP-2663 `tasks/update`: accept responses to outstanding in-task - /// input requests. Returns an empty acknowledgement on success. - fn update_task( - &self, - request: UpdateTaskParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - let _ = (request, context); - std::future::ready(Err(McpError::method_not_found::())) - } - - /// SEP-2663 `tasks/cancel`: cooperative cancellation. Returns an empty - /// acknowledgement; the task's observable status may lag. - fn cancel_task( - &self, - request: CancelTaskParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - let _ = (request, context); - std::future::ready(Err(McpError::method_not_found::())) - } - }; -} - -#[allow(unused_variables)] -#[cfg(not(feature = "local"))] -pub trait ServerHandler: Sized + Send + Sync + 'static { - server_handler_methods!(); -} - -#[allow(unused_variables)] -#[cfg(feature = "local")] -pub trait ServerHandler: Sized + 'static { - server_handler_methods!(); -} - -macro_rules! impl_server_handler_for_wrapper { - ($wrapper:ident) => { - impl ServerHandler for $wrapper { - fn ping( - &self, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).ping(context) - } - - fn initialize( - &self, - request: InitializeRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).initialize(request, context) - } - - fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { - (**self).supported_protocol_versions() - } - - fn discover( - &self, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).discover(context) - } - - fn complete( - &self, - request: CompleteRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).complete(request, context) - } - - fn set_level( - &self, - request: SetLevelRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).set_level(request, context) - } - - fn get_prompt( - &self, - request: GetPromptRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).get_prompt(request, context) - } - - fn list_prompts( - &self, - request: Option, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).list_prompts(request, context) - } - - fn list_resources( - &self, - request: Option, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).list_resources(request, context) - } - - fn list_resource_templates( - &self, - request: Option, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ - { - (**self).list_resource_templates(request, context) - } - - fn read_resource( - &self, - request: ReadResourceRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).read_resource(request, context) - } - - fn accepted_subscription_filter( - &self, - requested: &SubscriptionFilter, - ) -> Option { - (**self).accepted_subscription_filter(requested) - } - - fn listen( - &self, - context: SubscriptionContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).listen(context) - } - - fn subscribe( - &self, - request: SubscribeRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).subscribe(request, context) - } - - fn unsubscribe( - &self, - request: UnsubscribeRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).unsubscribe(request, context) - } - - fn call_tool( - &self, - request: CallToolRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).call_tool(request, context) - } - - fn list_tools( - &self, - request: Option, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).list_tools(request, context) - } - - fn get_tool(&self, name: &str) -> Option { - (**self).get_tool(name) - } - - fn on_custom_request( - &self, - request: CustomRequest, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).on_custom_request(request, context) - } - - fn on_cancelled( - &self, - notification: CancelledNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - (**self).on_cancelled(notification, context) - } - - fn on_progress( - &self, - notification: ProgressNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - (**self).on_progress(notification, context) - } - - fn on_initialized( - &self, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - (**self).on_initialized(context) - } - - fn on_roots_list_changed( - &self, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - (**self).on_roots_list_changed(context) - } - - fn on_custom_notification( - &self, - notification: CustomNotification, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - (**self).on_custom_notification(notification, context) - } - - fn get_info(&self) -> ServerInfo { - (**self).get_info() - } - - fn get_task( - &self, - request: GetTaskParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).get_task(request, context) - } - - fn update_task( - &self, - request: UpdateTaskParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).update_task(request, context) - } - - fn cancel_task( - &self, - request: CancelTaskParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).cancel_task(request, context) - } - } - }; -} - -impl_server_handler_for_wrapper!(Box); -impl_server_handler_for_wrapper!(Arc); diff --git a/vendor/rmcp/src/handler/server/common.rs b/vendor/rmcp/src/handler/server/common.rs deleted file mode 100644 index 8d3e4a8a1..000000000 --- a/vendor/rmcp/src/handler/server/common.rs +++ /dev/null @@ -1,404 +0,0 @@ -//! Common utilities shared between tool and prompt handlers - -use std::{ - any::TypeId, - collections::HashMap, - sync::{Arc, LazyLock}, -}; - -use schemars::JsonSchema; - -use crate::{ - RoleServer, model::JsonObject, schemars::generate::SchemaSettings, service::RequestContext, -}; - -/// Generates a JSON schema for a type -pub fn schema_for_type() -> Arc { - thread_local! { - static CACHE_FOR_TYPE: std::sync::RwLock>> = Default::default(); - }; - CACHE_FOR_TYPE.with(|cache| { - if let Some(x) = cache - .read() - .expect("schema cache lock poisoned") - .get(&TypeId::of::()) - { - x.clone() - } else { - // explicitly to align json schema version to official specifications. - // refer to https://github.com/modelcontextprotocol/modelcontextprotocol/pull/655 for details. - let settings = SchemaSettings::draft2020_12(); - // Note: AddNullable is intentionally NOT used here because the `nullable` keyword - // is an OpenAPI 3.0 extension, not part of JSON Schema 2020-12. Using it would - // cause validation failures with strict JSON Schema validators. - let generator = settings.into_generator(); - let schema = generator.into_root_schema_for::(); - let object = serde_json::to_value(schema).expect("failed to serialize schema"); - let serde_json::Value::Object(object) = object else { - panic!( - "Schema serialization produced non-object value: expected JSON object but got {object:?}" - ); - }; - let schema = Arc::new(object); - cache - .write() - .expect("schema cache lock poisoned") - .insert(TypeId::of::(), schema.clone()); - - schema - } - }) -} - -/// Validate that the schema root is `type: "object"` (per MCP spec) and strip top-level -/// `title`/`description` (the wrapper type name and doc, which are noise to the LLM). -fn validate_and_strip(raw: &Arc, purpose: &str) -> Result, String> { - match raw.get("type") { - Some(serde_json::Value::String(t)) if t == "object" => { - let mut object = raw.as_ref().clone(); - object.remove("title"); - object.remove("description"); - Ok(Arc::new(object)) - } - Some(serde_json::Value::String(t)) => Err(format!( - "MCP specification requires tool {purpose} to have root type 'object', but found '{t}'." - )), - None => Err(format!( - "Schema is missing 'type' field. MCP specification requires {purpose} to have root type 'object'." - )), - Some(other) => Err(format!( - "Schema 'type' field has unexpected format: {other:?}. Expected \"object\"." - )), - } -} - -/// Generate, validate, and strip a JSON schema for inputSchema (must have root type "object"; -/// top-level "title" and "description" are removed). -pub fn schema_for_input() -> Result, String> { - thread_local! { - static CACHE_FOR_INPUT: std::sync::RwLock, String>>> = Default::default(); - }; - CACHE_FOR_INPUT.with(|cache| { - if let Some(result) = cache - .read() - .expect("input schema cache lock poisoned") - .get(&TypeId::of::()) - { - return result.clone(); - } - let result = validate_and_strip(&schema_for_type::(), "inputSchema"); - cache - .write() - .expect("input schema cache lock poisoned") - .insert(TypeId::of::(), result.clone()); - result - }) -} - -/// Schema used when input is empty. -pub fn schema_for_empty_input() -> Arc { - static EMPTY: LazyLock> = LazyLock::new(|| { - let mut object = JsonObject::new(); - object.insert("type".into(), serde_json::json!("object")); - object.insert("properties".into(), serde_json::json!({})); - Arc::new(object) - }); - EMPTY.clone() -} - -/// Strip top-level `title` and `description` from a JSON schema for outputSchema. -/// Unlike `validate_and_strip`, this performs no validation — output schemas are not -/// restricted to `type: "object"` (per SEP-2106). -fn strip_output(raw: &Arc) -> Arc { - let mut object = raw.as_ref().clone(); - object.remove("title"); - object.remove("description"); - Arc::new(object) -} - -/// Generate and strip a JSON schema for outputSchema (top-level "title" and -/// "description" are removed; output schemas are not restricted to root type "object"). -pub fn schema_for_output() -> Arc { - thread_local! { - static CACHE_FOR_OUTPUT: std::sync::RwLock>> = Default::default(); - }; - - CACHE_FOR_OUTPUT.with(|cache| { - if let Some(schema) = cache - .read() - .expect("output schema cache lock poisoned") - .get(&TypeId::of::()) - { - return schema.clone(); - } - - let schema = strip_output(&schema_for_type::()); - - cache - .write() - .expect("output schema cache lock poisoned") - .insert(TypeId::of::(), schema.clone()); - - schema - }) -} - -/// Trait for extracting parts from a context, unifying tool and prompt extraction -pub trait FromContextPart: Sized { - fn from_context_part(context: &mut C) -> Result; -} - -/// Common extractors that can be used by both tool and prompt handlers -impl FromContextPart for RequestContext -where - C: AsRequestContext, -{ - fn from_context_part(context: &mut C) -> Result { - Ok(context.as_request_context().clone()) - } -} - -impl FromContextPart for tokio_util::sync::CancellationToken -where - C: AsRequestContext, -{ - fn from_context_part(context: &mut C) -> Result { - Ok(context.as_request_context().ct.clone()) - } -} - -impl FromContextPart for crate::model::Extensions -where - C: AsRequestContext, -{ - fn from_context_part(context: &mut C) -> Result { - Ok(context.as_request_context().extensions.clone()) - } -} - -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct Extension(pub T); - -impl FromContextPart for Extension -where - C: AsRequestContext, - T: Send + Sync + 'static + Clone, -{ - fn from_context_part(context: &mut C) -> Result { - let extension = context - .as_request_context() - .extensions - .get::() - .cloned() - .ok_or_else(|| { - crate::ErrorData::invalid_params( - format!("missing extension {}", std::any::type_name::()), - None, - ) - })?; - Ok(Extension(extension)) - } -} - -impl FromContextPart for crate::Peer -where - C: AsRequestContext, -{ - fn from_context_part(context: &mut C) -> Result { - Ok(context.as_request_context().peer.clone()) - } -} - -impl FromContextPart for crate::model::RequestMetaObject -where - C: AsRequestContext, -{ - fn from_context_part(context: &mut C) -> Result { - let request_context = context.as_request_context_mut(); - let mut meta = crate::model::RequestMetaObject::default(); - std::mem::swap(&mut meta, &mut request_context.meta); - Ok(meta) - } -} - -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct RequestId(pub crate::model::RequestId); - -impl FromContextPart for RequestId -where - C: AsRequestContext, -{ - fn from_context_part(context: &mut C) -> Result { - Ok(RequestId(context.as_request_context().id.clone())) - } -} - -/// Trait for types that can provide access to RequestContext -pub trait AsRequestContext { - fn as_request_context(&self) -> &RequestContext; - fn as_request_context_mut(&mut self) -> &mut RequestContext; -} - -#[cfg(test)] -mod tests { - use rstest::rstest; - - use super::*; - - #[derive(serde::Serialize, serde::Deserialize, JsonSchema)] - struct TestObject { - value: i32, - } - - #[derive(serde::Serialize, serde::Deserialize, JsonSchema)] - struct AnotherTestObject { - value: i32, - } - - #[rstest] - #[case::primitive(schema_for_type::, "integer")] - #[case::array(schema_for_type::>, "array")] - #[case::struct_object(schema_for_type::, "object")] - fn schema_for_type_sets_expected_root_type( - #[case] schema_fn: fn() -> Arc, - #[case] expected_type: &str, - ) { - let schema = schema_fn(); - - assert_eq!(schema.get("type"), Some(&serde_json::json!(expected_type))); - } - - #[test] - fn schema_for_type_sets_array_item_type() { - let schema = schema_for_type::>(); - let items = schema.get("items").and_then(|v| v.as_object()).unwrap(); - - assert_eq!(items.get("type"), Some(&serde_json::json!("integer"))); - } - - #[test] - fn schema_for_type_sets_struct_properties() { - let schema = schema_for_type::(); - let properties = schema - .get("properties") - .and_then(|v| v.as_object()) - .unwrap(); - - assert!(properties.contains_key("value")); - } - - #[rstest] - #[case::primitive(schema_for_type::)] - #[case::struct_object(schema_for_type::)] - fn test_schema_for_type_caches_schemas(#[case] schema_fn: fn() -> Arc) { - let schema1 = schema_fn(); - let schema2 = schema_fn(); - - assert!(Arc::ptr_eq(&schema1, &schema2)); - } - - #[test] - fn test_schema_for_type_different_types_different_schemas() { - let schema1 = schema_for_type::(); - let schema2 = schema_for_type::(); - - assert!(!Arc::ptr_eq(&schema1, &schema2)); - } - - #[test] - fn test_schema_for_type_arc_can_be_shared() { - let schema = schema_for_type::(); - let cloned = schema.clone(); - - assert!(Arc::ptr_eq(&schema, &cloned)); - } - - #[test] - fn test_schema_for_output_accepts_primitive() { - let schema = schema_for_output::(); - assert_eq!(schema.get("type"), Some(&serde_json::json!("integer"))); - } - - #[test] - fn test_schema_for_output_strips_description_for_primitive() { - let schema = schema_for_output::(); - assert!(!schema.contains_key("description")); - } - - #[test] - fn test_schema_for_output_accepts_composition() { - let schema = schema_for_output::>(); - let schema_str = serde_json::to_string(&schema).unwrap(); - assert!( - schema_str.contains("anyOf") - || schema_str.contains("oneOf") - || schema_str.contains("null"), - "Expected composition schema for Option, got: {schema_str}" - ); - } - - #[test] - fn test_schema_for_output_caches_result() { - let schema1 = schema_for_output::(); - let schema2 = schema_for_output::(); - assert!(Arc::ptr_eq(&schema1, &schema2)); - } - - #[test] - fn test_schema_for_input_rejects_array() { - let result = schema_for_input::>(); - assert!(result.is_err()); - } - - #[test] - fn test_schema_for_output_accepts_unit() { - let _schema = schema_for_output::<()>(); - } - - #[test] - fn test_schema_for_output_accepts_object() { - let schema = schema_for_output::(); - assert_eq!(schema.get("type"), Some(&serde_json::json!("object"))); - } - - #[test] - fn test_schema_for_output_strips_top_level_title() { - let schema = schema_for_output::(); - assert!(!schema.contains_key("title")); - } - - #[test] - fn test_schema_for_output_strips_top_level_description() { - let schema = schema_for_output::(); - assert!(!schema.contains_key("description")); - } - - #[rstest] - #[case::input(schema_for_input::)] - fn test_schema_for_input_rejects_primitives( - #[case] schema_fn: fn() -> Result, String>, - ) { - let result = schema_fn(); - assert!(result.is_err()); - } - - #[rstest] - #[case::input(schema_for_input::)] - fn test_schema_for_input_accepts_objects( - #[case] schema_fn: fn() -> Result, String>, - ) { - let result = schema_fn(); - assert!(result.is_ok()); - } - - #[rstest] - #[case::input_title(schema_for_input::, "title")] - #[case::input_description(schema_for_input::, "description")] - fn test_schema_for_input_strips_top_level_metadata( - #[case] schema_fn: fn() -> Result, String>, - #[case] field: &str, - ) { - let schema = schema_fn().unwrap(); - assert!(!schema.contains_key(field)); - } -} diff --git a/vendor/rmcp/src/handler/server/prompt.rs b/vendor/rmcp/src/handler/server/prompt.rs deleted file mode 100644 index b5b3c469b..000000000 --- a/vendor/rmcp/src/handler/server/prompt.rs +++ /dev/null @@ -1,383 +0,0 @@ -//! Prompt handling infrastructure for MCP servers -//! -//! This module provides the core types and traits for implementing prompt handlers -//! in MCP servers. Prompts allow servers to provide reusable templates for LLM -//! interactions with customizable arguments. - -use std::{future::Future, marker::PhantomData}; - -#[cfg(not(feature = "local"))] -use futures::future::BoxFuture; -use serde::de::DeserializeOwned; - -use super::common::{AsRequestContext, FromContextPart}; -pub use super::common::{Extension, RequestId}; -use crate::{ - RoleServer, - handler::server::wrapper::Parameters, - model::{GetPromptResponse, GetPromptResult, InputRequiredResult, PromptMessage}, - service::{MaybeBoxFuture, MaybeSend, MaybeSendFuture, RequestContext}, -}; - -/// Context for prompt retrieval operations -#[non_exhaustive] -pub struct PromptContext<'a, S> { - pub server: &'a S, - pub name: String, - pub arguments: Option>, - pub context: RequestContext, -} - -impl<'a, S> PromptContext<'a, S> { - pub fn new( - server: &'a S, - name: String, - arguments: Option>, - context: RequestContext, - ) -> Self { - Self { - server, - name, - arguments, - context, - } - } -} - -impl AsRequestContext for PromptContext<'_, S> { - fn as_request_context(&self) -> &RequestContext { - &self.context - } - - fn as_request_context_mut(&mut self) -> &mut RequestContext { - &mut self.context - } -} - -/// Trait for handling prompt retrieval -pub trait GetPromptHandler { - fn handle( - self, - context: PromptContext<'_, S>, - ) -> MaybeBoxFuture<'_, Result>; -} - -/// Type alias for dynamic prompt handlers -#[cfg(not(feature = "local"))] -pub type DynGetPromptHandler = dyn for<'a> Fn(PromptContext<'a, S>) -> BoxFuture<'a, Result> - + Send - + Sync; - -#[cfg(feature = "local")] -pub type DynGetPromptHandler = dyn for<'a> Fn( - PromptContext<'a, S>, -) -> futures::future::LocalBoxFuture< - 'a, - Result, ->; - -/// Adapter type for async methods that return `Vec` -pub struct AsyncMethodAdapter(PhantomData); - -/// Adapter type for async methods with parameters that return `Vec` -pub struct AsyncMethodWithArgsAdapter(PhantomData); - -/// Adapter types for macro-generated implementations -#[allow(clippy::type_complexity)] -pub struct AsyncPromptAdapter(PhantomData fn(Fut) -> R>); -pub struct SyncPromptAdapter(PhantomData R>); -pub struct AsyncPromptMethodAdapter(PhantomData R>); -pub struct SyncPromptMethodAdapter(PhantomData R>); - -/// Trait for types that can be converted into GetPromptResult -pub trait IntoGetPromptResult { - fn into_get_prompt_result(self) -> Result; -} - -impl IntoGetPromptResult for GetPromptResult { - fn into_get_prompt_result(self) -> Result { - Ok(self.into()) - } -} - -impl IntoGetPromptResult for InputRequiredResult { - fn into_get_prompt_result(self) -> Result { - Ok(self.into()) - } -} - -impl IntoGetPromptResult for Vec { - fn into_get_prompt_result(self) -> Result { - Ok(GetPromptResult::new(self).into()) - } -} - -impl IntoGetPromptResult for Result { - fn into_get_prompt_result(self) -> Result { - self.and_then(|v| v.into_get_prompt_result()) - } -} - -// Future wrapper that automatically handles IntoGetPromptResult conversion -pin_project_lite::pin_project! { - #[project = IntoGetPromptResultFutProj] - #[non_exhaustive] - pub enum IntoGetPromptResultFut { - Pending { - #[pin] - fut: F, - _marker: PhantomData, - }, - Ready { - #[pin] - result: futures::future::Ready>, - } - } -} - -impl Future for IntoGetPromptResultFut -where - F: Future, - R: IntoGetPromptResult, -{ - type Output = Result; - - fn poll( - self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll { - match self.project() { - IntoGetPromptResultFutProj::Pending { fut, _marker } => fut - .poll(cx) - .map(IntoGetPromptResult::into_get_prompt_result), - IntoGetPromptResultFutProj::Ready { result } => result.poll(cx), - } - } -} - -// Prompt-specific extractor for prompt name -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct PromptName(pub String); - -impl FromContextPart> for PromptName { - fn from_context_part(context: &mut PromptContext) -> Result { - Ok(Self(context.name.clone())) - } -} - -// Special implementation for Parameters that handles prompt arguments -impl FromContextPart> for Parameters

-where - P: DeserializeOwned, -{ - fn from_context_part(context: &mut PromptContext) -> Result { - let params = if let Some(args_map) = context.arguments.take() { - let args_value = serde_json::Value::Object(args_map); - serde_json::from_value::

(args_value).map_err(|e| { - crate::ErrorData::invalid_params(format!("Failed to parse parameters: {}", e), None) - })? - } else { - // Try to deserialize from empty object for optional fields - serde_json::from_value::

(serde_json::json!({})).map_err(|e| { - crate::ErrorData::invalid_params( - format!("Missing required parameters: {}", e), - None, - ) - })? - }; - Ok(Parameters(params)) - } -} - -// Macro to generate GetPromptHandler implementations for various parameter combinations -macro_rules! impl_prompt_handler_for { - ($($T: ident)*) => { - impl_prompt_handler_for!([] [$($T)*]); - }; - // finished - ([$($Tn: ident)*] []) => { - impl_prompt_handler_for!(@impl $($Tn)*); - }; - ([$($Tn: ident)*] [$Tn_1: ident $($Rest: ident)*]) => { - impl_prompt_handler_for!(@impl $($Tn)*); - impl_prompt_handler_for!([$($Tn)* $Tn_1] [$($Rest)*]); - }; - (@impl $($Tn: ident)*) => { - // Implementation for async methods (transformed by #[prompt] macro) - impl<$($Tn,)* S, F, R> GetPromptHandler for F - where - $( - $Tn: for<'a> FromContextPart> + MaybeSendFuture, - )* - F: FnOnce(&S, $($Tn,)*) -> MaybeBoxFuture<'_, R> + MaybeSendFuture, - R: IntoGetPromptResult + MaybeSendFuture + 'static, - S: MaybeSend + 'static, - { - #[allow(unused_variables, non_snake_case, unused_mut)] - fn handle( - self, - mut context: PromptContext<'_, S>, - ) -> MaybeBoxFuture<'_, Result> - { - $( - let result = $Tn::from_context_part(&mut context); - let $Tn = match result { - Ok(value) => value, - Err(e) => return Box::pin(std::future::ready(Err(e))), - }; - )* - let service = context.server; - let fut = self(service, $($Tn,)*); - Box::pin(async move { - let result = fut.await; - result.into_get_prompt_result() - }) - } - } - - - // Implementation for sync methods - impl<$($Tn,)* S, F, R> GetPromptHandler> for F - where - $( - $Tn: for<'a> FromContextPart> + MaybeSendFuture, - )* - F: FnOnce(&S, $($Tn,)*) -> R + MaybeSendFuture, - R: IntoGetPromptResult + MaybeSendFuture, - S: MaybeSend, - { - #[allow(unused_variables, non_snake_case, unused_mut)] - fn handle( - self, - mut context: PromptContext<'_, S>, - ) -> MaybeBoxFuture<'_, Result> - { - $( - let result = $Tn::from_context_part(&mut context); - let $Tn = match result { - Ok(value) => value, - Err(e) => return Box::pin(std::future::ready(Err(e))), - }; - )* - let service = context.server; - let result = self(service, $($Tn,)*); - Box::pin(std::future::ready(result.into_get_prompt_result())) - } - } - - - // AsyncPromptAdapter - for standalone functions returning GetPromptResult - impl<$($Tn,)* S, F, Fut, R> GetPromptHandler> for F - where - $( - $Tn: for<'a> FromContextPart> + MaybeSendFuture + 'static, - )* - F: FnOnce($($Tn,)*) -> Fut + MaybeSendFuture + 'static, - Fut: Future> + MaybeSendFuture + 'static, - R: IntoGetPromptResult + MaybeSendFuture + 'static, - S: MaybeSend + 'static, - { - #[allow(unused_variables, non_snake_case, unused_mut)] - fn handle( - self, - mut context: PromptContext<'_, S>, - ) -> MaybeBoxFuture<'_, Result> - { - // Extract all parameters before moving into the async block - $( - let result = $Tn::from_context_part(&mut context); - let $Tn = match result { - Ok(value) => value, - Err(e) => return Box::pin(std::future::ready(Err(e))), - }; - )* - - // Since we're dealing with standalone functions that don't take &S, - // we can return a 'static future - Box::pin(async move { - let result = self($($Tn,)*).await?; - result.into_get_prompt_result() - }) - } - } - - - // SyncPromptAdapter - for standalone sync functions returning Result - impl<$($Tn,)* S, F, R> GetPromptHandler> for F - where - $( - $Tn: for<'a> FromContextPart> + MaybeSendFuture + 'static, - )* - F: FnOnce($($Tn,)*) -> Result + MaybeSendFuture + 'static, - R: IntoGetPromptResult + MaybeSendFuture + 'static, - S: MaybeSend, - { - #[allow(unused_variables, non_snake_case, unused_mut)] - fn handle( - self, - mut context: PromptContext<'_, S>, - ) -> MaybeBoxFuture<'_, Result> - { - $( - let result = $Tn::from_context_part(&mut context); - let $Tn = match result { - Ok(value) => value, - Err(e) => return Box::pin(std::future::ready(Err(e))), - }; - )* - let result = self($($Tn,)*); - Box::pin(std::future::ready(result.and_then(|r| r.into_get_prompt_result()))) - } - } - - }; -} - -// Invoke the macro to generate implementations for up to 16 parameters -impl_prompt_handler_for!(T0 T1 T2 T3 T4 T5 T6 T7 T8 T9 T10 T11 T12 T13 T14 T15); - -/// Extract prompt arguments from a type's JSON schema -/// This function analyzes the schema of a type and extracts the properties -/// as PromptArgument entries with name, description, and required status -pub fn cached_arguments_from_schema() --> Option> { - let schema = super::common::schema_for_type::(); - let schema_value = serde_json::Value::Object((*schema).clone()); - - let properties = schema_value.get("properties").and_then(|p| p.as_object()); - - if let Some(props) = properties { - let required = schema_value - .get("required") - .and_then(|r| r.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str()) - .collect::>() - }) - .unwrap_or_default(); - - let mut arguments = Vec::new(); - for (name, prop_schema) in props { - let description = prop_schema - .get("description") - .and_then(|d| d.as_str()) - .map(|s| s.to_string()); - - arguments.push(crate::model::PromptArgument { - name: name.clone(), - title: None, - description, - required: Some(required.contains(name.as_str())), - }); - } - - if arguments.is_empty() { - None - } else { - Some(arguments) - } - } else { - None - } -} diff --git a/vendor/rmcp/src/handler/server/resource.rs b/vendor/rmcp/src/handler/server/resource.rs deleted file mode 100644 index 8b1378917..000000000 --- a/vendor/rmcp/src/handler/server/resource.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/rmcp/src/handler/server/router.rs b/vendor/rmcp/src/handler/server/router.rs deleted file mode 100644 index c49c40894..000000000 --- a/vendor/rmcp/src/handler/server/router.rs +++ /dev/null @@ -1,235 +0,0 @@ -use std::{borrow::Cow, sync::Arc}; - -use prompt::{IntoPromptRoute, PromptRoute}; -use tool::{IntoToolRoute, ToolRoute}; - -use super::ServerHandler; -use crate::{ - RoleServer, Service, - model::{ - ClientNotification, ClientRequest, ListPromptsResult, ListToolsResult, ProtocolVersion, - ServerResult, - }, - service::NotificationContext, -}; - -pub mod prompt; -pub mod tool; - -#[non_exhaustive] -pub struct Router { - pub tool_router: tool::ToolRouter, - pub prompt_router: prompt::PromptRouter, - pub service: Arc, - peer_slot: Arc>>, -} - -impl Router -where - S: ServerHandler, -{ - pub fn new(service: S) -> Self { - let (notifier, peer_slot) = tool::ToolRouter::::deferred_peer_notifier(); - let mut tool_router = tool::ToolRouter::new(); - tool_router.set_notifier(notifier); - Self { - tool_router, - prompt_router: prompt::PromptRouter::new(), - service: Arc::new(service), - peer_slot, - } - } - - pub fn with_tool(mut self, route: R) -> Self - where - R: IntoToolRoute, - { - self.tool_router.add_route(route.into_tool_route()); - self - } - - pub fn with_tools(mut self, routes: impl IntoIterator>) -> Self { - for route in routes { - self.tool_router.add_route(route); - } - self - } - - pub fn with_prompt(mut self, route: R) -> Self - where - R: IntoPromptRoute, - { - self.prompt_router.add_route(route.into_prompt_route()); - self - } - - pub fn with_prompts(mut self, routes: impl IntoIterator>) -> Self { - for route in routes { - self.prompt_router.add_route(route); - } - self - } -} - -impl Service for Router -where - S: ServerHandler, -{ - async fn handle_notification( - &self, - notification: ::PeerNot, - context: NotificationContext, - ) -> Result<(), crate::ErrorData> { - if matches!( - ¬ification, - ClientNotification::InitializedNotification(_) - ) { - let _ = self.peer_slot.set(context.peer.clone()); - } - self.service - .handle_notification(notification, context) - .await - } - async fn handle_request( - &self, - request: ::PeerReq, - context: crate::service::RequestContext, - ) -> Result<::Resp, crate::ErrorData> { - match request { - ClientRequest::CallToolRequest(request) => { - if self - .tool_router - .map - .contains_key(request.params.name.as_ref()) - || !self.tool_router.transparent_when_not_found - { - let tool_call_context = crate::handler::server::tool::ToolCallContext::new( - self.service.as_ref(), - request.params, - context, - ); - let result = self.tool_router.call(tool_call_context).await?; - Ok(ServerResult::from(result)) - } else { - self.service - .handle_request(ClientRequest::CallToolRequest(request), context) - .await - } - } - ClientRequest::ListToolsRequest(_) => { - let tools = self.tool_router.list_all(); - Ok(ServerResult::ListToolsResult(ListToolsResult { - tools, - ..Default::default() - })) - } - ClientRequest::GetPromptRequest(request) => { - if self.prompt_router.has_route(request.params.name.as_ref()) { - let prompt_context = crate::handler::server::prompt::PromptContext::new( - self.service.as_ref(), - request.params.name, - request.params.arguments, - context, - ); - let result = self.prompt_router.get_prompt(prompt_context).await?; - Ok(ServerResult::from(result)) - } else { - self.service - .handle_request(ClientRequest::GetPromptRequest(request), context) - .await - } - } - ClientRequest::ListPromptsRequest(_) => { - let prompts = self.prompt_router.list_all(); - Ok(ServerResult::ListPromptsResult(ListPromptsResult { - prompts, - ..Default::default() - })) - } - rest => self.service.handle_request(rest, context).await, - } - } - - fn get_info(&self) -> ::Info { - let mut info = ServerHandler::get_info(&self.service); - info.capabilities - .tools - .get_or_insert_with(Default::default) - .list_changed = Some(true); - info - } - - fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { - ServerHandler::supported_protocol_versions(&self.service) - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use super::*; - use crate::{ - model::{CallToolResult, ClientNotification, ServerNotification, Tool}, - service::{AtomicU32RequestIdProvider, Peer, PeerSinkMessage, RequestIdProvider}, - }; - - struct DummyHandler; - impl ServerHandler for DummyHandler {} - - async fn recv_notification( - rx: &mut tokio::sync::mpsc::Receiver>, - ) -> ServerNotification { - let msg = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) - .await - .expect("timed out") - .expect("channel closed"); - match msg { - PeerSinkMessage::Notification { - notification, - responder, - } => { - let _ = responder.send(Ok(())); - notification - } - other => panic!("expected notification, got {other:?}"), - } - } - - #[tokio::test] - async fn test_router_deferred_notifier_e2e() { - let mut router = Router::new(DummyHandler).with_tool(tool::ToolRoute::new_dyn( - Tool::new("my_tool", "test", Arc::new(Default::default())), - |_ctx| Box::pin(async { Ok(CallToolResult::default().into()) }), - )); - - let id_provider: Arc = - Arc::new(AtomicU32RequestIdProvider::default()); - let (peer, mut rx) = Peer::::new(id_provider, None); - - let context = crate::service::NotificationContext { - peer: peer.clone(), - meta: Default::default(), - extensions: Default::default(), - }; - router - .handle_notification( - ClientNotification::InitializedNotification(Default::default()), - context, - ) - .await - .unwrap(); - - router.tool_router.disable_route("my_tool"); - assert!(matches!( - recv_notification(&mut rx).await, - ServerNotification::ToolListChangedNotification(_) - )); - - router.tool_router.enable_route("my_tool"); - assert!(matches!( - recv_notification(&mut rx).await, - ServerNotification::ToolListChangedNotification(_) - )); - } -} diff --git a/vendor/rmcp/src/handler/server/router/prompt.rs b/vendor/rmcp/src/handler/server/router/prompt.rs deleted file mode 100644 index 509fb4287..000000000 --- a/vendor/rmcp/src/handler/server/router/prompt.rs +++ /dev/null @@ -1,217 +0,0 @@ -use std::{borrow::Cow, sync::Arc}; - -use crate::{ - handler::server::prompt::{DynGetPromptHandler, GetPromptHandler, PromptContext}, - model::{GetPromptResponse, Prompt}, - service::{MaybeBoxFuture, MaybeSend}, -}; - -#[non_exhaustive] -pub struct PromptRoute { - #[allow(clippy::type_complexity)] - pub get: Arc>, - pub attr: crate::model::Prompt, -} - -impl std::fmt::Debug for PromptRoute { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("PromptRoute") - .field("name", &self.attr.name) - .field("description", &self.attr.description) - .field("arguments", &self.attr.arguments) - .finish() - } -} - -impl Clone for PromptRoute { - fn clone(&self) -> Self { - Self { - get: self.get.clone(), - attr: self.attr.clone(), - } - } -} - -impl PromptRoute { - pub fn new(attr: impl Into, handler: H) -> Self - where - H: GetPromptHandler + MaybeSend + Clone + 'static, - { - Self { - get: Arc::new(move |context: PromptContext| { - let handler = handler.clone(); - handler.handle(context) - }), - attr: attr.into(), - } - } - - pub fn new_dyn(attr: impl Into, handler: H) -> Self - where - H: for<'a> Fn( - PromptContext<'a, S>, - ) - -> MaybeBoxFuture<'a, Result> - + MaybeSend - + 'static, - { - Self { - get: Arc::new(handler), - attr: attr.into(), - } - } - - pub fn name(&self) -> &str { - &self.attr.name - } -} - -pub trait IntoPromptRoute { - fn into_prompt_route(self) -> PromptRoute; -} - -impl IntoPromptRoute for (P, H) -where - S: MaybeSend + 'static, - A: 'static, - H: GetPromptHandler + MaybeSend + Clone + 'static, - P: Into, -{ - fn into_prompt_route(self) -> PromptRoute { - PromptRoute::new(self.0.into(), self.1) - } -} - -impl IntoPromptRoute for PromptRoute -where - S: MaybeSend + 'static, -{ - fn into_prompt_route(self) -> PromptRoute { - self - } -} - -/// Adapter for functions generated by the #\[prompt\] macro -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct PromptAttrGenerateFunctionAdapter; - -impl IntoPromptRoute for F -where - S: MaybeSend + 'static, - F: Fn() -> PromptRoute, -{ - fn into_prompt_route(self) -> PromptRoute { - (self)() - } -} - -#[derive(Debug)] -#[non_exhaustive] -pub struct PromptRouter { - #[allow(clippy::type_complexity)] - pub map: std::collections::HashMap, PromptRoute>, -} - -impl Default for PromptRouter { - fn default() -> Self { - Self { - map: std::collections::HashMap::new(), - } - } -} - -impl Clone for PromptRouter { - fn clone(&self) -> Self { - Self { - map: self.map.clone(), - } - } -} - -impl IntoIterator for PromptRouter { - type Item = PromptRoute; - type IntoIter = std::collections::hash_map::IntoValues, PromptRoute>; - - fn into_iter(self) -> Self::IntoIter { - self.map.into_values() - } -} - -impl PromptRouter -where - S: MaybeSend + 'static, -{ - pub fn new() -> Self { - Self { - map: std::collections::HashMap::new(), - } - } - - pub fn with_route(mut self, route: R) -> Self - where - R: IntoPromptRoute, - { - self.add_route(route.into_prompt_route()); - self - } - - pub fn add_route(&mut self, item: PromptRoute) { - self.map.insert(item.attr.name.clone().into(), item); - } - - pub fn merge(&mut self, other: PromptRouter) { - for item in other.map.into_values() { - self.add_route(item); - } - } - - pub fn remove_route(&mut self, name: &str) { - self.map.remove(name); - } - - pub fn has_route(&self, name: &str) -> bool { - self.map.contains_key(name) - } - - pub async fn get_prompt( - &self, - context: PromptContext<'_, S>, - ) -> Result { - let item = self.map.get(context.name.as_str()).ok_or_else(|| { - crate::ErrorData::invalid_params( - format!("prompt '{}' not found", context.name), - Some(serde_json::json!({ - "available_prompts": self.list_all().iter().map(|p| &p.name).collect::>() - })), - ) - })?; - (item.get)(context).await - } - - pub fn list_all(&self) -> Vec { - let mut prompts: Vec<_> = self.map.values().map(|item| item.attr.clone()).collect(); - prompts.sort_by(|a, b| a.name.cmp(&b.name)); - prompts - } -} - -impl std::ops::Add> for PromptRouter -where - S: MaybeSend + 'static, -{ - type Output = Self; - - fn add(mut self, other: PromptRouter) -> Self::Output { - self.merge(other); - self - } -} - -impl std::ops::AddAssign> for PromptRouter -where - S: MaybeSend + 'static, -{ - fn add_assign(&mut self, other: PromptRouter) { - self.merge(other); - } -} diff --git a/vendor/rmcp/src/handler/server/router/tool.rs b/vendor/rmcp/src/handler/server/router/tool.rs deleted file mode 100644 index f8fab0239..000000000 --- a/vendor/rmcp/src/handler/server/router/tool.rs +++ /dev/null @@ -1,726 +0,0 @@ -//! Tools for MCP servers. -//! -//! It's straightforward to define tools using [`tool_router`][crate::tool_router] and -//! [`tool`][crate::tool] macro. -//! -//! ```rust -//! # use rmcp::{ -//! # tool_router, tool, -//! # handler::server::{wrapper::{Parameters, Json}, tool::ToolRouter}, -//! # schemars -//! # }; -//! # use serde::{Serialize, Deserialize}; -//! struct Server; -//! -//! #[derive(Deserialize, schemars::JsonSchema, Default)] -//! struct AddParameter { -//! left: usize, -//! right: usize -//! } -//! #[derive(Serialize, schemars::JsonSchema)] -//! struct AddOutput { -//! sum: usize -//! } -//! #[tool_router(server_handler)] -//! impl Server { -//! #[tool(name = "adder", description = "Modular add two integers")] -//! fn add( -//! &self, -//! Parameters(AddParameter { left, right }): Parameters -//! ) -> Json { -//! Json(AddOutput { sum: left.wrapping_add(right) }) -//! } -//! } -//! ``` -//! -//! The `server_handler` flag emits `#[tool_handler]` for you (tools-only servers). For custom -//! `#[tool_handler(...)]` options or multiple handler macros on one `impl ServerHandler`, write -//! `#[tool_router]` and `#[tool_handler] impl ServerHandler for ...` explicitly—see -//! [`tool_router`][crate::tool_router] and [`tool_handler`][crate::tool_handler]. -//! -//! Using the macro-based code pattern above is suitable for small MCP servers with simple interfaces. -//! When the business logic become larger, it is recommended that each tool should reside -//! in individual file, combined into MCP server using [`SyncTool`] and [`AsyncTool`] traits. -//! -//! ```rust -//! # use rmcp::{ -//! # handler::server::{ -//! # tool::ToolRouter, -//! # router::tool::{SyncTool, AsyncTool, ToolBase}, -//! # }, -//! # schemars, ErrorData -//! # }; -//! # pub struct MyCustomError; -//! # impl From for ErrorData { -//! # fn from(err: MyCustomError) -> ErrorData { unimplemented!() } -//! # } -//! # use serde::{Serialize, Deserialize}; -//! # use std::borrow::Cow; -//! // In tool1.rs -//! pub struct ComplexTool1; -//! #[derive(Deserialize, schemars::JsonSchema, Default)] -//! pub struct ComplexTool1Input { /* ... */ } -//! #[derive(Serialize, schemars::JsonSchema)] -//! pub struct ComplexTool1Output { /* ... */ } -//! -//! impl ToolBase for ComplexTool1 { -//! type Parameter = ComplexTool1Input; -//! type Output = ComplexTool1Output; -//! type Error = MyCustomError; -//! fn name() -> Cow<'static, str> { -//! "complex-tool1".into() -//! } -//! -//! fn description() -> Option> { -//! Some("...".into()) -//! } -//! } -//! impl SyncTool for ComplexTool1 { -//! fn invoke(service: &MyToolServer, param: Self::Parameter) -> Result { -//! // ... -//! # unimplemented!() -//! } -//! } -//! // In tool2.rs -//! pub struct ComplexTool2; -//! #[derive(Deserialize, schemars::JsonSchema, Default)] -//! pub struct ComplexTool2Input { /* ... */ } -//! #[derive(Serialize, schemars::JsonSchema)] -//! pub struct ComplexTool2Output { /* ... */ } -//! -//! impl ToolBase for ComplexTool2 { -//! type Parameter = ComplexTool2Input; -//! type Output = ComplexTool2Output; -//! type Error = MyCustomError; -//! fn name() -> Cow<'static, str> { -//! "complex-tool2".into() -//! } -//! -//! fn description() -> Option> { -//! Some("...".into()) -//! } -//! } -//! impl AsyncTool for ComplexTool2 { -//! async fn invoke(service: &MyToolServer, param: Self::Parameter) -> Result { -//! // ... -//! # unimplemented!() -//! } -//! } -//! -//! // In tool_router.rs -//! struct MyToolServer { -//! tool_router: ToolRouter, -//! } -//! impl MyToolServer { -//! pub fn tool_router() -> ToolRouter { -//! ToolRouter::new() -//! .with_sync_tool::() -//! .with_async_tool::() -//! } -//! } -//! ``` -//! -//! It's also possible to use macro-based and trait-based tool definition together: Since -//! [`ToolRouter`] implements [`Add`][std::ops::Add], you can add two tool routers into final -//! router as showed in [the documentation of `tool_router`][crate::tool_router]. - -mod tool_traits; - -use std::{borrow::Cow, sync::Arc}; - -use schemars::JsonSchema; -pub use tool_traits::{AsyncTool, SyncTool, ToolBase}; - -use crate::{ - handler::server::{ - common::schema_for_input, - tool::{CallToolHandler, DynCallToolHandler, ToolCallContext}, - tool_name_validation::validate_and_warn_tool_name, - }, - model::{CallToolResponse, CallToolResult, ContentBlock, ErrorCode, Tool, ToolAnnotations}, - service::{MaybeBoxFuture, MaybeSend}, -}; - -const TOOL_ARGUMENT_DESERIALIZATION_ERROR_PREFIX: &str = "failed to deserialize parameters:"; - -fn into_tool_argument_error(error: crate::ErrorData) -> Result { - if error.code == ErrorCode::INVALID_PARAMS - && error - .message - .starts_with(TOOL_ARGUMENT_DESERIALIZATION_ERROR_PREFIX) - { - return Ok(CallToolResult::error(vec![ContentBlock::text(error.message)]).into()); - } - - Err(error) -} - -#[non_exhaustive] -pub struct ToolRoute { - #[allow(clippy::type_complexity)] - pub call: Arc>, - pub attr: crate::model::Tool, -} - -impl std::fmt::Debug for ToolRoute { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ToolRoute") - .field("name", &self.attr.name) - .field("description", &self.attr.description) - .field("input_schema", &self.attr.input_schema) - .finish() - } -} - -impl Clone for ToolRoute { - fn clone(&self) -> Self { - Self { - call: self.call.clone(), - attr: self.attr.clone(), - } - } -} - -impl ToolRoute { - pub fn new(attr: impl Into, call: C) -> Self - where - C: CallToolHandler + MaybeSend + Clone + 'static, - { - Self { - call: Arc::new(move |context: ToolCallContext| { - let call = call.clone(); - context.invoke(call) - }), - attr: attr.into(), - } - } - pub fn new_dyn(attr: impl Into, call: C) -> Self - where - C: for<'a> Fn( - ToolCallContext<'a, S>, - ) - -> MaybeBoxFuture<'a, Result> - + MaybeSend - + 'static, - { - Self { - call: Arc::new(call), - attr: attr.into(), - } - } - pub fn name(&self) -> &str { - &self.attr.name - } -} - -pub trait IntoToolRoute { - fn into_tool_route(self) -> ToolRoute; -} - -impl IntoToolRoute for (T, C) -where - S: MaybeSend + 'static, - C: CallToolHandler + MaybeSend + Clone + 'static, - T: Into, -{ - fn into_tool_route(self) -> ToolRoute { - ToolRoute::new(self.0.into(), self.1) - } -} - -impl IntoToolRoute for ToolRoute -where - S: MaybeSend + 'static, -{ - fn into_tool_route(self) -> ToolRoute { - self - } -} - -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct ToolAttrGenerateFunctionAdapter; -impl IntoToolRoute for F -where - S: MaybeSend + 'static, - F: Fn() -> ToolRoute, -{ - fn into_tool_route(self) -> ToolRoute { - (self)() - } -} - -pub trait CallToolHandlerExt: Sized -where - Self: CallToolHandler + MaybeSend + Clone + 'static, -{ - fn name(self, name: impl Into>) -> WithToolAttr; -} - -impl CallToolHandlerExt for C -where - C: CallToolHandler + MaybeSend + Clone + 'static, -{ - fn name(self, name: impl Into>) -> WithToolAttr { - WithToolAttr { - attr: Tool::new( - name.into(), - "", - schema_for_input::().unwrap_or_else(|e| { - panic!("Invalid input schema for JsonObject: {e}"); - }), - ), - call: self, - _marker: std::marker::PhantomData, - } - } -} - -#[non_exhaustive] -pub struct WithToolAttr -where - C: CallToolHandler + MaybeSend + Clone + 'static, -{ - pub attr: crate::model::Tool, - pub call: C, - pub _marker: std::marker::PhantomData, -} - -impl IntoToolRoute for WithToolAttr -where - C: CallToolHandler + MaybeSend + Clone + 'static, - S: MaybeSend + 'static, -{ - fn into_tool_route(self) -> ToolRoute { - ToolRoute::new(self.attr, self.call) - } -} - -impl WithToolAttr -where - C: CallToolHandler + MaybeSend + Clone + 'static, -{ - pub fn description(mut self, description: impl Into>) -> Self { - self.attr.description = Some(description.into()); - self - } - pub fn parameters(mut self) -> Self { - self.attr.input_schema = schema_for_input::().unwrap_or_else(|e| { - panic!( - "Invalid input schema for `{}`: {e}", - std::any::type_name::() - ) - }); - self - } - pub fn parameters_value(mut self, schema: serde_json::Value) -> Self { - self.attr.input_schema = crate::model::object(schema).into(); - self - } - pub fn annotation(mut self, annotation: impl Into) -> Self { - self.attr.annotations = Some(annotation.into()); - self - } -} -#[non_exhaustive] -pub struct ToolRouter { - #[allow(clippy::type_complexity)] - pub map: std::collections::HashMap, ToolRoute>, - - pub transparent_when_not_found: bool, - - disabled: std::collections::HashSet>, - - notifier: Option>, -} - -impl std::fmt::Debug for ToolRouter { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ToolRouter") - .field("map", &self.map) - .field( - "transparent_when_not_found", - &self.transparent_when_not_found, - ) - .field("disabled", &self.disabled) - .field("notifier", &self.notifier.as_ref().map(|_| "...")) - .finish() - } -} - -impl Default for ToolRouter { - fn default() -> Self { - Self { - map: std::collections::HashMap::new(), - transparent_when_not_found: false, - disabled: std::collections::HashSet::new(), - notifier: None, - } - } -} - -impl Clone for ToolRouter { - fn clone(&self) -> Self { - Self { - map: self.map.clone(), - transparent_when_not_found: self.transparent_when_not_found, - disabled: self.disabled.clone(), - notifier: self.notifier.clone(), - } - } -} - -impl IntoIterator for ToolRouter { - type Item = ToolRoute; - type IntoIter = std::collections::hash_map::IntoValues, ToolRoute>; - - fn into_iter(self) -> Self::IntoIter { - let mut map = self.map; - for name in &self.disabled { - map.remove(name); - } - map.into_values() - } -} - -impl ToolRouter -where - S: MaybeSend + 'static, -{ - pub fn new() -> Self { - Self::default() - } - pub fn with_route(mut self, route: R) -> Self - where - R: IntoToolRoute, - { - self.add_route(route.into_tool_route()); - self - } - - /// Add a tool that implements [`SyncTool`] - pub fn with_sync_tool(self) -> Self - where - T: SyncTool + 'static, - { - if T::input_schema().is_some() { - self.with_route(( - tool_traits::tool_attribute::(), - tool_traits::sync_tool_wrapper::, - )) - } else { - self.with_route(( - tool_traits::tool_attribute::(), - tool_traits::sync_tool_wrapper_with_empty_params::, - )) - } - } - - /// Add a tool that implements [`AsyncTool`] - pub fn with_async_tool(self) -> Self - where - T: AsyncTool + 'static, - { - if T::input_schema().is_some() { - self.with_route(( - tool_traits::tool_attribute::(), - tool_traits::async_tool_wrapper::, - )) - } else { - self.with_route(( - tool_traits::tool_attribute::(), - tool_traits::async_tool_wrapper_with_empty_params::, - )) - } - } - - pub fn add_route(&mut self, item: ToolRoute) { - let new_name = &item.attr.name; - validate_and_warn_tool_name(new_name); - self.map.insert(new_name.clone(), item); - } - - pub fn merge(&mut self, other: ToolRouter) { - self.disabled.extend(other.disabled); - for item in other.map.into_values() { - self.add_route(item); - } - } - - /// Remove a tool route from the router. - /// - /// The disabled state is **preserved**: if the name was in the disabled - /// set, it stays there so that a future [`add_route`](Self::add_route) - /// or [`merge`](Self::merge) with the same name will inherit the - /// disabled state. To also clear the disabled marker, call - /// [`enable_route`](Self::enable_route) afterwards. - pub fn remove_route(&mut self, name: &str) { - self.map.remove(name); - } - - /// Returns `true` if the tool is registered **and** not currently - /// disabled. - pub fn has_route(&self, name: &str) -> bool { - self.map.contains_key(name) && !self.disabled.contains(name) - } - - /// Disable a tool by name. Hidden from `list_all`, `get`, rejected by - /// `call`. Re-enable with [`enable_route`](Self::enable_route). - /// - /// Returns `true` if the name was newly added to the disabled set. - /// The name is recorded even if no matching route exists yet, so routes - /// added later will inherit the disabled state. - pub fn disable_route(&mut self, name: impl Into>) -> bool { - let name = name.into(); - let was_visible = self.map.contains_key(&name) && !self.disabled.contains(&name); - if was_visible { - self.notify_if_visible(&name); - } - self.disabled.insert(name) - } - - /// Re-enable a previously disabled tool. Returns `true` if the name - /// was in the disabled set. - pub fn enable_route(&mut self, name: &str) -> bool { - let removed = self.disabled.remove(name); - if removed { - self.notify_if_visible(name); - } - removed - } - - /// Returns `true` if the tool exists in the router **and** is currently - /// disabled. Returns `false` if the tool does not exist or if the name - /// was pre-disabled without a matching route. - pub fn is_disabled(&self, name: &str) -> bool { - self.map.contains_key(name) && self.disabled.contains(name) - } - - /// Builder-style variant of [`disable_route`](Self::disable_route). - /// - /// The name is recorded even if no matching route has been added yet, - /// so it can be called before [`with_route`](Self::with_route) in a - /// builder chain. - pub fn with_disabled(mut self, name: impl Into>) -> Self { - self.disabled.insert(name.into()); - self - } - - /// Install a callback invoked when the visible tool list changes. - pub fn set_notifier(&mut self, f: impl Fn() + Send + Sync + 'static) { - self.notifier = Some(Arc::new(f)); - } - - pub fn clear_notifier(&mut self) { - self.notifier = None; - } - - /// Install a notifier that sends `notifications/tools/list_changed` - /// via the given peer. - pub fn bind_peer_notifier(&mut self, peer: &crate::service::Peer) { - let peer = peer.clone(); - self.set_notifier(move || { - let peer = peer.clone(); - tokio::spawn(async move { - if let Err(e) = peer.notify_tool_list_changed().await { - tracing::warn!("failed to send tools/list_changed notification: {e}"); - } - }); - }); - } - - /// Deferred notifier: no-op until the peer slot is filled. - pub(crate) fn deferred_peer_notifier() -> ( - impl Fn() + Send + Sync + 'static, - Arc>>, - ) { - let peer_slot = - Arc::new(std::sync::OnceLock::>::new()); - let slot_clone = peer_slot.clone(); - let notifier = move || { - if let Some(peer) = slot_clone.get() { - let peer = peer.clone(); - tokio::spawn(async move { - if let Err(e) = peer.notify_tool_list_changed().await { - tracing::warn!("failed to send tools/list_changed notification: {e}"); - } - }); - } - }; - (notifier, peer_slot) - } - - fn notify_if_visible(&self, name: &str) { - if self.map.contains_key(name) - && let Some(notifier) = &self.notifier - { - notifier(); - } - } - - pub async fn call( - &self, - context: ToolCallContext<'_, S>, - ) -> Result { - let name = context.name(); - if self.disabled.contains(name) { - return Err(crate::ErrorData::invalid_params("tool not found", None)); - } - let item = self - .map - .get(name) - .ok_or_else(|| crate::ErrorData::invalid_params("tool not found", None))?; - - let result = match (item.call)(context).await { - Ok(result) => result, - Err(error) => return into_tool_argument_error(error), - }; - - Ok(result) - } - - pub fn list_all(&self) -> Vec { - let mut tools: Vec<_> = self - .map - .values() - .filter(|item| !self.disabled.contains(&item.attr.name)) - .map(|item| item.attr.clone()) - .collect(); - tools.sort_by(|a, b| a.name.cmp(&b.name)); - tools - } - - /// Get a tool definition by name. - /// - /// Returns the tool if found and enabled, or `None` if the tool does not - /// exist or is disabled. - pub fn get(&self, name: &str) -> Option<&crate::model::Tool> { - if self.disabled.contains(name) { - return None; - } - self.map.get(name).map(|r| &r.attr) - } -} - -impl std::ops::Add> for ToolRouter -where - S: MaybeSend + 'static, -{ - type Output = Self; - - fn add(mut self, other: ToolRouter) -> Self::Output { - self.merge(other); - self - } -} - -impl std::ops::AddAssign> for ToolRouter -where - S: MaybeSend + 'static, -{ - fn add_assign(&mut self, other: ToolRouter) { - self.merge(other); - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use super::*; - use crate::{ - RoleServer, - handler::server::wrapper::Parameters, - model::{CallToolRequestParams, ErrorCode, NumberOrString}, - service::{AtomicU32RequestIdProvider, Peer, RequestContext}, - }; - - struct DummyService; - impl crate::handler::server::ServerHandler for DummyService {} - - #[derive(serde::Deserialize, schemars::JsonSchema)] - struct RequiredParams { - project: String, - } - - fn requires_params(Parameters(params): Parameters) -> String { - params.project - } - - #[tokio::test] - async fn test_argument_deserialization_error_returns_tool_error_result() { - let service = DummyService; - let router = ToolRouter::new().with_route(ToolRoute::new( - crate::model::Tool::new( - "requires_params", - "requires params", - Arc::new(Default::default()), - ), - requires_params, - )); - - let id_provider: Arc = - Arc::new(AtomicU32RequestIdProvider::default()); - let (peer, _rx) = Peer::::new(id_provider, None); - let ctx = crate::handler::server::tool::ToolCallContext::new( - &service, - CallToolRequestParams { - meta: None, - name: Cow::Borrowed("requires_params"), - arguments: Some(Default::default()), - input_responses: None, - request_state: None, - }, - RequestContext::new(NumberOrString::Number(1), peer), - ); - - let result = router - .call(ctx) - .await - .expect("argument validation should be a tool result"); - let CallToolResponse::Complete(result) = result else { - panic!("expected complete CallToolResult"); - }; - assert_eq!(result.is_error, Some(true)); - - let text = result - .content - .first() - .and_then(|content| content.as_text()) - .map(|text| text.text.as_str()) - .expect("tool error result should include text"); - assert!(text.contains("failed to deserialize parameters")); - assert!(text.contains("missing field `project`")); - } - - #[tokio::test] - async fn test_call_disabled_tool_returns_error() { - let service = DummyService; - let mut router = ToolRouter::new().with_route(ToolRoute::new_dyn( - crate::model::Tool::new("test_tool", "a test tool", Arc::new(Default::default())), - |_ctx| Box::pin(async { Ok(CallToolResult::default().into()) }), - )); - router.disable_route("test_tool"); - - let id_provider: Arc = - Arc::new(AtomicU32RequestIdProvider::default()); - let (peer, _rx) = Peer::::new(id_provider, None); - let ctx = crate::handler::server::tool::ToolCallContext::new( - &service, - CallToolRequestParams { - meta: None, - name: Cow::Borrowed("test_tool"), - arguments: None, - input_responses: None, - request_state: None, - }, - RequestContext::new(NumberOrString::Number(1), peer), - ); - - let err = router - .call(ctx) - .await - .expect_err("disabled tool should reject"); - assert_eq!(err.code, ErrorCode::INVALID_PARAMS); - assert_eq!(err.message, "tool not found"); - } -} diff --git a/vendor/rmcp/src/handler/server/router/tool/tool_traits.rs b/vendor/rmcp/src/handler/server/router/tool/tool_traits.rs deleted file mode 100644 index bde92147d..000000000 --- a/vendor/rmcp/src/handler/server/router/tool/tool_traits.rs +++ /dev/null @@ -1,339 +0,0 @@ -use std::{borrow::Cow, future::Future, sync::Arc}; - -use serde::{Deserialize, Serialize}; - -use crate::{ - ErrorData, - handler::server::{ - common::{schema_for_empty_input, schema_for_input}, - tool::schema_for_output, - wrapper::{Json, Parameters}, - }, - model::{Icon, JsonObject, MetaObject, ToolAnnotations}, - schemars::JsonSchema, - service::{MaybeSend, MaybeSendFuture}, -}; - -/// Base trait to define attributes of a tool. -/// -/// Tools implementing [`SyncTool`] or [`AsyncTool`] must implement this trait first. -/// -/// All methods are consistent with fields of [`Tool`][crate::model::Tool]. -pub trait ToolBase { - /// Parameter type, will used in the invoke parameter of [`SyncTool`] or [`AsyncTool`] trait - /// - /// If the tool does not have any parameters, you **MUST** override [`input_schema`][Self::input_schema] - /// method. See its documentation for more details. - type Parameter: for<'de> Deserialize<'de> + JsonSchema + Send + Default + 'static; - /// Output type, will used in the invoke output of [`SyncTool`] or [`AsyncTool`] trait - /// - /// If the tool does not have any output, you **MUST** override [`output_schema`][Self::output_schema] - /// method. See its documentation for more details. - type Output: Serialize + JsonSchema + Send + 'static; - /// Error type, will used in the invoke output of [`SyncTool`] or [`AsyncTool`] trait - type Error: Into + Send + 'static; - - fn name() -> Cow<'static, str>; - - fn title() -> Option { - None - } - fn description() -> Option> { - None - } - - /// Json schema for tool input. - /// - /// The default implementation generates schema based on [`Self::Parameter`] type. - /// - /// If the tool does not have any parameters, you should override this methods to return [`None`], - /// and when invoked, the parameter will get default values. - fn input_schema() -> Option> { - Some( - schema_for_input::>().unwrap_or_else(|e| { - panic!( - "Invalid input schema for ToolBase::Parameter type `{0}`: {e}", - std::any::type_name::(), - ); - }), - ) - } - - /// Json schema for tool output. - /// - /// The default implementation generates schema based on [`Self::Output`] type. - /// - /// If the tool does not have any output, you should override this methods to return [`None`]. - fn output_schema() -> Option> { - Some(schema_for_output::()) - } - - fn annotations() -> Option { - None - } - fn icons() -> Option> { - None - } - fn meta() -> Option { - None - } -} - -/// Synchronous version of a tool. -/// -/// Consider using [`AsyncTool`] if your workflow involves asynchronous operations. -/// Examples are shown in [the module-level documentation][crate::handler::server::router::tool]. -#[allow(private_bounds)] -pub trait SyncTool: ToolBase { - fn invoke(service: &S, param: Self::Parameter) -> Result; -} - -/// Asynchronous version of a tool. -/// -/// Consider using [`SyncTool`] if your workflow does not involve asynchronous operations. -/// Examples are shown in [the module-level documentation][crate::handler::server::router::tool]. -#[allow(private_bounds)] -pub trait AsyncTool: ToolBase { - fn invoke( - service: &S, - param: Self::Parameter, - ) -> impl Future> + MaybeSendFuture; -} - -pub(crate) fn tool_attribute() -> crate::model::Tool { - crate::model::Tool { - name: T::name(), - title: T::title(), - description: T::description(), - input_schema: T::input_schema().unwrap_or_else(schema_for_empty_input), - output_schema: T::output_schema(), - annotations: T::annotations(), - icons: T::icons(), - meta: T::meta(), - } -} - -pub(crate) fn sync_tool_wrapper>( - service: &S, - Parameters(params): Parameters, -) -> Result, ErrorData> { - T::invoke(service, params).map(Json).map_err(Into::into) -} - -pub(crate) fn sync_tool_wrapper_with_empty_params>( - service: &S, -) -> Result, ErrorData> { - T::invoke(service, T::Parameter::default()) - .map(Json) - .map_err(Into::into) -} - -pub(crate) fn async_tool_wrapper>( - service: &S, - Parameters(params): Parameters, -) -> crate::service::MaybeBoxFuture<'_, Result, ErrorData>> { - Box::pin(async move { - T::invoke(service, params) - .await - .map(Json) - .map_err(Into::into) - }) -} - -pub(crate) fn async_tool_wrapper_with_empty_params>( - service: &S, -) -> crate::service::MaybeBoxFuture<'_, Result, ErrorData>> { - Box::pin(async move { - T::invoke(service, T::Parameter::default()) - .await - .map(Json) - .map_err(Into::into) - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate as rmcp; - use crate::tool; // workaround for macros - - #[derive(Deserialize, schemars::JsonSchema, Default)] - struct AddParameter { - left: usize, - right: usize, - } - #[derive(Serialize, schemars::JsonSchema, PartialEq, Debug)] - struct AddOutput { - sum: usize, - } - - struct MacroBasedToolServer; - - impl MacroBasedToolServer { - #[expect(unused)] - #[tool(name = "adder", description = "Modular add two integers")] - fn add( - &self, - Parameters(AddParameter { left, right }): Parameters, - ) -> Json { - Json(AddOutput { - sum: left.wrapping_add(right), - }) - } - - #[expect(unused)] - #[tool(name = "empty", description = "Empty tool")] - fn empty(&self) {} - } - - struct AddTool; - impl ToolBase for AddTool { - type Parameter = AddParameter; - type Output = AddOutput; - type Error = ErrorData; - - fn name() -> Cow<'static, str> { - "adder".into() - } - - fn description() -> Option> { - Some("Modular add two integers".into()) - } - } - impl SyncTool for AddTool { - fn invoke( - _service: &TraitBasedToolServer, - AddParameter { left, right }: Self::Parameter, - ) -> Result { - Ok(AddOutput { - sum: left.wrapping_add(right), - }) - } - } - impl AsyncTool for AddTool { - async fn invoke( - _service: &TraitBasedToolServer, - AddParameter { left, right }: Self::Parameter, - ) -> Result { - Ok(AddOutput { - sum: left.wrapping_add(right), - }) - } - } - - enum EmptyToolCustomError { - Internal, - InvalidParams, - } - impl From for ErrorData { - fn from(value: EmptyToolCustomError) -> Self { - match value { - EmptyToolCustomError::Internal => Self::internal_error("internal error", None), - EmptyToolCustomError::InvalidParams => Self::invalid_params("invalid params", None), - } - } - } - - struct EmptyTool; - impl ToolBase for EmptyTool { - type Parameter = (); - type Output = (); - type Error = EmptyToolCustomError; - - fn name() -> Cow<'static, str> { - "empty".into() - } - - fn description() -> Option> { - Some("Empty tool".into()) - } - - fn input_schema() -> Option> { - None - } - - fn output_schema() -> Option> { - None - } - } - impl SyncTool for EmptyTool { - fn invoke( - _service: &TraitBasedToolServer, - _param: Self::Parameter, - ) -> Result { - Err(EmptyToolCustomError::Internal) - } - } - impl AsyncTool for EmptyTool { - async fn invoke( - _service: &TraitBasedToolServer, - _param: Self::Parameter, - ) -> Result { - Err(EmptyToolCustomError::InvalidParams) - } - } - - struct TraitBasedToolServer; - - #[test] - fn test_macro_and_trait_have_same_attrs() { - let macro_attrs = MacroBasedToolServer::add_tool_attr(); - let trait_attrs = tool_attribute::(); - assert_eq!(macro_attrs, trait_attrs); - } - - #[test] - fn test_macro_and_trait_have_same_attrs_for_empty_tool() { - let macro_attrs = MacroBasedToolServer::empty_tool_attr(); - let trait_attrs = tool_attribute::(); - assert_eq!(macro_attrs, trait_attrs); - } - - #[test] - fn test_sync_tool_wrapper_happy_path() { - let left = 1; - let right = 2; - let result = sync_tool_wrapper::<_, AddTool>( - &TraitBasedToolServer, - Parameters(AddParameter { left, right }), - ); - assert!(result.is_ok()); - if let Ok(result) = result { - assert_eq!(result.0, AddOutput { sum: 3 }); - } - } - - #[tokio::test] - async fn test_async_tool_wrapper_happy_path() { - let left = 1; - let right = 2; - let result = async_tool_wrapper::<_, AddTool>( - &TraitBasedToolServer, - Parameters(AddParameter { left, right }), - ) - .await; - assert!(result.is_ok()); - if let Ok(result) = result { - assert_eq!(result.0, AddOutput { sum: 3 }); - } - } - - #[test] - fn test_sync_tool_wrapper_error_conversion() { - let result = sync_tool_wrapper::<_, EmptyTool>(&TraitBasedToolServer, Parameters(())); - assert!(result.is_err()); - if let Err(result) = result { - assert_eq!(result, ErrorData::internal_error("internal error", None)); - } - } - - #[tokio::test] - async fn test_async_tool_wrapper_error_conversion() { - let result = - async_tool_wrapper::<_, EmptyTool>(&TraitBasedToolServer, Parameters(())).await; - assert!(result.is_err()); - if let Err(result) = result { - assert_eq!(result, ErrorData::invalid_params("invalid params", None)); - } - } -} diff --git a/vendor/rmcp/src/handler/server/tool.rs b/vendor/rmcp/src/handler/server/tool.rs deleted file mode 100644 index a90240660..000000000 --- a/vendor/rmcp/src/handler/server/tool.rs +++ /dev/null @@ -1,367 +0,0 @@ -use std::{ - borrow::Cow, - future::{Future, Ready}, - marker::PhantomData, -}; - -#[cfg(not(feature = "local"))] -use futures::future::BoxFuture; -use serde::de::DeserializeOwned; - -use super::common::{AsRequestContext, FromContextPart}; -pub use super::{ - common::{Extension, RequestId, schema_for_input, schema_for_output, schema_for_type}, - router::tool::{ToolRoute, ToolRouter}, -}; -use crate::{ - RoleServer, - handler::server::wrapper::Parameters, - model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, InputRequiredResult, IntoContents, - JsonObject, - }, - service::{MaybeBoxFuture, MaybeSend, MaybeSendFuture, RequestContext}, -}; - -/// Deserialize a JSON object into a type -pub fn parse_json_object(input: JsonObject) -> Result { - serde_json::from_value(serde_json::Value::Object(input)).map_err(|e| { - crate::ErrorData::invalid_params( - format!("failed to deserialize parameters: {error}", error = e), - None, - ) - }) -} -#[non_exhaustive] -pub struct ToolCallContext<'s, S> { - pub request_context: RequestContext, - pub service: &'s S, - pub name: Cow<'static, str>, - pub arguments: Option, -} - -impl<'s, S> ToolCallContext<'s, S> { - pub fn new( - service: &'s S, - CallToolRequestParams { - meta: _, - name, - arguments, - .. - }: CallToolRequestParams, - request_context: RequestContext, - ) -> Self { - Self { - request_context, - service, - name, - arguments, - } - } - pub fn name(&self) -> &str { - &self.name - } - pub fn request_context(&self) -> &RequestContext { - &self.request_context - } -} - -impl AsRequestContext for ToolCallContext<'_, S> { - fn as_request_context(&self) -> &RequestContext { - &self.request_context - } - - fn as_request_context_mut(&mut self) -> &mut RequestContext { - &mut self.request_context - } -} - -pub trait IntoCallToolResult { - fn into_call_tool_result(self) -> Result; -} - -impl IntoCallToolResult for T { - fn into_call_tool_result(self) -> Result { - Ok(CallToolResult::success(self.into_contents()).into()) - } -} - -impl IntoCallToolResult for CallToolResult { - fn into_call_tool_result(self) -> Result { - Ok(self.into()) - } -} - -impl IntoCallToolResult for InputRequiredResult { - fn into_call_tool_result(self) -> Result { - Ok(self.into()) - } -} - -impl IntoCallToolResult for crate::ErrorData { - fn into_call_tool_result(self) -> Result { - Err(self) - } -} - -impl IntoCallToolResult for Result { - fn into_call_tool_result(self) -> Result { - match self { - Ok(value) => value.into_call_tool_result(), - Err(error) => match error.into_call_tool_result() { - Ok(CallToolResponse::Complete(mut result)) => { - result.is_error = Some(true); - Ok(result.into()) - } - Ok(CallToolResponse::InputRequired(_)) => Err(crate::ErrorData::internal_error( - "InputRequiredResult cannot be returned from a tool error branch", - None, - )), - Ok(CallToolResponse::Task(_)) => Err(crate::ErrorData::internal_error( - "CreateTaskResult cannot be returned from a tool error branch", - None, - )), - Err(e) => Err(e), - }, - } - } -} - -pin_project_lite::pin_project! { - #[project = IntoCallToolResultFutProj] - #[non_exhaustive] - pub enum IntoCallToolResultFut { - Pending { - #[pin] - fut: F, - _marker: PhantomData, - }, - Ready { - #[pin] - result: Ready>, - } - } -} - -impl Future for IntoCallToolResultFut -where - F: Future, - R: IntoCallToolResult, -{ - type Output = Result; - - fn poll( - self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll { - match self.project() { - IntoCallToolResultFutProj::Pending { fut, _marker } => { - fut.poll(cx).map(IntoCallToolResult::into_call_tool_result) - } - IntoCallToolResultFutProj::Ready { result } => result.poll(cx), - } - } -} - -pub trait CallToolHandler { - fn call( - self, - context: ToolCallContext<'_, S>, - ) -> MaybeBoxFuture<'_, Result>; -} - -#[cfg(not(feature = "local"))] -pub type DynCallToolHandler = dyn for<'s> Fn(ToolCallContext<'s, S>) -> BoxFuture<'s, Result> - + Send - + Sync; - -#[cfg(feature = "local")] -pub type DynCallToolHandler = dyn for<'s> Fn( - ToolCallContext<'s, S>, -) -> futures::future::LocalBoxFuture< - 's, - Result, ->; - -// Tool-specific extractor for tool name -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct ToolName(pub Cow<'static, str>); - -impl FromContextPart> for ToolName { - fn from_context_part(context: &mut ToolCallContext) -> Result { - Ok(Self(context.name.clone())) - } -} - -// Special implementation for Parameters that handles tool arguments -impl FromContextPart> for Parameters

-where - P: DeserializeOwned, -{ - fn from_context_part(context: &mut ToolCallContext) -> Result { - let arguments = context.arguments.take().unwrap_or_default(); - let value: P = - serde_json::from_value(serde_json::Value::Object(arguments)).map_err(|e| { - crate::ErrorData::invalid_params( - format!("failed to deserialize parameters: {error}", error = e), - None, - ) - })?; - Ok(Parameters(value)) - } -} - -// Special implementation for JsonObject that takes tool arguments -impl FromContextPart> for JsonObject { - fn from_context_part(context: &mut ToolCallContext) -> Result { - let object = context.arguments.take().unwrap_or_default(); - Ok(object) - } -} - -impl<'s, S> ToolCallContext<'s, S> { - pub fn invoke( - self, - h: H, - ) -> MaybeBoxFuture<'s, Result> - where - H: CallToolHandler, - { - h.call(self) - } -} -#[allow(clippy::type_complexity)] -pub struct AsyncAdapter(PhantomData fn(Fut) -> R>); -pub struct SyncAdapter(PhantomData R>); -// #[allow(clippy::type_complexity)] -pub struct AsyncMethodAdapter(PhantomData R>); -pub struct SyncMethodAdapter(PhantomData R>); - -macro_rules! impl_for { - ($($T: ident)*) => { - impl_for!([] [$($T)*]); - }; - // finished - ([$($Tn: ident)*] []) => { - impl_for!(@impl $($Tn)*); - }; - ([$($Tn: ident)*] [$Tn_1: ident $($Rest: ident)*]) => { - impl_for!(@impl $($Tn)*); - impl_for!([$($Tn)* $Tn_1] [$($Rest)*]); - }; - (@impl $($Tn: ident)*) => { - impl<$($Tn,)* S, F, R> CallToolHandler> for F - where - $( - $Tn: for<'a> FromContextPart> , - )* - F: FnOnce(&S, $($Tn,)*) -> MaybeBoxFuture<'_, R>, - - // Need RTN support here(I guess), https://github.com/rust-lang/rust/pull/138424 - // Fut: Future + Send + 'a, - R: IntoCallToolResult + MaybeSendFuture + 'static, - S: MaybeSend + 'static, - { - #[allow(unused_variables, non_snake_case, unused_mut)] - fn call( - self, - mut context: ToolCallContext<'_, S>, - ) -> MaybeBoxFuture<'_, Result>{ - $( - let result = $Tn::from_context_part(&mut context); - let $Tn = match result { - Ok(value) => value, - Err(e) => return Box::pin(std::future::ready(Err(e))), - }; - )* - let service = context.service; - let fut = self(service, $($Tn,)*); - Box::pin(async move { - let result = fut.await; - result.into_call_tool_result() - }) - } - } - - impl<$($Tn,)* S, F, Fut, R> CallToolHandler> for F - where - $( - $Tn: for<'a> FromContextPart> , - )* - F: FnOnce($($Tn,)*) -> Fut + MaybeSendFuture, - Fut: Future + MaybeSendFuture + 'static, - R: IntoCallToolResult + MaybeSendFuture + 'static, - S: MaybeSend, - { - #[allow(unused_variables, non_snake_case, unused_mut)] - fn call( - self, - mut context: ToolCallContext, - ) -> MaybeBoxFuture<'static, Result>{ - $( - let result = $Tn::from_context_part(&mut context); - let $Tn = match result { - Ok(value) => value, - Err(e) => return Box::pin(std::future::ready(Err(e))), - }; - )* - let fut = self($($Tn,)*); - Box::pin(async move { - let result = fut.await; - result.into_call_tool_result() - }) - } - } - - impl<$($Tn,)* S, F, R> CallToolHandler> for F - where - $( - $Tn: for<'a> FromContextPart> + , - )* - F: FnOnce(&S, $($Tn,)*) -> R + MaybeSendFuture, - R: IntoCallToolResult + MaybeSendFuture, - S: MaybeSend, - { - #[allow(unused_variables, non_snake_case, unused_mut)] - fn call( - self, - mut context: ToolCallContext, - ) -> MaybeBoxFuture<'static, Result> { - $( - let result = $Tn::from_context_part(&mut context); - let $Tn = match result { - Ok(value) => value, - Err(e) => return Box::pin(std::future::ready(Err(e))), - }; - )* - Box::pin(std::future::ready(self(context.service, $($Tn,)*).into_call_tool_result())) - } - } - - impl<$($Tn,)* S, F, R> CallToolHandler> for F - where - $( - $Tn: for<'a> FromContextPart> + , - )* - F: FnOnce($($Tn,)*) -> R + MaybeSendFuture, - R: IntoCallToolResult + MaybeSendFuture, - S: MaybeSend, - { - #[allow(unused_variables, non_snake_case, unused_mut)] - fn call( - self, - mut context: ToolCallContext, - ) -> MaybeBoxFuture<'static, Result> { - $( - let result = $Tn::from_context_part(&mut context); - let $Tn = match result { - Ok(value) => value, - Err(e) => return Box::pin(std::future::ready(Err(e))), - }; - )* - Box::pin(std::future::ready(self($($Tn,)*).into_call_tool_result())) - } - } - }; -} -impl_for!(T0 T1 T2 T3 T4 T5 T6 T7 T8 T9 T10 T11 T12 T13 T14 T15); diff --git a/vendor/rmcp/src/handler/server/tool_name_validation.rs b/vendor/rmcp/src/handler/server/tool_name_validation.rs deleted file mode 100644 index 8ae04b626..000000000 --- a/vendor/rmcp/src/handler/server/tool_name_validation.rs +++ /dev/null @@ -1,261 +0,0 @@ -//! Tool name validation utilities according to SEP: Specify Format for Tool Names -//! -//! Tool names SHOULD be between 1 and 128 characters in length (inclusive). -//! Tool names are case-sensitive. -//! Allowed characters: uppercase and lowercase ASCII letters (A-Z, a-z), digits -//! (0-9), underscore (_), dash (-), and dot (.). -//! Tool names SHOULD NOT contain spaces, commas, or other special characters. - -use std::collections::HashSet; - -/// Result of tool name validation containing validation status and warnings. -#[derive(Debug, Clone, PartialEq, Eq)] -struct ToolNameValidationResult { - /// Whether the tool name is valid according to the specification - is_valid: bool, - /// Array of warning messages about non-conforming aspects of the tool name - warnings: Vec, -} - -impl ToolNameValidationResult { - /// Create a new validation result - fn new(is_valid: bool, warnings: Vec) -> Self { - Self { is_valid, warnings } - } -} - -/// Validates a tool name according to the SEP specification. -fn validate_tool_name(name: &str) -> ToolNameValidationResult { - let mut warnings = Vec::new(); - - // Check length - if name.is_empty() { - return ToolNameValidationResult::new(false, vec!["Tool name cannot be empty".to_string()]); - } - - if name.len() > 128 { - return ToolNameValidationResult::new( - false, - vec![format!( - "Tool name exceeds maximum length of 128 characters (current: {})", - name.len() - )], - ); - } - - // Check for specific problematic patterns (these are warnings, not validation failures) - if name.contains(' ') { - warnings.push("Tool name contains spaces, which may cause parsing issues".to_string()); - } - - if name.contains(',') { - warnings.push("Tool name contains commas, which may cause parsing issues".to_string()); - } - - // Check for potentially confusing patterns (leading/trailing dashes, dots, slashes) - if name.starts_with('-') || name.ends_with('-') { - warnings.push( - "Tool name starts or ends with a dash, which may cause parsing issues in some contexts" - .to_string(), - ); - } - - if name.starts_with('.') || name.ends_with('.') { - warnings.push( - "Tool name starts or ends with a dot, which may cause parsing issues in some contexts" - .to_string(), - ); - } - - // Check for invalid characters - let mut invalid_chars = HashSet::new(); - let valid_chars: HashSet = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-" - .chars() - .collect(); - - for ch in name.chars() { - if !valid_chars.contains(&ch) { - invalid_chars.insert(ch); - } - } - - if !invalid_chars.is_empty() { - let invalid_chars_list: Vec = - invalid_chars.iter().map(|c| format!("\"{}\"", c)).collect(); - warnings.push(format!( - "Tool name contains invalid characters: {}", - invalid_chars_list.join(", ") - )); - warnings.push( - "Allowed characters are: A-Z, a-z, 0-9, underscore (_), dash (-), and dot (.)" - .to_string(), - ); - - return ToolNameValidationResult::new(false, warnings); - } - - // Verify the pattern matches (double check with character-by-character validation) - // We've already validated characters above, just need to verify length is within bounds - if name.is_empty() || name.len() > 128 { - return ToolNameValidationResult::new( - false, - vec!["Tool name length must be between 1 and 128 characters".to_string()], - ); - } - - ToolNameValidationResult::new(true, warnings) -} - -/// Issues warnings for non-conforming tool names. -fn issue_tool_name_warning(name: &str, warnings: &[String]) { - tracing::warn!("Tool name validation warning for \"{}\":", name); - for warning in warnings { - tracing::warn!(" - {}", warning); - } - tracing::warn!("Tool registration will proceed, but this may cause compatibility issues."); - tracing::warn!("Consider updating the tool name to conform to the MCP tool naming standard."); - tracing::warn!( - "See SEP: Specify Format for Tool Names (https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986) for more details." - ); -} - -/// Validates a tool name and issues warnings for non-conforming names. -pub fn validate_and_warn_tool_name(name: &str) -> bool { - let result = validate_tool_name(name); - - if !result.warnings.is_empty() { - issue_tool_name_warning(name, &result.warnings); - } - - result.is_valid -} -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_valid_tool_names() { - let max_length_name = "a".repeat(128); - let valid_names = vec![ - "my_tool", - "MyTool", - "my-tool", - "my.tool", - "tool123", - "a", - max_length_name.as_str(), // Maximum length - ]; - - for name in valid_names { - let result = validate_tool_name(name); - assert!(result.is_valid, "Tool name '{}' should be valid", name); - } - } - - #[test] - fn test_empty_tool_name() { - let result = validate_tool_name(""); - assert!(!result.is_valid); - assert!( - result - .warnings - .contains(&"Tool name cannot be empty".to_string()) - ); - } - - #[test] - fn test_too_long_tool_name() { - let name = "a".repeat(129); - let result = validate_tool_name(&name); - assert!(!result.is_valid); - assert!(result.warnings[0].contains("exceeds maximum length")); - } - - #[test] - fn test_tool_name_with_spaces() { - let result = validate_tool_name("my tool"); - assert!(!result.is_valid); - assert!( - result - .warnings - .iter() - .any(|w| w.contains("contains spaces")) - ); - } - - #[test] - fn test_tool_name_with_commas() { - let result = validate_tool_name("my,tool"); - assert!(!result.is_valid); - assert!( - result - .warnings - .iter() - .any(|w| w.contains("contains commas")) - ); - } - - #[test] - fn test_tool_name_starting_with_dash() { - let result = validate_tool_name("-tool"); - assert!(result.is_valid); // Still valid, but has warning - assert!( - result - .warnings - .iter() - .any(|w| w.contains("starts or ends with a dash")) - ); - } - - #[test] - fn test_tool_name_ending_with_dot() { - let result = validate_tool_name("tool."); - assert!(result.is_valid); // Still valid, but has warning - assert!( - result - .warnings - .iter() - .any(|w| w.contains("starts or ends with a dot")) - ); - } - - #[test] - fn test_tool_name_with_invalid_characters() { - let result = validate_tool_name("my@tool"); - assert!(!result.is_valid); - assert!( - result - .warnings - .iter() - .any(|w| w.contains("contains invalid characters")) - ); - } - - #[test] - fn test_tool_name_all_special_characters_allowed() { - let valid_chars = vec!['_', '-', '.']; - for ch in valid_chars { - let name = format!("tool{}", ch); - let result = validate_tool_name(&name); - assert!( - result.is_valid, - "Tool name with character '{}' should be valid", - ch - ); - } - } - - #[test] - fn test_minimum_length() { - let result = validate_tool_name("a"); - assert!(result.is_valid); - } - - #[test] - fn test_maximum_length() { - let name = "a".repeat(128); - let result = validate_tool_name(&name); - assert!(result.is_valid); - } -} diff --git a/vendor/rmcp/src/handler/server/wrapper.rs b/vendor/rmcp/src/handler/server/wrapper.rs deleted file mode 100644 index d9f2c86d9..000000000 --- a/vendor/rmcp/src/handler/server/wrapper.rs +++ /dev/null @@ -1,4 +0,0 @@ -mod json; -mod parameters; -pub use json::*; -pub use parameters::*; diff --git a/vendor/rmcp/src/handler/server/wrapper/json.rs b/vendor/rmcp/src/handler/server/wrapper/json.rs deleted file mode 100644 index 7c5297963..000000000 --- a/vendor/rmcp/src/handler/server/wrapper/json.rs +++ /dev/null @@ -1,43 +0,0 @@ -use std::borrow::Cow; - -use schemars::JsonSchema; -use serde::Serialize; - -use crate::{ - handler::server::tool::IntoCallToolResult, - model::{CallToolResponse, CallToolResult}, -}; - -/// Json wrapper for structured output -/// -/// When used with tools, this wrapper indicates that the value should be -/// serialized as structured JSON content with an associated schema. -/// The framework will place the JSON in the `structured_content` field -/// of the tool result rather than the regular `content` field. -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct Json(pub T); - -// Implement JsonSchema for Json to delegate to T's schema -impl JsonSchema for Json { - fn schema_name() -> Cow<'static, str> { - T::schema_name() - } - - fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { - T::json_schema(generator) - } -} - -// Implementation for Json to create structured content -impl IntoCallToolResult for Json { - fn into_call_tool_result(self) -> Result { - let value = serde_json::to_value(self.0).map_err(|e| { - crate::ErrorData::internal_error( - format!("Failed to serialize structured content: {}", e), - None, - ) - })?; - - Ok(CallToolResult::structured(value).into()) - } -} diff --git a/vendor/rmcp/src/handler/server/wrapper/parameters.rs b/vendor/rmcp/src/handler/server/wrapper/parameters.rs deleted file mode 100644 index ab3eb0ce1..000000000 --- a/vendor/rmcp/src/handler/server/wrapper/parameters.rs +++ /dev/null @@ -1,56 +0,0 @@ -use schemars::JsonSchema; - -/// Parameter extractor for tools and prompts -/// -/// When used in tool and prompt handlers, this wrapper extracts and deserializes -/// parameters from the incoming request. The framework will automatically parse -/// the JSON arguments from tool calls or prompt arguments and deserialize them -/// into the specified type `P`. -/// -/// The `#[serde(transparent)]` attribute ensures that the wrapper doesn't add -/// an extra layer in the JSON structure - it directly delegates serialization -/// and deserialization to the inner type `P`. -/// -/// # Usage -/// -/// Use `Parameters` as a parameter in your tool or prompt handler functions: -/// -/// ```rust -/// # use rmcp::handler::server::wrapper::Parameters; -/// # use schemars::JsonSchema; -/// # use serde::{Deserialize, Serialize}; -/// #[derive(Deserialize, JsonSchema)] -/// struct CalculationRequest { -/// operation: String, -/// a: f64, -/// b: f64, -/// } -/// -/// // In a tool handler -/// async fn calculate(params: Parameters) -> Result { -/// let request = params.0; // Extract the inner value -/// match request.operation.as_str() { -/// "add" => Ok((request.a + request.b).to_string()), -/// _ => Err("Unknown operation".to_string()), -/// } -/// } -/// ``` -/// -/// The framework handles the extraction automatically: -/// - For tools: Parses the `arguments` field from tool call requests -/// - For prompts: Parses the `arguments` field from prompt requests -/// - Returns appropriate error responses if deserialization fails -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(transparent)] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct Parameters

(pub P); - -impl JsonSchema for Parameters

{ - fn schema_name() -> std::borrow::Cow<'static, str> { - P::schema_name() - } - - fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { - P::json_schema(generator) - } -} diff --git a/vendor/rmcp/src/lib.rs b/vendor/rmcp/src/lib.rs deleted file mode 100644 index 3be6616ed..000000000 --- a/vendor/rmcp/src/lib.rs +++ /dev/null @@ -1,44 +0,0 @@ -#![cfg_attr(docsrs, feature(doc_cfg))] -#![cfg_attr(docsrs, allow(unused_attributes))] -#![doc = include_str!("../README.md")] - -mod error; -pub use error::{ErrorData, RmcpError}; - -/// Basic data types in MCP specification -pub mod model; -#[cfg(any(feature = "client", feature = "server"))] -pub mod service; -#[cfg(feature = "client")] -pub use handler::client::ClientHandler; -#[cfg(feature = "server")] -pub use handler::server::ServerHandler; -#[cfg(feature = "server")] -pub use handler::server::wrapper::Json; -#[cfg(feature = "client")] -pub use service::{ - ClientCacheConfig, ClientLifecycleMode, ClientServiceExt, MAX_CLIENT_CACHE_TTL, RoleClient, - select_protocol_version, serve_client, serve_client_with_lifecycle, -}; -#[cfg(any(feature = "client", feature = "server"))] -pub use service::{Peer, Service, ServiceError, ServiceExt}; -#[cfg(feature = "server")] -pub use service::{RoleServer, serve_server}; - -pub mod handler; -#[cfg(feature = "server")] -pub mod task_manager; -#[cfg(any(feature = "client", feature = "server"))] -pub mod transport; - -// re-export -#[cfg(all(feature = "macros", feature = "server"))] -pub use pastey::paste; -#[cfg(all(feature = "macros", feature = "server"))] -pub use rmcp_macros::*; -#[cfg(feature = "schemars")] -pub use schemars; -#[cfg(feature = "macros")] -pub use serde; -#[cfg(feature = "macros")] -pub use serde_json; diff --git a/vendor/rmcp/src/model.rs b/vendor/rmcp/src/model.rs deleted file mode 100644 index cf08d7fbb..000000000 --- a/vendor/rmcp/src/model.rs +++ /dev/null @@ -1,5326 +0,0 @@ -// Internal references to the SEP-2577-deprecated Roots/Sampling/Logging types -// defined in this module are expected; the deprecation is advisory for downstream users. -#![expect(deprecated)] -use std::{ - borrow::Cow, - collections::hash_map::RandomState, - hash::{BuildHasher, Hasher}, - ops::{Deref, DerefMut}, - sync::{Arc, OnceLock}, -}; -mod annotated; -mod capabilities; -mod content; -mod elicitation_schema; -mod extension; -mod meta; -mod mrtr; -mod prompt; -#[cfg(feature = "request-state")] -mod request_state; -mod resource; -mod serde_impl; -mod task; -mod tool; -pub use annotated::*; -pub use capabilities::*; -pub use content::*; -pub use elicitation_schema::*; -pub use extension::*; -pub use meta::*; -pub use mrtr::*; -pub use prompt::*; -#[cfg(feature = "request-state")] -pub use request_state::*; -pub use resource::*; -use serde::{Deserialize, Serialize, de::DeserializeOwned}; -use serde_json::Value; -pub use task::*; -pub use tool::*; - -/// A JSON object type alias for convenient handling of JSON data. -/// -/// You can use [`crate::object!`] or [`crate::model::object`] to create a json object quickly. -/// This is commonly used for storing arbitrary JSON data in MCP messages. -pub type JsonObject = serde_json::Map; - -/// unwrap the JsonObject under [`serde_json::Value`] -/// -/// # Panic -/// This will panic when the value is not a object in debug mode. -pub fn object(value: serde_json::Value) -> JsonObject { - debug_assert!(value.is_object()); - match value { - serde_json::Value::Object(map) => map, - _ => JsonObject::default(), - } -} - -/// Use this macro just like [`serde_json::json!`] -#[cfg(feature = "macros")] -#[macro_export] -macro_rules! object { - ({$($tt:tt)*}) => { - $crate::model::object(serde_json::json! { - {$($tt)*} - }) - }; -} - -/// This is commonly used for representing empty objects in MCP messages. -/// -/// without returning any specific data. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Copy, Eq)] -#[serde(deny_unknown_fields)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct EmptyObject {} - -pub trait ConstString: Default { - const VALUE: &str; - fn as_str(&self) -> &'static str { - Self::VALUE - } -} -#[macro_export] -macro_rules! const_string { - ($name:ident = $value:literal) => { - #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] - #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] - pub struct $name; - - impl ConstString for $name { - const VALUE: &str = $value; - } - - impl serde::Serialize for $name { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - $value.serialize(serializer) - } - } - - impl<'de> serde::Deserialize<'de> for $name { - fn deserialize(deserializer: D) -> Result<$name, D::Error> - where - D: serde::Deserializer<'de>, - { - let s: String = serde::Deserialize::deserialize(deserializer)?; - if s == $value { - Ok($name) - } else { - Err(serde::de::Error::custom(format!(concat!( - "expect const string value \"", - $value, - "\"" - )))) - } - } - } - - #[cfg(feature = "schemars")] - impl schemars::JsonSchema for $name { - fn schema_name() -> Cow<'static, str> { - Cow::Borrowed(stringify!($name)) - } - - fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { - use serde_json::{Map, json}; - - let mut schema_map = Map::new(); - schema_map.insert("type".to_string(), json!("string")); - schema_map.insert("format".to_string(), json!("const")); - schema_map.insert("const".to_string(), json!($value)); - - schemars::Schema::from(schema_map) - } - } - }; -} - -const_string!(JsonRpcVersion2_0 = "2.0"); - -// ============================================================================= -// CORE PROTOCOL TYPES -// ============================================================================= - -/// Represents the MCP protocol version used for communication. -/// -/// This ensures compatibility between clients and servers by specifying -/// which version of the Model Context Protocol is being used. -#[derive(Debug, Clone, Eq, PartialEq, Hash, PartialOrd)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct ProtocolVersion(Cow<'static, str>); - -impl Default for ProtocolVersion { - fn default() -> Self { - Self::LATEST - } -} - -impl std::fmt::Display for ProtocolVersion { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) - } -} - -impl ProtocolVersion { - pub const V_2026_07_28: Self = Self(Cow::Borrowed("2026-07-28")); - pub const V_2025_11_25: Self = Self(Cow::Borrowed("2025-11-25")); - pub const V_2025_06_18: Self = Self(Cow::Borrowed("2025-06-18")); - pub const V_2025_03_26: Self = Self(Cow::Borrowed("2025-03-26")); - pub const V_2024_11_05: Self = Self(Cow::Borrowed("2024-11-05")); - pub const LATEST: Self = Self::V_2025_11_25; - - /// First protocol version that requires SEP-2243 standard HTTP headers. - pub const STANDARD_HEADERS: Self = Self::V_2026_07_28; - - /// All protocol versions known to this SDK. - pub const KNOWN_VERSIONS: &[Self] = &[ - Self::V_2024_11_05, - Self::V_2025_03_26, - Self::V_2025_06_18, - Self::V_2025_11_25, - Self::V_2026_07_28, - ]; - - /// Returns the string representation of this protocol version. - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl Serialize for ProtocolVersion { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - self.0.serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for ProtocolVersion { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let s: String = Deserialize::deserialize(deserializer)?; - #[allow(clippy::single_match)] - match s.as_str() { - "2024-11-05" => return Ok(ProtocolVersion::V_2024_11_05), - "2025-03-26" => return Ok(ProtocolVersion::V_2025_03_26), - "2025-06-18" => return Ok(ProtocolVersion::V_2025_06_18), - "2025-11-25" => return Ok(ProtocolVersion::V_2025_11_25), - "2026-07-28" => return Ok(ProtocolVersion::V_2026_07_28), - _ => {} - } - Ok(ProtocolVersion(Cow::Owned(s))) - } -} - -/// A flexible identifier type that can be either a number or a string. -/// -/// This is commonly used for request IDs and other identifiers in JSON-RPC -/// where the specification allows both numeric and string values. -#[derive(Debug, Clone, Eq, PartialEq, Hash)] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] -pub enum NumberOrString { - /// A numeric identifier - Number(i64), - /// A string identifier - String(Arc), -} - -impl NumberOrString { - pub fn into_json_value(self) -> Value { - match self { - NumberOrString::Number(n) => Value::Number(serde_json::Number::from(n)), - NumberOrString::String(s) => Value::String(s.to_string()), - } - } - - pub(crate) fn numeric_string_value(&self) -> Option { - match self { - Self::String(id) => id.parse().ok(), - Self::Number(_) => None, - } - } - - pub(crate) fn matches_response_id(&self, response_id: &Self) -> bool { - self == response_id - || matches!( - self, - Self::Number(request_id) - if response_id.numeric_string_value() == Some(*request_id) - ) - } -} - -impl std::fmt::Display for NumberOrString { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - NumberOrString::Number(n) => n.fmt(f), - NumberOrString::String(s) => s.fmt(f), - } - } -} - -impl Serialize for NumberOrString { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - match self { - NumberOrString::Number(n) => n.serialize(serializer), - NumberOrString::String(s) => s.serialize(serializer), - } - } -} - -impl<'de> Deserialize<'de> for NumberOrString { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let value: Value = Deserialize::deserialize(deserializer)?; - match value { - Value::Number(n) => { - if let Some(i) = n.as_i64() { - Ok(NumberOrString::Number(i)) - } else if let Some(u) = n.as_u64() { - // Handle large unsigned numbers that fit in i64 - if u <= i64::MAX as u64 { - Ok(NumberOrString::Number(u as i64)) - } else { - Err(serde::de::Error::custom("Number too large for i64")) - } - } else { - Err(serde::de::Error::custom("Expected an integer")) - } - } - Value::String(s) => Ok(NumberOrString::String(s.into())), - _ => Err(serde::de::Error::custom("Expect number or string")), - } - } -} - -#[cfg(feature = "schemars")] -impl schemars::JsonSchema for NumberOrString { - fn schema_name() -> Cow<'static, str> { - Cow::Borrowed("NumberOrString") - } - - fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { - use serde_json::{Map, json}; - - let mut number_schema = Map::new(); - number_schema.insert("type".to_string(), json!("number")); - - let mut string_schema = Map::new(); - string_schema.insert("type".to_string(), json!("string")); - - let mut schema_map = Map::new(); - schema_map.insert("oneOf".to_string(), json!([number_schema, string_schema])); - - schemars::Schema::from(schema_map) - } -} - -/// Type alias for request identifiers used in JSON-RPC communication. -pub type RequestId = NumberOrString; - -/// A token used to track the progress of long-running operations. -/// -/// Progress tokens allow clients and servers to associate progress notifications -/// with specific requests, enabling real-time updates on operation status. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Hash, Eq)] -#[serde(transparent)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct ProgressToken(pub NumberOrString); - -// ============================================================================= -// JSON-RPC MESSAGE STRUCTURES -// ============================================================================= - -/// Represents a JSON-RPC request with method, parameters, and extensions. -/// -/// This is the core structure for all MCP requests, containing: -/// - `method`: The name of the method being called -/// - `params`: The parameters for the method -/// - `extensions`: Additional context data (similar to HTTP headers) -#[derive(Debug, Clone, Default)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct Request { - pub method: M, - pub params: P, - /// extensions will carry anything possible in the context, including the metadata - /// ([`RequestMetaObject`] for requests, [`NotificationMetaObject`] for notifications) - /// - /// this is similar with the Extensions in `http` crate - #[cfg_attr(feature = "schemars", schemars(skip))] - pub extensions: Extensions, -} - -impl Request { - pub fn new(params: P) -> Self { - Self { - method: Default::default(), - params, - extensions: Extensions::default(), - } - } -} - -impl GetExtensions for Request { - fn extensions(&self) -> &Extensions { - &self.extensions - } - fn extensions_mut(&mut self) -> &mut Extensions { - &mut self.extensions - } -} - -#[derive(Debug, Clone, Default)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct RequestOptionalParam { - pub method: M, - // #[serde(skip_serializing_if = "Option::is_none")] - pub params: Option

, - /// extensions will carry anything possible in the context, including the metadata - /// ([`RequestMetaObject`] for requests, [`NotificationMetaObject`] for notifications) - /// - /// this is similar with the Extensions in `http` crate - #[cfg_attr(feature = "schemars", schemars(skip))] - pub extensions: Extensions, -} - -impl RequestOptionalParam { - pub fn with_param(params: P) -> Self { - Self { - method: Default::default(), - params: Some(params), - extensions: Extensions::default(), - } - } -} - -#[derive(Debug, Clone, Default)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct RequestNoParam { - pub method: M, - /// extensions will carry anything possible in the context, including the metadata - /// ([`RequestMetaObject`] for requests, [`NotificationMetaObject`] for notifications) - /// - /// this is similar with the Extensions in `http` crate - #[cfg_attr(feature = "schemars", schemars(skip))] - pub extensions: Extensions, -} - -impl GetExtensions for RequestNoParam { - fn extensions(&self) -> &Extensions { - &self.extensions - } - fn extensions_mut(&mut self) -> &mut Extensions { - &mut self.extensions - } -} -#[derive(Debug, Clone, Default)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct Notification { - pub method: M, - pub params: P, - /// extensions will carry anything possible in the context, including the metadata - /// ([`RequestMetaObject`] for requests, [`NotificationMetaObject`] for notifications) - /// - /// this is similar with the Extensions in `http` crate - #[cfg_attr(feature = "schemars", schemars(skip))] - pub extensions: Extensions, -} - -impl Notification { - pub fn new(params: P) -> Self { - Self { - method: Default::default(), - params, - extensions: Extensions::default(), - } - } -} - -#[derive(Debug, Clone, Default)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct NotificationNoParam { - pub method: M, - /// extensions will carry anything possible in the context, including the metadata - /// ([`RequestMetaObject`] for requests, [`NotificationMetaObject`] for notifications) - /// - /// this is similar with the Extensions in `http` crate - #[cfg_attr(feature = "schemars", schemars(skip))] - pub extensions: Extensions, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct JsonRpcRequest { - pub jsonrpc: JsonRpcVersion2_0, - pub id: RequestId, - #[serde(flatten)] - pub request: R, -} - -impl JsonRpcRequest { - /// Create a new JsonRpcRequest. - pub fn new(id: RequestId, request: R) -> Self { - Self { - jsonrpc: JsonRpcVersion2_0, - id, - request, - } - } -} - -type DefaultResponse = JsonObject; -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct JsonRpcResponse { - pub jsonrpc: JsonRpcVersion2_0, - pub id: RequestId, - pub result: R, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct JsonRpcError { - pub jsonrpc: JsonRpcVersion2_0, - // MCP 2026-07-28 §Error Responses: `id` is optional and omitted when the - // server cannot read the request id (e.g. parse error / invalid request). - // https://modelcontextprotocol.io/specification/2026-07-28/basic#error-responses - #[serde(default, skip_serializing_if = "Option::is_none")] - pub id: Option, - pub error: ErrorData, -} - -impl JsonRpcError { - /// Create a new JsonRpcError. - pub fn new(id: Option, error: ErrorData) -> Self { - Self { - jsonrpc: JsonRpcVersion2_0, - id, - error, - } - } -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct JsonRpcNotification { - pub jsonrpc: JsonRpcVersion2_0, - #[serde(flatten)] - pub notification: N, -} - -/// Standard JSON-RPC error codes used throughout the MCP protocol. -/// -/// These codes follow the JSON-RPC 2.0 specification and provide -/// standardized error reporting across all MCP implementations. -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(transparent)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct ErrorCode(pub i32); - -impl ErrorCode { - /// The request used a protocol version the server does not support. - pub const UNSUPPORTED_PROTOCOL_VERSION: Self = Self(-32022); - /// Processing the request requires a client capability that was not declared. - pub const MISSING_REQUIRED_CLIENT_CAPABILITY: Self = Self(-32021); - pub const HEADER_MISMATCH: Self = Self(-32020); - pub const RESOURCE_NOT_FOUND: Self = Self(-32002); - pub const INVALID_REQUEST: Self = Self(-32600); - pub const METHOD_NOT_FOUND: Self = Self(-32601); - pub const INVALID_PARAMS: Self = Self(-32602); - pub const INTERNAL_ERROR: Self = Self(-32603); - pub const PARSE_ERROR: Self = Self(-32700); -} - -/// Error information for JSON-RPC error responses. -/// -/// This structure follows the JSON-RPC 2.0 specification for error reporting, -/// providing a standardized way to communicate errors between clients and servers. -#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct ErrorData { - /// The error type that occurred (using standard JSON-RPC error codes) - pub code: ErrorCode, - - /// A short description of the error. The message SHOULD be limited to a concise single sentence. - pub message: Cow<'static, str>, - - /// Additional information about the error. The value of this member is defined by the - /// sender (e.g. detailed error information, nested errors etc.). - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, -} - -impl ErrorData { - const TRANSPORT_CLOSED_MARKER: &str = "io.modelcontextprotocol/transportClosed"; - - pub fn new( - code: ErrorCode, - message: impl Into>, - data: Option, - ) -> Self { - Self { - code, - message: message.into(), - data, - } - } - /// Resource-not-found error (`-32002`). The server upgrades this to `INVALID_PARAMS` - /// (`-32602`) for peers negotiating protocol `2026-07-28` or newer (SEP-2164). - pub fn resource_not_found(message: impl Into>, data: Option) -> Self { - Self::new(ErrorCode::RESOURCE_NOT_FOUND, message, data) - } - pub fn header_mismatch(message: impl Into>, data: Option) -> Self { - Self::new(ErrorCode::HEADER_MISMATCH, message, data) - } - /// Create an unsupported-protocol-version error. - pub fn unsupported_protocol_version( - requested: ProtocolVersion, - supported: &[ProtocolVersion], - ) -> Self { - Self::new( - ErrorCode::UNSUPPORTED_PROTOCOL_VERSION, - "Unsupported protocol version", - Some(serde_json::json!({ - "requested": requested, - "supported": supported, - })), - ) - } - /// Create a missing-required-capability error. - pub fn missing_required_client_capability(required: ClientCapabilities) -> Self { - Self::new( - ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY, - "Missing required client capability", - Some(serde_json::json!({ - "requiredCapabilities": required, - })), - ) - } - pub fn parse_error(message: impl Into>, data: Option) -> Self { - Self::new(ErrorCode::PARSE_ERROR, message, data) - } - pub fn invalid_request(message: impl Into>, data: Option) -> Self { - Self::new(ErrorCode::INVALID_REQUEST, message, data) - } - pub fn method_not_found() -> Self { - Self::new(ErrorCode::METHOD_NOT_FOUND, M::VALUE, None) - } - pub fn invalid_params(message: impl Into>, data: Option) -> Self { - Self::new(ErrorCode::INVALID_PARAMS, message, data) - } - pub fn internal_error(message: impl Into>, data: Option) -> Self { - Self::new(ErrorCode::INTERNAL_ERROR, message, data) - } - - #[cfg(feature = "transport-streamable-http-client")] - pub(crate) fn transport_closed(message: impl Into>) -> Self { - let mut data = JsonObject::new(); - data.insert( - Self::TRANSPORT_CLOSED_MARKER.to_owned(), - Value::from(Self::transport_closed_token()), - ); - Self::internal_error(message, Some(Value::Object(data))) - } - - pub(crate) fn is_transport_closed(&self) -> bool { - self.data - .as_ref() - .and_then(|data| data.get(Self::TRANSPORT_CLOSED_MARKER)) - .and_then(Value::as_u64) - == Some(Self::transport_closed_token()) - } - - fn transport_closed_token() -> u64 { - static TOKEN: OnceLock = OnceLock::new(); - *TOKEN.get_or_init(|| { - let mut hasher = RandomState::new().build_hasher(); - hasher.write(b"rmcp transport-closed marker"); - hasher.finish() - }) - } -} - -/// Represents any JSON-RPC message that can be sent or received. -/// -/// This enum covers all possible message types in the JSON-RPC protocol: -/// individual requests/responses, notifications, and errors. -/// It serves as the top-level message container for MCP communication. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(untagged)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] -pub enum JsonRpcMessage { - /// A single request expecting a response - Request(JsonRpcRequest), - /// A response to a previous request - Response(JsonRpcResponse), - /// A one-way notification (no response expected) - Notification(JsonRpcNotification), - /// An error response - Error(JsonRpcError), -} - -impl JsonRpcMessage { - #[inline] - pub const fn request(request: Req, id: RequestId) -> Self { - JsonRpcMessage::Request(JsonRpcRequest { - jsonrpc: JsonRpcVersion2_0, - id, - request, - }) - } - #[inline] - pub const fn response(response: Resp, id: RequestId) -> Self { - JsonRpcMessage::Response(JsonRpcResponse { - jsonrpc: JsonRpcVersion2_0, - id, - result: response, - }) - } - #[inline] - pub const fn error(error: ErrorData, id: Option) -> Self { - JsonRpcMessage::Error(JsonRpcError { - jsonrpc: JsonRpcVersion2_0, - id, - error, - }) - } - #[inline] - pub const fn notification(notification: Not) -> Self { - JsonRpcMessage::Notification(JsonRpcNotification { - jsonrpc: JsonRpcVersion2_0, - notification, - }) - } - pub fn into_request(self) -> Option<(Req, RequestId)> { - match self { - JsonRpcMessage::Request(r) => Some((r.request, r.id)), - _ => None, - } - } - pub fn into_response(self) -> Option<(Resp, RequestId)> { - match self { - JsonRpcMessage::Response(r) => Some((r.result, r.id)), - _ => None, - } - } - pub fn into_notification(self) -> Option { - match self { - JsonRpcMessage::Notification(n) => Some(n.notification), - _ => None, - } - } - pub fn into_error(self) -> Option<(ErrorData, Option)> { - match self { - JsonRpcMessage::Error(e) => Some((e.error, e.id)), - _ => None, - } - } - pub fn into_result(self) -> Option<(Result, Option)> { - match self { - JsonRpcMessage::Response(r) => Some((Ok(r.result), Some(r.id))), - JsonRpcMessage::Error(e) => Some((Err(e.error), e.id)), - - _ => None, - } - } -} - -// ============================================================================= -// INITIALIZATION AND CONNECTION SETUP -// ============================================================================= - -/// # Empty result -/// A response that indicates success but carries no data. -pub type EmptyResult = EmptyObject; - -impl From<()> for EmptyResult { - fn from(_value: ()) -> Self { - EmptyResult {} - } -} - -impl From for () { - fn from(_value: EmptyResult) {} -} - -/// Indicates the type of a result object, allowing the client to -/// determine how to parse the response. -/// -/// The spec defines this as an open string (`"complete" | "input_required" | string`), -/// so unknown values are preserved rather than rejected. Servers implementing this -/// protocol version MUST include `resultType` in every result. For backward -/// compatibility, clients MUST treat an absent field as `"complete"`. -/// -/// Ordinary results model the field as `Option`: `None` means the -/// field is absent on the wire. Constructors default to `Some(COMPLETE)`, and -/// the server handler strips the `"complete"` discriminator before responding -/// to peers that negotiated a protocol version older than `2026-07-28`, so -/// legacy sessions keep their historical wire shape (see -/// [`ServerResult::strip_result_type_for_legacy_peer`]). -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct ResultType(Cow<'static, str>); - -impl ResultType { - pub const COMPLETE: Self = Self(Cow::Borrowed("complete")); - pub const INPUT_REQUIRED: Self = Self(Cow::Borrowed("input_required")); - /// SEP-2663 Tasks extension: the result is a task handle ([`CreateTaskResult`]). - pub const TASK: Self = Self(Cow::Borrowed("task")); - - pub fn as_str(&self) -> &str { - &self.0 - } - - /// Returns `true` if this is `"input_required"`. - pub fn is_input_required(&self) -> bool { - self.0 == "input_required" - } - - /// Returns `true` if this is `"complete"`. - pub fn is_complete(&self) -> bool { - self.0 == "complete" - } - - /// Returns `true` if this is `"task"` (SEP-2663 Tasks extension). - pub fn is_task(&self) -> bool { - self.0 == "task" - } -} - -impl Default for ResultType { - fn default() -> Self { - Self::COMPLETE - } -} - -impl Serialize for ResultType { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - self.0.serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for ResultType { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let s: String = Deserialize::deserialize(deserializer)?; - match s.as_str() { - "complete" => Ok(Self::COMPLETE), - "input_required" => Ok(Self::INPUT_REQUIRED), - _ => Ok(Self(Cow::Owned(s))), - } - } -} - -impl std::fmt::Display for ResultType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) - } -} - -/// A catch-all response either side can use for custom requests. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(transparent)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct CustomResult(pub Value); - -impl CustomResult { - pub fn new(result: Value) -> Self { - Self(result) - } - - /// Deserialize the result into a strongly-typed structure. - pub fn result_as(&self) -> Result { - serde_json::from_value(self.0.clone()) - } -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct CancelledNotificationParam { - #[serde(skip_serializing_if = "Option::is_none")] - pub request_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option, - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -impl CancelledNotificationParam { - pub fn new(request_id: Option, reason: Option) -> Self { - Self { - request_id, - reason, - meta: None, - } - } -} - -const_string!(CancelledNotificationMethod = "notifications/cancelled"); - -/// # Cancellation -/// This notification can be sent by either side to indicate that it is cancelling a previously-issued request. -/// -/// The request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished. -/// -/// This notification indicates that the result will be unused, so any associated processing SHOULD cease. -/// -/// A client MUST NOT attempt to cancel its `initialize` request. -pub type CancelledNotification = - Notification; - -/// A catch-all notification either side can use to send custom messages to its peer. -/// -/// This preserves the raw `method` name and `params` payload so handlers can -/// deserialize them into domain-specific types. -#[derive(Debug, Clone)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct CustomNotification { - pub method: String, - pub params: Option, - /// extensions will carry anything possible in the context, including the metadata - /// ([`RequestMetaObject`] for requests, [`NotificationMetaObject`] for notifications) - /// - /// this is similar with the Extensions in `http` crate - #[cfg_attr(feature = "schemars", schemars(skip))] - pub extensions: Extensions, -} - -impl CustomNotification { - pub fn new(method: impl Into, params: Option) -> Self { - Self { - method: method.into(), - params, - extensions: Extensions::default(), - } - } - - /// Deserialize `params` into a strongly-typed structure. - pub fn params_as(&self) -> Result, serde_json::Error> { - self.params - .as_ref() - .map(|params| serde_json::from_value(params.clone())) - .transpose() - } -} - -/// A catch-all request either side can use to send custom messages to its peer. -/// -/// This preserves the raw `method` name and `params` payload so handlers can -/// deserialize them into domain-specific types. -#[derive(Debug, Clone)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct CustomRequest { - pub method: String, - pub params: Option, - /// extensions will carry anything possible in the context, including the metadata - /// ([`RequestMetaObject`] for requests, [`NotificationMetaObject`] for notifications) - /// - /// this is similar with the Extensions in `http` crate - #[cfg_attr(feature = "schemars", schemars(skip))] - pub extensions: Extensions, -} - -impl CustomRequest { - pub fn new(method: impl Into, params: Option) -> Self { - Self { - method: method.into(), - params, - extensions: Extensions::default(), - } - } - - /// Deserialize `params` into a strongly-typed structure. - pub fn params_as(&self) -> Result, serde_json::Error> { - self.params - .as_ref() - .map(|params| serde_json::from_value(params.clone())) - .transpose() - } -} - -const_string!(InitializeResultMethod = "initialize"); -/// # Initialization -/// This request is sent from the client to the server when it first connects, asking it to begin initialization. -pub type InitializeRequest = Request; - -const_string!(InitializedNotificationMethod = "notifications/initialized"); -/// This notification is sent from the client to the server after initialization has finished. -pub type InitializedNotification = NotificationNoParam; - -/// Parameters sent by a client when initializing a connection to an MCP server. -/// -/// This contains the client's protocol version, capabilities, and implementation -/// information, allowing the server to understand what the client supports. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct InitializeRequestParams { - /// Protocol-level metadata for this request (SEP-1319) - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, - /// The MCP protocol version this client supports - pub protocol_version: ProtocolVersion, - /// The capabilities this client supports (sampling, roots, etc.) - pub capabilities: ClientCapabilities, - /// Information about the client implementation - pub client_info: Implementation, -} - -impl InitializeRequestParams { - /// Create a new InitializeRequestParams. - pub fn new(capabilities: ClientCapabilities, client_info: Implementation) -> Self { - Self { - meta: None, - protocol_version: ProtocolVersion::default(), - capabilities, - client_info, - } - } - - pub fn with_protocol_version(mut self, protocol_version: ProtocolVersion) -> Self { - self.protocol_version = protocol_version; - self - } -} - -impl RequestParamsMeta for InitializeRequestParams { - fn meta(&self) -> Option<&RequestMetaObject> { - self.meta.as_ref() - } - fn meta_mut(&mut self) -> &mut Option { - &mut self.meta - } -} - -/// The server's response to an initialization request. -/// -/// Contains the server's protocol version, capabilities, and implementation -/// information, along with optional instructions for the client. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct InitializeResult { - /// The MCP protocol version this server supports - pub protocol_version: ProtocolVersion, - /// The capabilities this server provides (tools, resources, prompts, etc.) - pub capabilities: ServerCapabilities, - /// Information about the server implementation - pub server_info: Implementation, - /// Optional human-readable instructions about using this server - #[serde(skip_serializing_if = "Option::is_none")] - pub instructions: Option, - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -impl InitializeResult { - /// Create a new `InitializeResult` with default protocol version and the given capabilities. - pub fn new(capabilities: ServerCapabilities) -> Self { - Self { - protocol_version: ProtocolVersion::default(), - capabilities, - server_info: Implementation::from_build_env(), - instructions: None, - meta: None, - } - } - - /// Set instructions on this result. - pub fn with_instructions(mut self, instructions: impl Into) -> Self { - self.instructions = Some(instructions.into()); - self - } - - /// Set the server info on this result. - pub fn with_server_info(mut self, server_info: Implementation) -> Self { - self.server_info = server_info; - self - } - - /// Set the protocol version on this result. - pub fn with_protocol_version(mut self, protocol_version: ProtocolVersion) -> Self { - self.protocol_version = protocol_version; - self - } -} - -pub type ServerInfo = InitializeResult; -pub type ClientInfo = InitializeRequestParams; - -/// Information negotiated about a server peer. -/// -/// Unlike [`InitializeResult`], the server implementation identity is optional -/// because discovery responses are not required to provide it. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct ServerPeerInfo { - /// The negotiated MCP protocol version. - pub protocol_version: ProtocolVersion, - /// The capabilities this server provides. - pub capabilities: ServerCapabilities, - /// Information about the server implementation, when provided. - #[serde(skip_serializing_if = "Option::is_none")] - pub server_info: Option, - /// Optional human-readable instructions about using this server. - #[serde(skip_serializing_if = "Option::is_none")] - pub instructions: Option, - /// Protocol-level response metadata. - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -impl ServerPeerInfo { - /// Create peer information without a server implementation identity. - pub fn new(protocol_version: ProtocolVersion, capabilities: ServerCapabilities) -> Self { - Self { - protocol_version, - capabilities, - server_info: None, - instructions: None, - meta: None, - } - } - - /// Set the server implementation identity. - pub fn with_server_info(mut self, server_info: Implementation) -> Self { - self.server_info = Some(server_info); - self - } - - /// Set instructions supplied by the server. - pub fn with_instructions(mut self, instructions: impl Into) -> Self { - self.instructions = Some(instructions.into()); - self - } -} - -impl From for ServerPeerInfo { - fn from(result: InitializeResult) -> Self { - Self { - protocol_version: result.protocol_version, - capabilities: result.capabilities, - server_info: Some(result.server_info), - instructions: result.instructions, - meta: result.meta, - } - } -} - -const_string!(DiscoverRequestMethod = "server/discover"); - -/// Parameters for [`DiscoverRequest`]. -#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)] -#[serde(deny_unknown_fields)] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct DiscoverRequestParams {} - -#[cfg(feature = "schemars")] -#[derive(schemars::JsonSchema)] -#[expect(dead_code, reason = "schema-only representation of request parameters")] -struct DiscoverRequestParamsSchema { - #[schemars(rename = "_meta")] - meta: RequestMetaObject, -} - -#[cfg(feature = "schemars")] -impl schemars::JsonSchema for DiscoverRequestParams { - fn schema_name() -> Cow<'static, str> { - Cow::Borrowed("DiscoverRequestParams") - } - - fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { - DiscoverRequestParamsSchema::json_schema(generator) - } -} - -/// A request for the server's supported protocol versions and capabilities. -pub type DiscoverRequest = Request; - -/// The server's response to a [`DiscoverRequest`]. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct DiscoverResult { - /// Identifies how the result should be parsed. - pub result_type: ResultType, - /// Protocol versions implemented by this server. - pub supported_versions: Vec, - /// Capabilities provided by this server. - pub capabilities: ServerCapabilities, - /// Optional guidance for using the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub instructions: Option, - /// How long clients may consider this response fresh, in milliseconds. - pub ttl_ms: u64, - /// Whether the cached result may be shared across authorization contexts. - pub cache_scope: CacheScope, - /// Protocol-level response metadata. - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -const SERVER_INFO_META_KEY: &str = "io.modelcontextprotocol/serverInfo"; - -fn server_info_from_meta(meta: &MetaObject) -> Option { - meta.get(SERVER_INFO_META_KEY) - .and_then(|value| serde_json::from_value(value.clone()).ok()) -} - -fn set_server_info_on_meta(meta: &mut MetaObject, server_info: Implementation) { - let server_info = - serde_json::to_value(server_info).expect("Implementation serialization cannot fail"); - meta.insert(SERVER_INFO_META_KEY.to_owned(), server_info); -} - -impl DiscoverResult { - /// Create a non-cacheable private discovery result. - pub fn new(supported_versions: Vec, capabilities: ServerCapabilities) -> Self { - Self { - result_type: ResultType::COMPLETE, - supported_versions, - capabilities, - instructions: None, - ttl_ms: 0, - cache_scope: CacheScope::Private, - meta: None, - } - } - - /// Return the server implementation information stored in result metadata. - pub fn server_info(&self) -> Option { - server_info_from_meta(self.meta.as_ref()?) - } - - /// Store server implementation information in result metadata. - pub fn set_server_info(&mut self, server_info: Implementation) { - set_server_info_on_meta(self.meta.get_or_insert_default(), server_info); - } - - /// Store server implementation information in result metadata. - pub fn with_server_info(mut self, server_info: Implementation) -> Self { - self.set_server_info(server_info); - self - } - - /// Create a discovery result from the server's initialization information. - pub fn from_server_info( - supported_versions: Vec, - server_info: ServerInfo, - ) -> Self { - let ServerInfo { - capabilities, - server_info, - instructions, - meta, - .. - } = server_info; - let mut result = Self { - result_type: ResultType::COMPLETE, - supported_versions, - capabilities, - instructions, - ttl_ms: 0, - cache_scope: CacheScope::Private, - meta, - }; - result.set_server_info(server_info); - result - } - - /// Set the cache lifetime hint in milliseconds. - pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self { - self.ttl_ms = ttl_ms; - self - } - - /// Set the cache scope. - pub fn with_cache_scope(mut self, cache_scope: CacheScope) -> Self { - self.cache_scope = cache_scope; - self - } -} - -impl ServerPeerInfo { - /// Create peer information from a discovery result and the selected version. - pub fn from_discover_result(protocol_version: ProtocolVersion, result: DiscoverResult) -> Self { - let server_info = result.server_info(); - Self { - protocol_version, - capabilities: result.capabilities, - server_info, - instructions: result.instructions, - meta: result.meta, - } - } -} - -#[allow(clippy::derivable_impls)] -impl Default for ServerInfo { - fn default() -> Self { - ServerInfo { - protocol_version: ProtocolVersion::default(), - capabilities: ServerCapabilities::default(), - server_info: Implementation::from_build_env(), - instructions: None, - meta: None, - } - } -} - -#[allow(clippy::derivable_impls)] -impl Default for ClientInfo { - fn default() -> Self { - ClientInfo { - meta: None, - protocol_version: ProtocolVersion::default(), - capabilities: ClientCapabilities::default(), - client_info: Implementation::from_build_env(), - } - } -} - -/// Icon themes supported by the MCP specification -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash, Copy)] -#[serde(rename_all = "lowercase")] //match spec -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub enum IconTheme { - /// Indicates the icon is designed to be used with a light background - Light, - /// Indicates the icon is designed to be used with a dark background - Dark, -} - -/// A URL pointing to an icon resource or a base64-encoded data URI. -/// -/// Clients that support rendering icons MUST support at least the following MIME types: -/// - image/png - PNG images (safe, universal compatibility) -/// - image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility) -/// -/// Clients that support rendering icons SHOULD also support: -/// - image/svg+xml - SVG images (scalable but requires security precautions) -/// - image/webp - WebP images (modern, efficient format) -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct Icon { - /// A standard URI pointing to an icon resource - pub src: String, - /// Optional override if the server's MIME type is missing or generic - #[serde(skip_serializing_if = "Option::is_none")] - pub mime_type: Option, - /// Size specification, each string should be in WxH format (e.g., `\"48x48\"`, `\"96x96\"`) or `\"any\"` for scalable formats like SVG - #[serde(skip_serializing_if = "Option::is_none")] - pub sizes: Option>, - /// Optional specifier for the theme this icon is designed for - /// If not provided, the client should assume the icon can be used with any theme. - #[serde(skip_serializing_if = "Option::is_none")] - pub theme: Option, -} - -impl Icon { - /// Create a new Icon with the given source URL. - pub fn new(src: impl Into) -> Self { - Self { - src: src.into(), - mime_type: None, - sizes: None, - theme: None, - } - } - - /// Set the MIME type. - pub fn with_mime_type(mut self, mime_type: impl Into) -> Self { - self.mime_type = Some(mime_type.into()); - self - } - - /// Set the sizes. - pub fn with_sizes(mut self, sizes: Vec) -> Self { - self.sizes = Some(sizes); - self - } - - /// Set the theme. - pub fn with_theme(mut self, theme: IconTheme) -> Self { - self.theme = Some(theme); - self - } -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct Implementation { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option, - pub version: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub icons: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub website_url: Option, -} - -impl Default for Implementation { - fn default() -> Self { - Self::from_build_env() - } -} - -impl Implementation { - /// Create a new Implementation. - pub fn new(name: impl Into, version: impl Into) -> Self { - Self { - name: name.into(), - title: None, - version: version.into(), - description: None, - icons: None, - website_url: None, - } - } - - pub fn from_build_env() -> Self { - Implementation { - name: env!("CARGO_CRATE_NAME").to_owned(), - title: None, - version: env!("CARGO_PKG_VERSION").to_owned(), - description: None, - icons: None, - website_url: None, - } - } - - /// Set the human-readable title. - pub fn with_title(mut self, title: impl Into) -> Self { - self.title = Some(title.into()); - self - } - - /// Set the description. - pub fn with_description(mut self, description: impl Into) -> Self { - self.description = Some(description.into()); - self - } - - /// Set the icons. - pub fn with_icons(mut self, icons: Vec) -> Self { - self.icons = Some(icons); - self - } - - /// Set the website URL. - pub fn with_website_url(mut self, website_url: impl Into) -> Self { - self.website_url = Some(website_url.into()); - self - } -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct PaginatedRequestParams { - /// Protocol-level metadata for this request (SEP-1319) - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cursor: Option, -} - -impl PaginatedRequestParams { - pub fn with_cursor(mut self, cursor: Option) -> Self { - self.cursor = cursor; - self - } -} - -impl RequestParamsMeta for PaginatedRequestParams { - fn meta(&self) -> Option<&RequestMetaObject> { - self.meta.as_ref() - } - fn meta_mut(&mut self) -> &mut Option { - &mut self.meta - } -} - -// ============================================================================= -// PROGRESS AND PAGINATION -// ============================================================================= - -const_string!(PingRequestMethod = "ping"); -pub type PingRequest = RequestNoParam; - -const_string!(ProgressNotificationMethod = "notifications/progress"); -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ProgressNotificationParam { - pub progress_token: ProgressToken, - /// The progress thus far. This should increase every time progress is made, even if the total is unknown. - pub progress: f64, - /// Total number of items to process (or total progress required), if known - #[serde(skip_serializing_if = "Option::is_none")] - pub total: Option, - /// An optional message describing the current progress. - #[serde(skip_serializing_if = "Option::is_none")] - pub message: Option, - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -impl ProgressNotificationParam { - /// Create a new ProgressNotificationParam with required fields. - pub fn new(progress_token: ProgressToken, progress: f64) -> Self { - Self { - progress_token, - progress, - total: None, - message: None, - meta: None, - } - } - - /// Set the total number of items to process. - pub fn with_total(mut self, total: f64) -> Self { - self.total = Some(total); - self - } - - /// Set a message describing the current progress. - pub fn with_message(mut self, message: impl Into) -> Self { - self.message = Some(message.into()); - self - } -} - -pub type ProgressNotification = Notification; - -pub type Cursor = String; - -/// Scope describing who may cache cacheable list/read results (SEP-2549). -/// -/// Defaults to [`CacheScope::Public`] when absent from the wire. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(rename_all = "lowercase")] -#[non_exhaustive] -pub enum CacheScope { - /// Any client or intermediary may cache and serve the response to any user. - #[default] - Public, - /// Only the requesting user's client may cache the response. - Private, -} - -/// Normalize a `ttlMs` value during deserialization. -/// -/// Per SEP-2549, `ttlMs` MUST be `>= 0`; if a server returns a negative value, -/// clients SHOULD treat it as `0` (immediately stale). This tolerates that case -/// rather than erroring, while still accepting an absent field as `None`. -fn deserialize_ttl_ms<'de, D>(deserializer: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - let value = Option::::deserialize(deserializer)?; - Ok(value.map(|ttl_ms| ttl_ms.max(0) as u64)) -} - -macro_rules! paginated_result { - ($t:ident { - $i_item: ident: $t_item: ty - }) => { - #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] - #[serde(rename_all = "camelCase")] - #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] - #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] - pub struct $t { - /// Result type discriminator (SEP-2322). Required by the [spec schema] - /// for servers implementing protocol version `2026-07-28`, but optional - /// here because this type also models results from older protocol - /// versions, which do not carry the field: `None` means absent on the - /// wire, and per the spec "the client MUST treat the absent field as - /// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`; - /// the server handler clears the field when responding to peers that - /// negotiated an older version. - /// - /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub result_type: Option, - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub next_cursor: Option, - /// Time, in milliseconds, that this result may be treated as fresh (SEP-2549). - /// Required by spec version 2026-07-28, but optional here to maintain compatibility - /// with older spec versions. - #[serde( - default, - deserialize_with = "deserialize_ttl_ms", - skip_serializing_if = "Option::is_none" - )] - pub ttl_ms: Option, - /// Scope describing who may cache this result (SEP-2549). - /// Required by spec version 2026-07-28, but optional here to maintain compatibility - /// with older spec versions. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cache_scope: Option, - pub $i_item: $t_item, - } - - impl Default for $t { - fn default() -> Self { - Self::with_all_items(Default::default()) - } - } - - impl $t { - pub fn with_all_items(items: $t_item) -> Self { - Self { - result_type: Some(ResultType::COMPLETE), - meta: None, - next_cursor: None, - ttl_ms: None, - cache_scope: None, - $i_item: items, - } - } - - /// Set the time, in milliseconds, that this result may be treated as fresh. - pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self { - self.ttl_ms = Some(ttl_ms); - self - } - - /// Set the cache scope for this result. - pub fn with_cache_scope(mut self, cache_scope: CacheScope) -> Self { - self.cache_scope = Some(cache_scope); - self - } - } - }; -} - -// ============================================================================= -// RESOURCE MANAGEMENT -// ============================================================================= - -const_string!(ListResourcesRequestMethod = "resources/list"); -/// Request to list all available resources from a server -pub type ListResourcesRequest = - RequestOptionalParam; - -paginated_result!(ListResourcesResult { - resources: Vec -}); - -const_string!(ListResourceTemplatesRequestMethod = "resources/templates/list"); -/// Request to list all available resource templates from a server -pub type ListResourceTemplatesRequest = - RequestOptionalParam; - -paginated_result!(ListResourceTemplatesResult { - resource_templates: Vec -}); - -const_string!(ReadResourceRequestMethod = "resources/read"); -/// Parameters for reading a specific resource -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ReadResourceRequestParams { - /// Protocol-level metadata for this request (SEP-1319) - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, - /// The URI of the resource to read - pub uri: String, - /// Client responses to server-initiated input requests from a previous - /// [`InputRequiredResult`]. - #[serde(skip_serializing_if = "Option::is_none")] - pub input_responses: Option, - /// Opaque request state echoed back from a previous [`InputRequiredResult`]. - #[serde(skip_serializing_if = "Option::is_none")] - pub request_state: Option, -} - -impl ReadResourceRequestParams { - /// Create a new ReadResourceRequestParams with the given URI. - pub fn new(uri: impl Into) -> Self { - Self { - meta: None, - uri: uri.into(), - input_responses: None, - request_state: None, - } - } - - /// Set the metadata for this request. - pub fn with_meta(mut self, meta: RequestMetaObject) -> Self { - self.meta = Some(meta); - self - } - - /// Sets the input responses for an MRTR retry. - pub fn with_input_responses(mut self, input_responses: InputResponses) -> Self { - self.input_responses = Some(input_responses); - self - } - - /// Sets the request state for an MRTR retry. - pub fn with_request_state(mut self, request_state: impl Into) -> Self { - self.request_state = Some(request_state.into()); - self - } -} - -impl RequestParamsMeta for ReadResourceRequestParams { - fn meta(&self) -> Option<&RequestMetaObject> { - self.meta.as_ref() - } - fn meta_mut(&mut self) -> &mut Option { - &mut self.meta - } -} - -/// Result containing the contents of a read resource -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ReadResourceResult { - /// Result type discriminator (SEP-2322). Required by the [spec schema] - /// for servers implementing protocol version `2026-07-28`, but optional - /// here because this type also models results from older protocol - /// versions, which do not carry the field: `None` means absent on the - /// wire, and per the spec "the client MUST treat the absent field as - /// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`; - /// the server handler clears the field when responding to peers that - /// negotiated an older version. - /// - /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub result_type: Option, - /// Time, in milliseconds, that this result may be treated as fresh (SEP-2549). - /// Required by spec version 2026-07-28, but optional here to maintain compatibility - /// with older spec versions. - #[serde( - default, - deserialize_with = "deserialize_ttl_ms", - skip_serializing_if = "Option::is_none" - )] - pub ttl_ms: Option, - /// Scope describing who may cache this result (SEP-2549). - /// Required by spec version 2026-07-28, but optional here to maintain compatibility - /// with older spec versions. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cache_scope: Option, - /// The actual content of the resource - pub contents: Vec, - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -impl ReadResourceResult { - /// Create a new ReadResourceResult with the given contents. - pub fn new(contents: Vec) -> Self { - Self { - result_type: Some(ResultType::COMPLETE), - ttl_ms: None, - cache_scope: None, - contents, - meta: None, - } - } - - /// Set the time, in milliseconds, that this result may be treated as fresh. - pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self { - self.ttl_ms = Some(ttl_ms); - self - } - - /// Set the cache scope for this result. - pub fn with_cache_scope(mut self, cache_scope: CacheScope) -> Self { - self.cache_scope = Some(cache_scope); - self - } -} - -/// Request to read a specific resource -pub type ReadResourceRequest = Request; - -const_string!(ResourceListChangedNotificationMethod = "notifications/resources/list_changed"); -/// Notification sent when the list of available resources changes -pub type ResourceListChangedNotification = - NotificationNoParam; - -const_string!(SubscribeRequestMethod = "resources/subscribe"); -/// Parameters for subscribing to resource updates -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct SubscribeRequestParams { - /// Protocol-level metadata for this request (SEP-1319) - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, - /// The URI of the resource to subscribe to - pub uri: String, -} - -impl SubscribeRequestParams { - /// Create a new SubscribeRequestParams. - pub fn new(uri: impl Into) -> Self { - Self { - meta: None, - uri: uri.into(), - } - } -} - -impl RequestParamsMeta for SubscribeRequestParams { - fn meta(&self) -> Option<&RequestMetaObject> { - self.meta.as_ref() - } - fn meta_mut(&mut self) -> &mut Option { - &mut self.meta - } -} - -/// Request to subscribe to resource updates -#[deprecated( - note = "resources/subscribe is legacy-only; use subscriptions/listen for protocol version 2026-07-28" -)] -pub type SubscribeRequest = Request; - -const_string!(UnsubscribeRequestMethod = "resources/unsubscribe"); -/// Parameters for unsubscribing from resource updates -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct UnsubscribeRequestParams { - /// Protocol-level metadata for this request (SEP-1319) - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, - /// The URI of the resource to unsubscribe from - pub uri: String, -} - -impl UnsubscribeRequestParams { - /// Creates a new `UnsubscribeRequestParams` for the given URI. - pub fn new(uri: impl Into) -> Self { - Self { - meta: None, - uri: uri.into(), - } - } -} - -impl RequestParamsMeta for UnsubscribeRequestParams { - fn meta(&self) -> Option<&RequestMetaObject> { - self.meta.as_ref() - } - fn meta_mut(&mut self) -> &mut Option { - &mut self.meta - } -} - -/// Request to unsubscribe from resource updates -#[deprecated( - note = "resources/unsubscribe is legacy-only; cancel the subscriptions/listen request for protocol version 2026-07-28" -)] -pub type UnsubscribeRequest = Request; - -const_string!(ResourceUpdatedNotificationMethod = "notifications/resources/updated"); -/// Parameters for a resource update notification -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ResourceUpdatedNotificationParam { - /// The URI of the resource that was updated - pub uri: String, - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -impl ResourceUpdatedNotificationParam { - /// Create a new ResourceUpdatedNotificationParam. - pub fn new(uri: impl Into) -> Self { - Self { - uri: uri.into(), - meta: None, - } - } -} - -/// Notification sent when a subscribed resource is updated -pub type ResourceUpdatedNotification = - Notification; - -// ============================================================================= -// SUBSCRIPTIONS -// ============================================================================= - -/// Notification categories a client opts in to on a `subscriptions/listen` stream. -#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct SubscriptionFilter { - #[serde(default, skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "schemars", schemars(with = "bool"))] - pub tools_list_changed: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "schemars", schemars(with = "bool"))] - pub prompts_list_changed: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "schemars", schemars(with = "bool"))] - pub resources_list_changed: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "schemars", schemars(with = "Vec"))] - pub resource_subscriptions: Option>, -} - -impl SubscriptionFilter { - /// Create an empty filter that opts in to no notifications. - pub fn new() -> Self { - Self::default() - } - - /// Create a builder for a subscription filter. - pub fn builder() -> SubscriptionFilterBuilder { - SubscriptionFilterBuilder::default() - } - - /// Return the subset present in both filters. - pub fn intersection(&self, other: &Self) -> Self { - let resource_subscriptions = self - .resource_subscriptions - .as_ref() - .and_then(|requested| { - other.resource_subscriptions.as_ref().map(|accepted| { - requested - .iter() - .filter(|uri| accepted.contains(uri)) - .cloned() - .collect() - }) - }) - .filter(|uris: &Vec| !uris.is_empty()); - Self { - tools_list_changed: (self.tools_list_changed == Some(true) - && other.tools_list_changed == Some(true)) - .then_some(true), - prompts_list_changed: (self.prompts_list_changed == Some(true) - && other.prompts_list_changed == Some(true)) - .then_some(true), - resources_list_changed: (self.resources_list_changed == Some(true) - && other.resources_list_changed == Some(true)) - .then_some(true), - resource_subscriptions, - } - } - - /// Return whether this filter accepts only notifications requested by `other`. - pub fn is_subset_of(&self, other: &Self) -> bool { - let booleans_are_subset = [ - (self.tools_list_changed, other.tools_list_changed), - (self.prompts_list_changed, other.prompts_list_changed), - (self.resources_list_changed, other.resources_list_changed), - ] - .into_iter() - .all(|(accepted, requested)| accepted != Some(true) || requested == Some(true)); - let resources_are_subset = self.resource_subscriptions.as_ref().is_none_or(|accepted| { - accepted.iter().all(|uri| { - other - .resource_subscriptions - .as_ref() - .is_some_and(|requested| requested.contains(uri)) - }) - }); - booleans_are_subset && resources_are_subset - } - - /// Return the requested notification types advertised by server capabilities. - pub fn supported_by(&self, capabilities: &ServerCapabilities) -> Self { - Self { - tools_list_changed: (self.tools_list_changed == Some(true) - && capabilities - .tools - .as_ref() - .is_some_and(|tools| tools.list_changed == Some(true))) - .then_some(true), - prompts_list_changed: (self.prompts_list_changed == Some(true) - && capabilities - .prompts - .as_ref() - .is_some_and(|prompts| prompts.list_changed == Some(true))) - .then_some(true), - resources_list_changed: (self.resources_list_changed == Some(true) - && capabilities - .resources - .as_ref() - .is_some_and(|resources| resources.list_changed == Some(true))) - .then_some(true), - resource_subscriptions: capabilities - .resources - .as_ref() - .is_some_and(|resources| resources.subscribe == Some(true)) - .then(|| self.resource_subscriptions.clone()) - .flatten(), - } - } -} - -/// Builder for [`SubscriptionFilter`]. -#[derive(Debug, Default)] -#[non_exhaustive] -pub struct SubscriptionFilterBuilder { - filter: SubscriptionFilter, -} - -impl SubscriptionFilterBuilder { - /// Opt in to `notifications/tools/list_changed`. - pub fn tools_list_changed(mut self) -> Self { - self.filter.tools_list_changed = Some(true); - self - } - - /// Opt in to `notifications/prompts/list_changed`. - pub fn prompts_list_changed(mut self) -> Self { - self.filter.prompts_list_changed = Some(true); - self - } - - /// Opt in to `notifications/resources/list_changed`. - pub fn resources_list_changed(mut self) -> Self { - self.filter.resources_list_changed = Some(true); - self - } - - /// Opt in to updates for all supplied resource URIs. - pub fn resource_subscriptions( - mut self, - uris: impl IntoIterator>, - ) -> Self { - self.filter.resource_subscriptions = Some(uris.into_iter().map(Into::into).collect()); - self - } - - /// Add one resource URI to the update subscription set. - pub fn resource_subscription(mut self, uri: impl Into) -> Self { - self.filter - .resource_subscriptions - .get_or_insert_default() - .push(uri.into()); - self - } - - /// Build the filter. - pub fn build(self) -> SubscriptionFilter { - self.filter - } -} - -const_string!(SubscriptionsListenRequestMethod = "subscriptions/listen"); - -#[cfg(feature = "schemars")] -fn subscriptions_listen_request_meta_schema( - generator: &mut schemars::SchemaGenerator, -) -> schemars::Schema { - let progress_token = generator.subschema_for::(); - let client_info = generator.subschema_for::(); - let client_capabilities = generator.subschema_for::(); - let log_level = generator.subschema_for::(); - schemars::json_schema!({ - "type": "object", - "properties": { - "progressToken": progress_token, - "io.modelcontextprotocol/protocolVersion": { - "type": "string", - }, - "io.modelcontextprotocol/clientInfo": client_info, - "io.modelcontextprotocol/clientCapabilities": client_capabilities, - "io.modelcontextprotocol/logLevel": log_level, - }, - "required": RequestMetaObject::DRAFT_REQUIRED_KEYS, - "additionalProperties": true, - }) -} - -/// Parameters for opening a long-lived notification subscription. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct SubscriptionsListenRequestParams { - /// Protocol-level metadata. Required by the 2026-07-28 wire schema. - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - #[cfg_attr( - feature = "schemars", - schemars(required, schema_with = "subscriptions_listen_request_meta_schema") - )] - pub meta: Option, - /// Notification categories requested for this stream. - pub notifications: SubscriptionFilter, -} - -impl SubscriptionsListenRequestParams { - /// Create listen parameters for a notification filter. - pub fn new(notifications: SubscriptionFilter) -> Self { - Self { - meta: None, - notifications, - } - } - - /// Set protocol-level request metadata. - pub fn with_meta(mut self, meta: RequestMetaObject) -> Self { - self.meta = Some(meta); - self - } -} - -impl RequestParamsMeta for SubscriptionsListenRequestParams { - fn meta(&self) -> Option<&RequestMetaObject> { - self.meta.as_ref() - } - - fn meta_mut(&mut self) -> &mut Option { - &mut self.meta - } -} - -/// Request that opens a long-lived notification subscription. -pub type SubscriptionsListenRequest = - Request; - -const SUBSCRIPTION_ID_META_KEY: &str = "io.modelcontextprotocol/subscriptionId"; - -/// Metadata on the final result of a `subscriptions/listen` request. -#[derive(Debug, Serialize, Clone, PartialEq)] -#[serde(transparent)] -#[non_exhaustive] -pub struct SubscriptionsListenResultMeta(MetaObject); - -impl SubscriptionsListenResultMeta { - /// Create result metadata for the originating listen request. - pub fn new(subscription_id: RequestId) -> Self { - let mut meta = MetaObject::new(); - meta.insert( - SUBSCRIPTION_ID_META_KEY.to_owned(), - subscription_id.into_json_value(), - ); - Self(meta) - } - - /// Return the originating listen request ID, if the metadata remains valid. - pub fn subscription_id(&self) -> Option { - self.0 - .get(SUBSCRIPTION_ID_META_KEY) - .and_then(|value| RequestId::deserialize(value).ok()) - } - - /// Replace the originating listen request ID. - pub fn set_subscription_id(&mut self, subscription_id: RequestId) { - self.0.insert( - SUBSCRIPTION_ID_META_KEY.to_owned(), - subscription_id.into_json_value(), - ); - } - - /// Return the server implementation information stored in result metadata. - pub fn server_info(&self) -> Option { - server_info_from_meta(&self.0) - } - - /// Store server implementation information in result metadata. - pub fn set_server_info(&mut self, server_info: Implementation) { - set_server_info_on_meta(&mut self.0, server_info); - } -} - -impl<'de> Deserialize<'de> for SubscriptionsListenResultMeta { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let meta = MetaObject::deserialize(deserializer)?; - let Some(value) = meta.get(SUBSCRIPTION_ID_META_KEY) else { - return Err(serde::de::Error::missing_field(SUBSCRIPTION_ID_META_KEY)); - }; - RequestId::deserialize(value).map_err(serde::de::Error::custom)?; - Ok(Self(meta)) - } -} - -impl std::ops::Deref for SubscriptionsListenResultMeta { - type Target = MetaObject; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl std::ops::DerefMut for SubscriptionsListenResultMeta { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -#[cfg(feature = "schemars")] -impl schemars::JsonSchema for SubscriptionsListenResultMeta { - fn schema_name() -> Cow<'static, str> { - Cow::Borrowed("SubscriptionsListenResultMeta") - } - - fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { - let subscription_id = generator.subschema_for::(); - let server_info = generator.subschema_for::(); - schemars::json_schema!({ - "type": "object", - "properties": { - "io.modelcontextprotocol/serverInfo": { - "description": "Identifies the server software producing the response. Servers SHOULD include this field on every response unless specifically configured not to do so.", - "allOf": [server_info], - }, - "io.modelcontextprotocol/subscriptionId": subscription_id, - }, - "required": ["io.modelcontextprotocol/subscriptionId"], - "additionalProperties": true, - }) - } -} - -/// Final response indicating that a subscription ended gracefully. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct SubscriptionsListenResult { - pub result_type: ResultType, - #[serde(rename = "_meta")] - pub meta: SubscriptionsListenResultMeta, -} - -impl SubscriptionsListenResult { - /// Create a completed subscription result. - pub fn new(meta: SubscriptionsListenResultMeta) -> Self { - Self { - result_type: ResultType::COMPLETE, - meta, - } - } - - /// Create a completed result for the originating listen request. - pub fn complete(subscription_id: RequestId) -> Self { - Self::new(SubscriptionsListenResultMeta::new(subscription_id)) - } -} - -const_string!( - SubscriptionsAcknowledgedNotificationMethod = "notifications/subscriptions/acknowledged" -); - -/// Parameters reporting the accepted subset of a subscription filter. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct SubscriptionsAcknowledgedNotificationParams { - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "schemars", schemars(with = "NotificationMetaObject"))] - pub meta: Option, - pub notifications: SubscriptionFilter, -} - -impl SubscriptionsAcknowledgedNotificationParams { - /// Create acknowledgment parameters for the accepted filter. - pub fn new(notifications: SubscriptionFilter) -> Self { - Self { - meta: None, - notifications, - } - } - - /// Set notification metadata. - pub fn with_meta(mut self, meta: NotificationMetaObject) -> Self { - self.meta = Some(meta); - self - } -} - -/// First notification sent on an established subscription stream. -pub type SubscriptionsAcknowledgedNotification = Notification< - SubscriptionsAcknowledgedNotificationMethod, - SubscriptionsAcknowledgedNotificationParams, ->; - -// ============================================================================= -// PROMPT MANAGEMENT -// ============================================================================= - -const_string!(ListPromptsRequestMethod = "prompts/list"); -/// Request to list all available prompts from a server -pub type ListPromptsRequest = - RequestOptionalParam; - -paginated_result!(ListPromptsResult { - prompts: Vec -}); - -const_string!(GetPromptRequestMethod = "prompts/get"); -/// Parameters for retrieving a specific prompt -#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct GetPromptRequestParams { - /// Protocol-level metadata for this request (SEP-1319) - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub arguments: Option, - /// Client responses to server-initiated input requests from a previous - /// [`InputRequiredResult`]. - #[serde(skip_serializing_if = "Option::is_none")] - pub input_responses: Option, - /// Opaque request state echoed back from a previous [`InputRequiredResult`]. - #[serde(skip_serializing_if = "Option::is_none")] - pub request_state: Option, -} - -impl GetPromptRequestParams { - /// Create a new `GetPromptRequestParams` with the given prompt name. - pub fn new(name: impl Into) -> Self { - Self { - meta: None, - name: name.into(), - arguments: None, - input_responses: None, - request_state: None, - } - } - - /// Set the arguments for this prompt request. - pub fn with_arguments(mut self, arguments: JsonObject) -> Self { - self.arguments = Some(arguments); - self - } - - /// Set the metadata for this request. - pub fn with_meta(mut self, meta: RequestMetaObject) -> Self { - self.meta = Some(meta); - self - } - - /// Sets the input responses for an MRTR retry. - pub fn with_input_responses(mut self, input_responses: InputResponses) -> Self { - self.input_responses = Some(input_responses); - self - } - - /// Sets the request state for an MRTR retry. - pub fn with_request_state(mut self, request_state: impl Into) -> Self { - self.request_state = Some(request_state.into()); - self - } -} - -impl RequestParamsMeta for GetPromptRequestParams { - fn meta(&self) -> Option<&RequestMetaObject> { - self.meta.as_ref() - } - fn meta_mut(&mut self) -> &mut Option { - &mut self.meta - } -} - -/// Request to get a specific prompt -pub type GetPromptRequest = Request; - -const_string!(PromptListChangedNotificationMethod = "notifications/prompts/list_changed"); -/// Notification sent when the list of available prompts changes -pub type PromptListChangedNotification = NotificationNoParam; - -const_string!(ToolListChangedNotificationMethod = "notifications/tools/list_changed"); -/// Notification sent when the list of available tools changes -pub type ToolListChangedNotification = NotificationNoParam; - -// ============================================================================= -// LOGGING -// ============================================================================= - -/// Logging levels supported by the MCP protocol -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Copy)] -#[serde(rename_all = "lowercase")] //match spec -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] -#[deprecated( - since = "2.0.0", - note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" -)] -pub enum LoggingLevel { - Debug, - Info, - Notice, - Warning, - Error, - Critical, - Alert, - Emergency, -} - -const_string!(SetLevelRequestMethod = "logging/setLevel"); -/// Parameters for setting the logging level -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -#[deprecated( - since = "2.0.0", - note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" -)] -pub struct SetLevelRequestParams { - /// Protocol-level metadata for this request (SEP-1319) - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, - /// The desired logging level - pub level: LoggingLevel, -} - -impl SetLevelRequestParams { - /// Create a new SetLevelRequestParams with the given logging level. - pub fn new(level: LoggingLevel) -> Self { - Self { meta: None, level } - } -} - -impl RequestParamsMeta for SetLevelRequestParams { - fn meta(&self) -> Option<&RequestMetaObject> { - self.meta.as_ref() - } - fn meta_mut(&mut self) -> &mut Option { - &mut self.meta - } -} - -/// Request to set the logging level -#[deprecated( - since = "2.0.0", - note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" -)] -pub type SetLevelRequest = Request; - -const_string!(LoggingMessageNotificationMethod = "notifications/message"); -/// Parameters for a logging message notification -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -#[deprecated( - since = "2.0.0", - note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" -)] -pub struct LoggingMessageNotificationParam { - /// The severity level of this log message - pub level: LoggingLevel, - /// Optional logger name that generated this message - #[serde(skip_serializing_if = "Option::is_none")] - pub logger: Option, - /// The actual log data - pub data: Value, - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -impl LoggingMessageNotificationParam { - /// Create a new LoggingMessageNotificationParam. - pub fn new(level: LoggingLevel, data: Value) -> Self { - Self { - level, - logger: None, - data, - meta: None, - } - } - - /// Set the logger name. - pub fn with_logger(mut self, logger: impl Into) -> Self { - self.logger = Some(logger.into()); - self - } -} - -/// Notification containing a log message -#[deprecated( - since = "2.0.0", - note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" -)] -pub type LoggingMessageNotification = - Notification; - -// ============================================================================= -// SAMPLING (LLM INTERACTION) -// ============================================================================= - -const_string!(CreateMessageRequestMethod = "sampling/createMessage"); -#[deprecated( - since = "2.0.0", - note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" -)] -pub type CreateMessageRequest = Request; - -/// Represents the role of a participant in a conversation or message exchange. -/// -/// Used in sampling and chat contexts to distinguish between different -/// types of message senders in the conversation flow. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] -pub enum Role { - /// A human user or client making a request - User, - /// An AI assistant or server providing a response - Assistant, -} - -/// Tool selection mode (SEP-1577). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub enum ToolChoiceMode { - /// Model decides whether to use tools - #[default] - Auto, - /// Model must use at least one tool - Required, - /// Model must not use tools - None, -} - -/// Tool choice configuration (SEP-1577). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -#[deprecated( - since = "2.0.0", - note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" -)] -pub struct ToolChoice { - #[serde(skip_serializing_if = "Option::is_none")] - pub mode: Option, -} - -impl ToolChoice { - pub fn auto() -> Self { - Self { - mode: Some(ToolChoiceMode::Auto), - } - } - - pub fn required() -> Self { - Self { - mode: Some(ToolChoiceMode::Required), - } - } - - pub fn none() -> Self { - Self { - mode: Some(ToolChoiceMode::None), - } - } -} - -/// Single or array content wrapper (SEP-1577). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] -pub enum SamplingContent { - Single(T), - Multiple(Vec), -} - -impl SamplingContent { - /// Convert to a Vec regardless of whether it's single or multiple - pub fn into_vec(self) -> Vec { - match self { - SamplingContent::Single(item) => vec![item], - SamplingContent::Multiple(items) => items, - } - } - - /// Check if the content is empty - pub fn is_empty(&self) -> bool { - match self { - SamplingContent::Single(_) => false, - SamplingContent::Multiple(items) => items.is_empty(), - } - } - - /// Get the number of content items - pub fn len(&self) -> usize { - match self { - SamplingContent::Single(_) => 1, - SamplingContent::Multiple(items) => items.len(), - } - } -} - -impl Default for SamplingContent { - fn default() -> Self { - SamplingContent::Multiple(Vec::new()) - } -} - -impl SamplingContent { - /// Get the first item if present - pub fn first(&self) -> Option<&T> { - match self { - SamplingContent::Single(item) => Some(item), - SamplingContent::Multiple(items) => items.first(), - } - } - - /// Iterate over all content items - pub fn iter(&self) -> impl Iterator { - let items: Vec<&T> = match self { - SamplingContent::Single(item) => vec![item], - SamplingContent::Multiple(items) => items.iter().collect(), - }; - items.into_iter() - } -} - -impl SamplingMessageContentBlock { - /// Get the text content if this is a Text variant - pub fn as_text(&self) -> Option<&TextContent> { - match self { - SamplingMessageContentBlock::Text(text) => Some(text), - _ => None, - } - } - - /// Get the tool use content if this is a ToolUse variant - pub fn as_tool_use(&self) -> Option<&ToolUseContent> { - match self { - SamplingMessageContentBlock::ToolUse(tool_use) => Some(tool_use), - _ => None, - } - } - - /// Get the tool result content if this is a ToolResult variant - pub fn as_tool_result(&self) -> Option<&ToolResultContent> { - match self { - SamplingMessageContentBlock::ToolResult(tool_result) => Some(tool_result), - _ => None, - } - } -} - -impl From for SamplingContent { - fn from(item: T) -> Self { - SamplingContent::Single(item) - } -} - -impl From> for SamplingContent { - fn from(items: Vec) -> Self { - SamplingContent::Multiple(items) - } -} - -/// A message in a sampling conversation, containing a role and content. -/// -/// This represents a single message in a conversation flow, used primarily -/// in LLM sampling requests where the conversation history is important -/// for generating appropriate responses. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -#[deprecated( - since = "2.0.0", - note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" -)] -pub struct SamplingMessage { - /// The role of the message sender (User or Assistant) - pub role: Role, - /// The actual content of the message (text, image, audio, tool use, or tool result) - pub content: SamplingContent, - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -/// Content types for sampling messages (SEP-1577). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -#[deprecated( - since = "2.0.0", - note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" -)] -pub enum SamplingMessageContentBlock { - Text(TextContent), - Image(ImageContent), - Audio(AudioContent), - /// Assistant only - ToolUse(ToolUseContent), - /// User only - ToolResult(ToolResultContent), -} - -impl SamplingMessageContentBlock { - /// Create a text content - pub fn text(text: impl Into) -> Self { - Self::Text(TextContent::new(text)) - } - - pub fn tool_use(id: impl Into, name: impl Into, input: JsonObject) -> Self { - Self::ToolUse(ToolUseContent::new(id, name, input)) - } - - pub fn tool_result(tool_use_id: impl Into, content: Vec) -> Self { - Self::ToolResult(ToolResultContent::new(tool_use_id, content)) - } -} - -impl SamplingMessage { - pub fn new(role: Role, content: impl Into) -> Self { - Self { - role, - content: SamplingContent::Single(content.into()), - meta: None, - } - } - - pub fn new_multiple(role: Role, contents: Vec) -> Self { - Self { - role, - content: SamplingContent::Multiple(contents), - meta: None, - } - } - - pub fn user_text(text: impl Into) -> Self { - Self::new(Role::User, SamplingMessageContentBlock::text(text)) - } - - pub fn assistant_text(text: impl Into) -> Self { - Self::new(Role::Assistant, SamplingMessageContentBlock::text(text)) - } - - pub fn user_tool_result(tool_use_id: impl Into, content: Vec) -> Self { - Self::new( - Role::User, - SamplingMessageContentBlock::tool_result(tool_use_id, content), - ) - } - - pub fn assistant_tool_use( - id: impl Into, - name: impl Into, - input: JsonObject, - ) -> Self { - Self::new( - Role::Assistant, - SamplingMessageContentBlock::tool_use(id, name, input), - ) - } -} - -impl From for SamplingMessageContentBlock { - fn from(text: TextContent) -> Self { - SamplingMessageContentBlock::Text(text) - } -} - -// Conversion from String to SamplingMessageContentBlock (as text) -impl From for SamplingMessageContentBlock { - fn from(text: String) -> Self { - SamplingMessageContentBlock::text(text) - } -} - -impl From<&str> for SamplingMessageContentBlock { - fn from(text: &str) -> Self { - SamplingMessageContentBlock::text(text) - } -} - -impl TryFrom for SamplingMessageContentBlock { - type Error = &'static str; - - fn try_from(content: ContentBlock) -> Result { - match content { - ContentBlock::Text(text) => Ok(SamplingMessageContentBlock::Text(text)), - ContentBlock::Image(image) => Ok(SamplingMessageContentBlock::Image(image)), - ContentBlock::Audio(audio) => Ok(SamplingMessageContentBlock::Audio(audio)), - ContentBlock::Resource(_) => { - Err("Resource content is not supported in sampling messages") - } - ContentBlock::ResourceLink(_) => { - Err("ResourceLink content is not supported in sampling messages") - } - } - } -} - -impl TryFrom for SamplingContent { - type Error = &'static str; - - fn try_from(content: ContentBlock) -> Result { - Ok(SamplingContent::Single(content.try_into()?)) - } -} - -/// Specifies how much context should be included in sampling requests. -/// -/// This allows clients to control what additional context information -/// should be provided to the LLM when processing sampling requests. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub enum ContextInclusion { - /// Include context from all connected MCP servers - #[serde(rename = "allServers")] - AllServers, - /// Include no additional context - #[serde(rename = "none")] - None, - /// Include context only from the requesting server - #[serde(rename = "thisServer")] - ThisServer, -} - -/// Parameters for creating a message through LLM sampling. -/// -/// This structure contains all the necessary information for a client to -/// generate an LLM response, including conversation history, model preferences, -/// and generation parameters. -/// -/// This implements `TaskAugmentedRequestParamsMeta` as sampling requests can be -/// long-running and may benefit from task-based execution. -#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -#[deprecated( - since = "2.0.0", - note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" -)] -pub struct CreateMessageRequestParams { - /// Protocol-level metadata for this request (SEP-1319) - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, - /// The conversation history and current messages - pub messages: Vec, - /// Preferences for model selection and behavior - #[serde(skip_serializing_if = "Option::is_none")] - pub model_preferences: Option, - /// System prompt to guide the model's behavior - #[serde(skip_serializing_if = "Option::is_none")] - pub system_prompt: Option, - /// How much context to include from MCP servers - #[serde(skip_serializing_if = "Option::is_none")] - pub include_context: Option, - /// Temperature for controlling randomness (0.0 to 1.0) - #[serde(skip_serializing_if = "Option::is_none")] - pub temperature: Option, - /// Maximum number of tokens to generate - pub max_tokens: u32, - /// Sequences that should stop generation - #[serde(skip_serializing_if = "Option::is_none")] - pub stop_sequences: Option>, - /// Additional metadata for the request - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option, - /// Tools available for the model to call (SEP-1577) - #[serde(skip_serializing_if = "Option::is_none")] - pub tools: Option>, - /// Tool selection behavior (SEP-1577) - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_choice: Option, -} - -impl RequestParamsMeta for CreateMessageRequestParams { - fn meta(&self) -> Option<&RequestMetaObject> { - self.meta.as_ref() - } - fn meta_mut(&mut self) -> &mut Option { - &mut self.meta - } -} - -impl CreateMessageRequestParams { - /// Create a new CreateMessageRequestParams with required fields. - pub fn new(messages: Vec, max_tokens: u32) -> Self { - Self { - meta: None, - messages, - model_preferences: None, - system_prompt: None, - include_context: None, - temperature: None, - max_tokens, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, - } - } - - /// Set model preferences. - pub fn with_model_preferences(mut self, model_preferences: ModelPreferences) -> Self { - self.model_preferences = Some(model_preferences); - self - } - - /// Set system prompt. - pub fn with_system_prompt(mut self, system_prompt: impl Into) -> Self { - self.system_prompt = Some(system_prompt.into()); - self - } - - /// Set include context. - pub fn with_include_context(mut self, include_context: ContextInclusion) -> Self { - self.include_context = Some(include_context); - self - } - - /// Set temperature. - pub fn with_temperature(mut self, temperature: f32) -> Self { - self.temperature = Some(temperature); - self - } - - /// Set stop sequences. - pub fn with_stop_sequences(mut self, stop_sequences: Vec) -> Self { - self.stop_sequences = Some(stop_sequences); - self - } - - /// Set metadata. - pub fn with_metadata(mut self, metadata: Value) -> Self { - self.metadata = Some(metadata); - self - } - - /// Set tools. - pub fn with_tools(mut self, tools: Vec) -> Self { - self.tools = Some(tools); - self - } - - /// Set tool choice. - pub fn with_tool_choice(mut self, tool_choice: ToolChoice) -> Self { - self.tool_choice = Some(tool_choice); - self - } - - /// Validate the sampling request parameters per SEP-1577 spec requirements. - /// - /// Checks: - /// - ToolUse content is only allowed in assistant messages - /// - ToolResult content is only allowed in user messages - /// - Messages with tool result content MUST NOT contain other content types - /// - Every assistant ToolUse must be balanced with a corresponding user ToolResult - pub fn validate(&self) -> Result<(), String> { - for msg in &self.messages { - for content in msg.content.iter() { - // ToolUse only in assistant messages, ToolResult only in user messages - match content { - SamplingMessageContentBlock::ToolUse(_) if msg.role != Role::Assistant => { - return Err("ToolUse content is only allowed in assistant messages".into()); - } - SamplingMessageContentBlock::ToolResult(_) if msg.role != Role::User => { - return Err("ToolResult content is only allowed in user messages".into()); - } - _ => {} - } - } - - // Tool result messages MUST NOT contain other content types - let contents: Vec<_> = msg.content.iter().collect(); - let has_tool_result = contents - .iter() - .any(|c| matches!(c, SamplingMessageContentBlock::ToolResult(_))); - if has_tool_result - && contents - .iter() - .any(|c| !matches!(c, SamplingMessageContentBlock::ToolResult(_))) - { - return Err( - "SamplingMessage with tool result content MUST NOT contain other content types" - .into(), - ); - } - } - - // Every assistant ToolUse must be balanced with a user ToolResult - self.validate_tool_use_result_balance()?; - - Ok(()) - } - - fn validate_tool_use_result_balance(&self) -> Result<(), String> { - let mut pending_tool_use_ids: Vec = Vec::new(); - for msg in &self.messages { - if msg.role == Role::Assistant { - for content in msg.content.iter() { - if let SamplingMessageContentBlock::ToolUse(tu) = content { - pending_tool_use_ids.push(tu.id.clone()); - } - } - } else if msg.role == Role::User { - for content in msg.content.iter() { - if let SamplingMessageContentBlock::ToolResult(tr) = content { - if !pending_tool_use_ids.contains(&tr.tool_use_id) { - return Err(format!( - "ToolResult with toolUseId '{}' has no matching ToolUse", - tr.tool_use_id - )); - } - pending_tool_use_ids.retain(|id| id != &tr.tool_use_id); - } - } - } - } - if !pending_tool_use_ids.is_empty() { - return Err(format!( - "ToolUse with id(s) {:?} not balanced with ToolResult", - pending_tool_use_ids - )); - } - Ok(()) - } -} - -/// Preferences for model selection and behavior in sampling requests. -/// -/// This allows servers to express their preferences for which model to use -/// and how to balance different priorities when the client has multiple -/// model options available. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -#[deprecated( - since = "2.0.0", - note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" -)] -pub struct ModelPreferences { - /// Specific model names or families to prefer (e.g., "claude", "gpt") - #[serde(skip_serializing_if = "Option::is_none")] - pub hints: Option>, - /// Priority for cost optimization (0.0 to 1.0, higher = prefer cheaper models) - #[serde(skip_serializing_if = "Option::is_none")] - pub cost_priority: Option, - /// Priority for speed/latency (0.0 to 1.0, higher = prefer faster models) - #[serde(skip_serializing_if = "Option::is_none")] - pub speed_priority: Option, - /// Priority for intelligence/capability (0.0 to 1.0, higher = prefer more capable models) - #[serde(skip_serializing_if = "Option::is_none")] - pub intelligence_priority: Option, -} - -impl ModelPreferences { - /// Create a new default ModelPreferences. - pub fn new() -> Self { - Self { - hints: None, - cost_priority: None, - speed_priority: None, - intelligence_priority: None, - } - } - - /// Set hints for model selection. - pub fn with_hints(mut self, hints: Vec) -> Self { - self.hints = Some(hints); - self - } - - /// Set cost priority (0.0 to 1.0). - pub fn with_cost_priority(mut self, cost_priority: f32) -> Self { - self.cost_priority = Some(cost_priority); - self - } - - /// Set speed priority (0.0 to 1.0). - pub fn with_speed_priority(mut self, speed_priority: f32) -> Self { - self.speed_priority = Some(speed_priority); - self - } - - /// Set intelligence priority (0.0 to 1.0). - pub fn with_intelligence_priority(mut self, intelligence_priority: f32) -> Self { - self.intelligence_priority = Some(intelligence_priority); - self - } -} - -impl Default for ModelPreferences { - fn default() -> Self { - Self::new() - } -} - -/// A hint suggesting a preferred model name or family. -/// -/// Model hints are advisory suggestions that help clients choose appropriate -/// models. They can be specific model names or general families like "claude" or "gpt". -#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -#[deprecated( - since = "2.0.0", - note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" -)] -pub struct ModelHint { - /// The suggested model name or family identifier - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, -} - -impl ModelHint { - /// Create a new ModelHint with a name. - pub fn new(name: impl Into) -> Self { - Self { - name: Some(name.into()), - } - } -} - -// ============================================================================= -// COMPLETION AND AUTOCOMPLETE -// ============================================================================= - -/// Context for completion requests providing previously resolved arguments. -/// -/// This enables context-aware completion where subsequent argument completions -/// can take into account the values of previously resolved arguments. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct CompletionContext { - /// Previously resolved argument values that can inform completion suggestions - #[serde(skip_serializing_if = "Option::is_none")] - pub arguments: Option>, -} - -impl CompletionContext { - /// Create a new empty completion context - pub fn new() -> Self { - Self::default() - } - - /// Create a completion context with the given arguments - pub fn with_arguments(arguments: std::collections::HashMap) -> Self { - Self { - arguments: Some(arguments), - } - } - - /// Get a specific argument value by name - pub fn get_argument(&self, name: &str) -> Option<&String> { - self.arguments.as_ref()?.get(name) - } - - /// Check if the context has any arguments - pub fn has_arguments(&self) -> bool { - self.arguments.as_ref().is_some_and(|args| !args.is_empty()) - } - - /// Get all argument names - pub fn argument_names(&self) -> impl Iterator { - self.arguments - .as_ref() - .into_iter() - .flat_map(|args| args.keys()) - .map(|k| k.as_str()) - } -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct CompleteRequestParams { - /// Protocol-level metadata for this request (SEP-1319) - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, - pub r#ref: Reference, - pub argument: ArgumentInfo, - /// Optional context containing previously resolved argument values - #[serde(skip_serializing_if = "Option::is_none")] - pub context: Option, -} - -impl CompleteRequestParams { - /// Create a new CompleteRequestParams with required fields. - pub fn new(r#ref: Reference, argument: ArgumentInfo) -> Self { - Self { - meta: None, - r#ref, - argument, - context: None, - } - } - - /// Set the completion context - pub fn with_context(mut self, context: CompletionContext) -> Self { - self.context = Some(context); - self - } -} - -impl RequestParamsMeta for CompleteRequestParams { - fn meta(&self) -> Option<&RequestMetaObject> { - self.meta.as_ref() - } - fn meta_mut(&mut self) -> &mut Option { - &mut self.meta - } -} - -pub type CompleteRequest = Request; - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct CompletionInfo { - pub values: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub total: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub has_more: Option, -} - -impl CompletionInfo { - /// Maximum number of completion values allowed per response according to MCP specification - pub const MAX_VALUES: usize = 100; - - /// Create a new CompletionInfo with validation for maximum values - pub fn new(values: Vec) -> Result { - if values.len() > Self::MAX_VALUES { - return Err(format!( - "Too many completion values: {} (max: {})", - values.len(), - Self::MAX_VALUES - )); - } - Ok(Self { - values, - total: None, - has_more: None, - }) - } - - /// Create CompletionInfo with all values and no pagination - pub fn with_all_values(values: Vec) -> Result { - let completion = Self::new(values)?; - Ok(Self { - total: Some(completion.values.len() as u32), - has_more: Some(false), - ..completion - }) - } - - /// Create CompletionInfo with pagination information - pub fn with_pagination( - values: Vec, - total: Option, - has_more: bool, - ) -> Result { - let completion = Self::new(values)?; - Ok(Self { - total, - has_more: Some(has_more), - ..completion - }) - } - - /// Check if this completion response indicates more results are available - pub fn has_more_results(&self) -> bool { - self.has_more.unwrap_or(false) - } - - /// Get the total number of available completions, if known - pub fn total_available(&self) -> Option { - self.total - } - - /// Validate that the completion info complies with MCP specification - pub fn validate(&self) -> Result<(), String> { - if self.values.len() > Self::MAX_VALUES { - return Err(format!( - "Too many completion values: {} (max: {})", - self.values.len(), - Self::MAX_VALUES - )); - } - Ok(()) - } -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct CompleteResult { - /// Result type discriminator (SEP-2322). Required by the [spec schema] - /// for servers implementing protocol version `2026-07-28`, but optional - /// here because this type also models results from older protocol - /// versions, which do not carry the field: `None` means absent on the - /// wire, and per the spec "the client MUST treat the absent field as - /// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`; - /// the server handler clears the field when responding to peers that - /// negotiated an older version. - /// - /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub result_type: Option, - pub completion: CompletionInfo, - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -impl Default for CompleteResult { - fn default() -> Self { - Self::new(CompletionInfo::default()) - } -} - -impl CompleteResult { - /// Create a new CompleteResult with the given completion info. - pub fn new(completion: CompletionInfo) -> Self { - Self { - result_type: Some(ResultType::COMPLETE), - completion, - meta: None, - } - } -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(tag = "type")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub enum Reference { - #[serde(rename = "ref/resource")] - Resource(ResourceTemplateReference), - #[serde(rename = "ref/prompt")] - Prompt(PromptReference), -} - -impl Reference { - /// Create a prompt reference - pub fn for_prompt(name: impl Into) -> Self { - // Not accepting `title` currently as it'll break the API - // Until further decision, keep it `None`, modify later - // if required, add `title` to the API - Self::Prompt(PromptReference { - name: name.into(), - title: None, - }) - } - - /// Create a resource reference - pub fn for_resource(uri: impl Into) -> Self { - Self::Resource(ResourceTemplateReference { uri: uri.into() }) - } - - /// Get the reference type as a string - pub fn reference_type(&self) -> &'static str { - match self { - Self::Prompt(_) => "ref/prompt", - Self::Resource(_) => "ref/resource", - } - } - - /// Extract prompt name if this is a prompt reference - pub fn as_prompt_name(&self) -> Option<&str> { - match self { - Self::Prompt(prompt_ref) => Some(&prompt_ref.name), - _ => None, - } - } - - /// Extract resource URI if this is a resource reference - pub fn as_resource_uri(&self) -> Option<&str> { - match self { - Self::Resource(resource_ref) => Some(&resource_ref.uri), - _ => None, - } - } -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ResourceTemplateReference { - pub uri: String, -} - -impl ResourceTemplateReference { - pub fn new(uri: impl Into) -> Self { - Self { uri: uri.into() } - } -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct PromptReference { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option, -} - -impl PromptReference { - /// Creates a new `PromptReference` with the given name. `title` defaults to `None`. - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - title: None, - } - } - - /// Sets the human-readable title for this prompt reference. - pub fn with_title(mut self, title: impl Into) -> Self { - self.title = Some(title.into()); - self - } -} - -const_string!(CompleteRequestMethod = "completion/complete"); -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ArgumentInfo { - pub name: String, - pub value: String, -} - -impl ArgumentInfo { - pub fn new(name: impl Into, value: impl Into) -> Self { - Self { - name: name.into(), - value: value.into(), - } - } -} - -// ============================================================================= -// ROOTS AND WORKSPACE MANAGEMENT -// ============================================================================= - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -#[deprecated( - since = "2.0.0", - note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" -)] -pub struct Root { - pub uri: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -impl Root { - /// Creates a new `Root` with the given URI. `name` defaults to `None`. - pub fn new(uri: impl Into) -> Self { - Self { - uri: uri.into(), - name: None, - meta: None, - } - } - - /// Sets the human-readable name for this root. - pub fn with_name(mut self, name: impl Into) -> Self { - self.name = Some(name.into()); - self - } - - /// Sets the protocol-level metadata for this root. - pub fn with_meta(mut self, meta: MetaObject) -> Self { - self.meta = Some(meta); - self - } -} - -const_string!(ListRootsRequestMethod = "roots/list"); -#[deprecated( - since = "2.0.0", - note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" -)] -pub type ListRootsRequest = RequestNoParam; - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -#[deprecated( - since = "2.0.0", - note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" -)] -pub struct ListRootsResult { - pub roots: Vec, - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -impl ListRootsResult { - /// Creates a new `ListRootsResult` with the given roots. - pub fn new(roots: Vec) -> Self { - Self { roots, meta: None } - } - - /// Sets the protocol-level metadata for this result. - pub fn with_meta(mut self, meta: MetaObject) -> Self { - self.meta = Some(meta); - self - } -} - -const_string!(RootsListChangedNotificationMethod = "notifications/roots/list_changed"); -pub type RootsListChangedNotification = NotificationNoParam; - -// ============================================================================= -// ELICITATION (INTERACTIVE USER INPUT) -// ============================================================================= - -// Method constants for elicitation operations. -// Elicitation allows servers to request interactive input from users during tool execution. -const_string!(ElicitationCreateRequestMethod = "elicitation/create"); -const_string!(ElicitationResponseNotificationMethod = "notifications/elicitation/response"); - -/// Represents the possible actions a user can take in response to an elicitation request. -/// -/// When a server requests user input through elicitation, the user can: -/// - Accept: Provide the requested information and continue -/// - Decline: Refuse to provide the information but continue the operation -/// - Cancel: Stop the entire operation -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub enum ElicitationAction { - /// User accepts the request and provides the requested information - Accept, - /// User declines to provide the information but allows the operation to continue - Decline, - /// User cancels the entire operation - Cancel, -} - -/// Wire representation for tagged elicitation parameters and legacy forms without `mode`. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(tag = "mode")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -enum ElicitRequestParamsWire { - #[serde(rename = "form", rename_all = "camelCase")] - Form { - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - meta: Option, - message: String, - requested_schema: ElicitationSchema, - }, - #[serde(rename = "url", rename_all = "camelCase")] - Url { - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - meta: Option, - message: String, - url: String, - elicitation_id: String, - }, - #[serde(untagged, rename_all = "camelCase")] - LegacyForm { - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - meta: Option, - message: String, - requested_schema: ElicitationSchema, - }, -} - -impl TryFrom for ElicitRequestParams { - type Error = serde_json::Error; - - fn try_from(value: ElicitRequestParamsWire) -> Result { - match value { - ElicitRequestParamsWire::Form { - meta, - message, - requested_schema, - } - | ElicitRequestParamsWire::LegacyForm { - meta, - message, - requested_schema, - } => Ok(ElicitRequestParams::FormElicitationParams { - meta, - message, - requested_schema, - }), - ElicitRequestParamsWire::Url { - meta, - message, - url, - elicitation_id, - } => Ok(ElicitRequestParams::UrlElicitationParams { - meta, - message, - url, - elicitation_id, - }), - } - } -} - -/// Parameters for creating an elicitation request to gather user input. -/// -/// This structure contains everything needed to request interactive input from a user: -/// - A human-readable message explaining what information is needed -/// - A type-safe schema defining the expected structure of the response -/// -/// # Example -/// 1. Form-based elicitation request -/// ```rust -/// use rmcp::model::*; -/// -/// let params = ElicitRequestParams::FormElicitationParams { -/// meta: None, -/// message: "Please provide your email".to_string(), -/// requested_schema: ElicitationSchema::builder() -/// .required_email("email") -/// .build() -/// .unwrap(), -/// }; -/// ``` -/// 2. URL-based elicitation request -/// ```rust -/// use rmcp::model::*; -/// let params = ElicitRequestParams::UrlElicitationParams { -/// meta: None, -/// message: "Please provide your feedback at the following URL".to_string(), -/// url: "https://example.com/feedback".to_string(), -/// elicitation_id: "unique-id-123".to_string(), -/// }; -/// ``` -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(tag = "mode", try_from = "ElicitRequestParamsWire")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub enum ElicitRequestParams { - #[serde(rename = "form", rename_all = "camelCase")] - FormElicitationParams { - /// Protocol-level metadata for this request (SEP-1319) - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - meta: Option, - /// Human-readable message explaining what input is needed from the user. - /// This should be clear and provide sufficient context for the user to understand - /// what information they need to provide. - message: String, - - /// Type-safe schema defining the expected structure and validation rules for the user's response. - /// This enforces the MCP 2025-06-18 specification that elicitation schemas must be objects - /// with primitive-typed properties. - requested_schema: ElicitationSchema, - }, - #[serde(rename = "url", rename_all = "camelCase")] - UrlElicitationParams { - /// Protocol-level metadata for this request (SEP-1319) - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - meta: Option, - /// Human-readable message explaining what input is needed from the user. - /// This should be clear and provide sufficient context for the user to understand - /// what information they need to provide. - message: String, - - /// The URL where the user can provide the requested information. - /// The client should direct the user to this URL to complete the elicitation. - url: String, - /// The unique identifier for this elicitation request. - elicitation_id: String, - }, -} - -impl RequestParamsMeta for ElicitRequestParams { - fn meta(&self) -> Option<&RequestMetaObject> { - match self { - ElicitRequestParams::FormElicitationParams { meta, .. } => meta.as_ref(), - ElicitRequestParams::UrlElicitationParams { meta, .. } => meta.as_ref(), - } - } - fn meta_mut(&mut self) -> &mut Option { - match self { - ElicitRequestParams::FormElicitationParams { meta, .. } => meta, - ElicitRequestParams::UrlElicitationParams { meta, .. } => meta, - } - } -} - -/// The result returned by a client in response to an elicitation request. -/// -/// Contains the user's decision (accept/decline/cancel) and optionally their input data -/// if they chose to accept the request. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ElicitResult { - /// The user's decision on how to handle the elicitation request - pub action: ElicitationAction, - - /// The actual data provided by the user, if they accepted the request. - /// Must conform to the JSON schema specified in the original request. - /// Only present when action is Accept. - #[serde(skip_serializing_if = "Option::is_none")] - pub content: Option, - - /// Optional protocol-level metadata for this result. - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -impl ElicitResult { - /// Create a new ElicitResult. - pub fn new(action: ElicitationAction) -> Self { - Self { - action, - content: None, - meta: None, - } - } - - /// Set the content on this result. - pub fn with_content(mut self, content: Value) -> Self { - self.content = Some(content); - self - } - - /// Set the metadata on this result. - pub fn with_meta(mut self, meta: MetaObject) -> Self { - self.meta = Some(meta); - self - } -} - -/// Request type for creating an elicitation to gather user input -pub type ElicitRequest = Request; - -// ============================================================================= -// TOOL EXECUTION RESULTS -// ============================================================================= - -/// The result of a tool call operation. -/// -/// Contains the content returned by the tool execution and an optional -/// flag indicating whether the operation resulted in an error. -#[derive(Debug, Serialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct CallToolResult { - /// Result type discriminator (SEP-2322). Required by the [spec schema] - /// for servers implementing protocol version `2026-07-28`, but optional - /// here because this type also models results from older protocol - /// versions, which do not carry the field: `None` means absent on the - /// wire, and per the spec "the client MUST treat the absent field as - /// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`; - /// the server handler clears the field when responding to peers that - /// negotiated an older version. - /// - /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub result_type: Option, - /// The content returned by the tool (text, images, etc.) - #[serde(default)] - pub content: Vec, - /// An optional JSON object that represents the structured result of the tool call - #[serde(skip_serializing_if = "Option::is_none")] - pub structured_content: Option, - /// Whether this result represents an error condition - #[serde(skip_serializing_if = "Option::is_none")] - pub is_error: Option, - /// Optional protocol-level metadata for this result - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -// Custom Deserialize implementation that: -// 1. Defaults `content` to `[]` when the field is missing (lenient per Postel's law) -// 2. Requires at least one tool-specific field to be present, so protocol-generic -// fields such as `resultType` and `_meta` do not make `CallToolResult` greedily -// match custom-extension responses inside `#[serde(untagged)]` enums (e.g. -// `ServerResult`), which would shadow `CustomResult`. -// 3. Rejects `resultType: "input_required"` so untagged `ServerResult` -// decoding selects `InputRequiredResult` and preserves input requests and state. -impl<'de> Deserialize<'de> for CallToolResult { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - struct Helper { - #[serde(default)] - result_type: Option, - content: Option>, - structured_content: Option, - is_error: Option, - #[serde(rename = "_meta")] - meta: Option, - } - - let helper = Helper::deserialize(deserializer)?; - - if helper - .result_type - .as_ref() - .is_some_and(ResultType::is_input_required) - { - return Err(serde::de::Error::custom( - "CallToolResult cannot use resultType \"input_required\"", - )); - } - - if helper.content.is_none() - && helper.structured_content.is_none() - && helper.is_error.is_none() - { - return Err(serde::de::Error::custom( - "expected at least one tool-specific CallToolResult field \ - (content, structuredContent, or isError)", - )); - } - - Ok(CallToolResult { - result_type: helper.result_type, - content: helper.content.unwrap_or_default(), - structured_content: helper.structured_content, - is_error: helper.is_error, - meta: helper.meta, - }) - } -} - -impl Default for CallToolResult { - fn default() -> Self { - CallToolResult { - result_type: Some(ResultType::COMPLETE), - content: Vec::new(), - structured_content: None, - is_error: None, - meta: None, - } - } -} - -impl CallToolResult { - /// Create a successful tool result with unstructured content - pub fn success(content: Vec) -> Self { - CallToolResult { - result_type: Some(ResultType::COMPLETE), - content, - structured_content: None, - is_error: Some(false), - meta: None, - } - } - - /// Create a tool-level error result with caller-visible content. - /// - /// # When to use this vs `Err(ErrorData)` - /// - /// MCP distinguishes two failure modes for a `call_tool` invocation, and - /// the right one to use depends on **whose problem it is**: - /// - /// - **Tool-level error** — `Ok(CallToolResult::error(...))`. - /// The request was valid and routed to your tool, but executing the - /// tool failed in a way the caller should see (a query returned no - /// rows, an external API returned 500, the user's input is plausible - /// but produced no result, etc.). The caller's MCP client renders the - /// `content` you provide; your message reaches the user. **This is the - /// right choice for almost every "the tool ran and didn't work" case.** - /// - /// - **Protocol error** — `Err(ErrorData)` with a JSON-RPC code. - /// The server cannot route the request at all, or an infrastructure - /// error makes the server itself unusable - /// ([`ErrorCode::INTERNAL_ERROR`], `-32603`). MCP clients typically - /// render protocol errors opaquely (e.g. "Tool result missing due to - /// internal error") — the caller does **not** see your message. - /// - /// # Example - /// - /// ```rust,ignore - /// use rmcp::model::{CallToolResult, Content, ErrorData}; - /// - /// async fn lookup(query: &str) -> Result { - /// // Caller passed a malformed query — the server can't run anything. - /// // This is a protocol error, the caller's client will render it - /// // as -32602 invalid_params: - /// if query.is_empty() { - /// return Err(ErrorData::invalid_params("query must be non-empty", None)); - /// } - /// - /// // Tool ran, no result. Caller should see the explanation: - /// let rows = run_query(query).await; - /// if rows.is_empty() { - /// return Ok(CallToolResult::error(vec![ContentBlock::text( - /// format!("no rows matched '{query}'"), - /// )])); - /// } - /// - /// Ok(CallToolResult::success(vec![ContentBlock::text(format_rows(&rows))])) - /// } - /// # async fn run_query(_: &str) -> Vec<&'static str> { vec![] } - /// # fn format_rows(_: &[&str]) -> String { String::new() } - /// ``` - pub fn error(content: Vec) -> Self { - CallToolResult { - result_type: Some(ResultType::COMPLETE), - content, - structured_content: None, - is_error: Some(true), - meta: None, - } - } - /// Create a successful tool result with structured content - /// - /// # Example - /// - /// ```rust,ignore - /// use rmcp::model::CallToolResult; - /// use serde_json::json; - /// - /// let result = CallToolResult::structured(json!({ - /// "temperature": 22.5, - /// "humidity": 65, - /// "description": "Partly cloudy" - /// })); - /// ``` - pub fn structured(value: Value) -> Self { - CallToolResult { - result_type: Some(ResultType::COMPLETE), - content: vec![ContentBlock::text(value.to_string())], - structured_content: Some(value), - is_error: Some(false), - meta: None, - } - } - /// Create an error tool result with structured content - /// - /// # Example - /// - /// ```rust,ignore - /// use rmcp::model::CallToolResult; - /// use serde_json::json; - /// - /// let result = CallToolResult::structured_error(json!({ - /// "error_code": "INVALID_INPUT", - /// "message": "Temperature value out of range", - /// "details": { - /// "min": -50, - /// "max": 50, - /// "provided": 100 - /// } - /// })); - /// ``` - pub fn structured_error(value: Value) -> Self { - CallToolResult { - result_type: Some(ResultType::COMPLETE), - content: vec![ContentBlock::text(value.to_string())], - structured_content: Some(value), - is_error: Some(true), - meta: None, - } - } - - /// Set the metadata on this result - pub fn with_meta(mut self, meta: Option) -> Self { - self.meta = meta; - self - } - - /// Convert the `structured_content` part of response into a certain type. - /// - /// # About json schema validation - /// Since rust is a strong type language, we don't need to do json schema validation here. - /// - /// But if you do have to validate the response data, you can use [`jsonschema`](https://crates.io/crates/jsonschema) crate. - pub fn into_typed(self) -> Result - where - T: DeserializeOwned, - { - let raw_text = match (self.structured_content, &self.content.first()) { - (Some(value), _) => return serde_json::from_value(value), - (None, Some(contents)) => { - if let Some(text) = contents.as_text() { - let text = &text.text; - Some(text) - } else { - None - } - } - (None, None) => None, - }; - if let Some(text) = raw_text { - return serde_json::from_str(text); - } - serde_json::from_value(serde_json::Value::Null) - } -} - -const_string!(ListToolsRequestMethod = "tools/list"); -/// Request to list all available tools from a server -pub type ListToolsRequest = RequestOptionalParam; - -paginated_result!( - ListToolsResult { - tools: Vec - } -); - -const_string!(CallToolRequestMethod = "tools/call"); -/// Parameters for calling a tool provided by an MCP server. -/// -/// Contains the tool name and optional arguments needed to execute -/// the tool operation. -#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct CallToolRequestParams { - /// Protocol-level metadata for this request (SEP-1319) - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, - /// The name of the tool to call - pub name: Cow<'static, str>, - /// Arguments to pass to the tool (must match the tool's input schema) - #[serde(skip_serializing_if = "Option::is_none")] - pub arguments: Option, - /// Client responses to server-initiated input requests from a previous - /// [`InputRequiredResult`]. Present only when retrying after an incomplete result. - #[serde(skip_serializing_if = "Option::is_none")] - pub input_responses: Option, - /// Opaque request state echoed back from a previous [`InputRequiredResult`]. - /// Clients MUST return this value exactly as received. - #[serde(skip_serializing_if = "Option::is_none")] - pub request_state: Option, -} - -impl CallToolRequestParams { - /// Creates a new `CallToolRequestParams` with the given tool name. - pub fn new(name: impl Into>) -> Self { - Self { - meta: None, - name: name.into(), - arguments: None, - input_responses: None, - request_state: None, - } - } - - /// Sets the arguments for this tool call. - pub fn with_arguments(mut self, arguments: JsonObject) -> Self { - self.arguments = Some(arguments); - self - } - - /// Sets the input responses for an MRTR retry. - pub fn with_input_responses(mut self, input_responses: InputResponses) -> Self { - self.input_responses = Some(input_responses); - self - } - - /// Sets the request state for an MRTR retry. - pub fn with_request_state(mut self, request_state: impl Into) -> Self { - self.request_state = Some(request_state.into()); - self - } -} - -impl RequestParamsMeta for CallToolRequestParams { - fn meta(&self) -> Option<&RequestMetaObject> { - self.meta.as_ref() - } - fn meta_mut(&mut self) -> &mut Option { - &mut self.meta - } -} - -/// Request to call a specific tool -pub type CallToolRequest = Request; - -/// Result of sampling/createMessage (SEP-1577). -/// The result of a sampling/createMessage request containing the generated response. -/// -/// This structure contains the generated message along with metadata about -/// how the generation was performed and why it stopped. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -#[deprecated( - since = "2.0.0", - note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" -)] -pub struct CreateMessageResult { - /// The identifier of the model that generated the response - pub model: String, - /// The reason why generation stopped (e.g., "endTurn", "maxTokens") - #[serde(skip_serializing_if = "Option::is_none")] - pub stop_reason: Option, - /// The generated message with role and content - #[serde(flatten)] - pub message: SamplingMessage, -} - -impl CreateMessageResult { - /// Create a new CreateMessageResult with required fields. - pub fn new(message: SamplingMessage, model: String) -> Self { - Self { - message, - model, - stop_reason: None, - } - } - - pub const STOP_REASON_END_TURN: &str = "endTurn"; - pub const STOP_REASON_END_SEQUENCE: &str = "stopSequence"; - pub const STOP_REASON_END_MAX_TOKEN: &str = "maxTokens"; - pub const STOP_REASON_TOOL_USE: &str = "toolUse"; - - /// Set the stop reason. - pub fn with_stop_reason(mut self, stop_reason: impl Into) -> Self { - self.stop_reason = Some(stop_reason.into()); - self - } - - /// Set the model identifier. - pub fn with_model(mut self, model: impl Into) -> Self { - self.model = model.into(); - self - } - - /// Validate the result per SEP-1577: role must be "assistant". - pub fn validate(&self) -> Result<(), String> { - if self.message.role != Role::Assistant { - return Err("CreateMessageResult role must be 'assistant'".into()); - } - Ok(()) - } -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct GetPromptResult { - /// Result type discriminator (SEP-2322). Required by the [spec schema] - /// for servers implementing protocol version `2026-07-28`, but optional - /// here because this type also models results from older protocol - /// versions, which do not carry the field: `None` means absent on the - /// wire, and per the spec "the client MUST treat the absent field as - /// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`; - /// the server handler clears the field when responding to peers that - /// negotiated an older version. - /// - /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub result_type: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - pub messages: Vec, - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -impl Default for GetPromptResult { - fn default() -> Self { - Self::new(Vec::new()) - } -} - -impl GetPromptResult { - /// Create a new GetPromptResult with required fields. - pub fn new(messages: Vec) -> Self { - Self { - result_type: Some(ResultType::COMPLETE), - description: None, - messages, - meta: None, - } - } - - /// Set the description - pub fn with_description>(mut self, description: D) -> Self { - self.description = Some(description.into()); - self - } -} - -// ============================================================================= -// TASK MANAGEMENT (SEP-2663 Tasks extension: `io.modelcontextprotocol/tasks`) -// ============================================================================= - -const_string!(GetTaskMethod = "tasks/get"); -pub type GetTaskRequest = Request; - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct GetTaskParams { - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, - /// Identifier of the task to query. - pub task_id: String, -} - -impl GetTaskParams { - pub fn new(task_id: impl Into) -> Self { - Self { - meta: None, - task_id: task_id.into(), - } - } -} - -impl RequestParamsMeta for GetTaskParams { - fn meta(&self) -> Option<&RequestMetaObject> { - self.meta.as_ref() - } - fn meta_mut(&mut self) -> &mut Option { - &mut self.meta - } -} - -const_string!(UpdateTaskMethod = "tasks/update"); -pub type UpdateTaskRequest = Request; - -/// Parameters for `tasks/update` (SEP-2663): deliver responses to outstanding -/// in-task server-to-client requests surfaced via `tasks/get` `inputRequests`. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct UpdateTaskParams { - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, - /// Identifier of the task to update. - pub task_id: String, - /// Responses to outstanding `inputRequests` previously surfaced by the - /// server. Each key MUST correspond to a currently-outstanding - /// `inputRequests` key. - pub input_responses: InputResponses, -} - -impl UpdateTaskParams { - pub fn new(task_id: impl Into, input_responses: InputResponses) -> Self { - Self { - meta: None, - task_id: task_id.into(), - input_responses, - } - } -} - -impl RequestParamsMeta for UpdateTaskParams { - fn meta(&self) -> Option<&RequestMetaObject> { - self.meta.as_ref() - } - fn meta_mut(&mut self) -> &mut Option { - &mut self.meta - } -} - -const_string!(CancelTaskMethod = "tasks/cancel"); -pub type CancelTaskRequest = Request; - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct CancelTaskParams { - /// Protocol-level metadata for this request (SEP-1319) - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, - pub task_id: String, -} - -impl CancelTaskParams { - pub fn new(task_id: impl Into) -> Self { - Self { - meta: None, - task_id: task_id.into(), - } - } -} - -impl RequestParamsMeta for CancelTaskParams { - fn meta(&self) -> Option<&RequestMetaObject> { - self.meta.as_ref() - } - fn meta_mut(&mut self) -> &mut Option { - &mut self.meta - } -} - -// --------------------------------------------------------------------------- -// Task status notification (SEP-2663 `notifications/tasks`) -// --------------------------------------------------------------------------- -const_string!(TaskStatusNotificationMethod = "notifications/tasks"); - -/// Parameters for a task status notification (spec `TaskStatusNotificationParams`). -/// -/// Carries a complete [`DetailedTask`] for the current status, identical to -/// what `tasks/get` would have returned at that moment. The task fields are -/// flattened at the top level: `NotificationParams & Task`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct TaskStatusNotificationParams { - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, - #[serde(flatten)] - pub task: crate::model::DetailedTask, -} - -impl TaskStatusNotificationParams { - pub fn new(task: crate::model::DetailedTask) -> Self { - Self { meta: None, task } - } - - pub fn with_meta(mut self, meta: NotificationMetaObject) -> Self { - self.meta = Some(meta); - self - } -} - -impl From for TaskStatusNotificationParams { - fn from(task: crate::model::DetailedTask) -> Self { - Self::new(task) - } -} - -impl Deref for TaskStatusNotificationParams { - type Target = crate::model::DetailedTask; - - fn deref(&self) -> &Self::Target { - &self.task - } -} - -impl DerefMut for TaskStatusNotificationParams { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.task - } -} - -pub type TaskStatusNotification = - Notification; - -// ============================================================================= -// MESSAGE TYPE UNIONS -// ============================================================================= - -macro_rules! ts_union { - ( - export type $U:ident = - $($rest:tt)* - ) => { - ts_union!(@declare $U { $($rest)* }); - ts_union!(@impl_from $U { $($rest)* }); - }; - (@declare $U:ident { $($variant:tt)* }) => { - ts_union!(@declare_variant $U { } {$($variant)*} ); - }; - (@declare_variant $U:ident { $($declared:tt)* } {$(|)? box $V:ident $($rest:tt)*}) => { - ts_union!(@declare_variant $U { $($declared)* $V(Box<$V>), } {$($rest)*}); - }; - (@declare_variant $U:ident { $($declared:tt)* } {$(|)? $V:ident $($rest:tt)*}) => { - ts_union!(@declare_variant $U { $($declared)* $V($V), } {$($rest)*}); - }; - (@declare_variant $U:ident { $($declared:tt)* } { ; }) => { - ts_union!(@declare_end $U { $($declared)* } ); - }; - (@declare_end $U:ident { $($declared:tt)* }) => { - #[derive(Debug, Serialize, Deserialize, Clone)] - #[serde(untagged)] - #[allow(clippy::large_enum_variant)] - #[non_exhaustive] - #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] - pub enum $U { - $($declared)* - } - }; - (@impl_from $U: ident {$(|)? box $V:ident $($rest:tt)*}) => { - impl From<$V> for $U { - fn from(value: $V) -> Self { - $U::$V(Box::new(value)) - } - } - ts_union!(@impl_from $U {$($rest)*}); - }; - (@impl_from $U: ident {$(|)? $V:ident $($rest:tt)*}) => { - impl From<$V> for $U { - fn from(value: $V) -> Self { - $U::$V(value) - } - } - ts_union!(@impl_from $U {$($rest)*}); - }; - (@impl_from $U: ident { ; }) => {}; - (@impl_from $U: ident { }) => {}; -} - -ts_union!( - export type ClientRequest = - | PingRequest - | InitializeRequest - | DiscoverRequest - | CompleteRequest - | SetLevelRequest - | GetPromptRequest - | ListPromptsRequest - | ListResourcesRequest - | ListResourceTemplatesRequest - | ReadResourceRequest - | SubscriptionsListenRequest - | SubscribeRequest - | UnsubscribeRequest - | CallToolRequest - | ListToolsRequest - | GetTaskRequest - | UpdateTaskRequest - | CancelTaskRequest - | CustomRequest; -); - -impl ClientRequest { - pub fn method(&self) -> &str { - match &self { - ClientRequest::PingRequest(r) => r.method.as_str(), - ClientRequest::InitializeRequest(r) => r.method.as_str(), - ClientRequest::DiscoverRequest(r) => r.method.as_str(), - ClientRequest::CompleteRequest(r) => r.method.as_str(), - ClientRequest::SetLevelRequest(r) => r.method.as_str(), - ClientRequest::GetPromptRequest(r) => r.method.as_str(), - ClientRequest::ListPromptsRequest(r) => r.method.as_str(), - ClientRequest::ListResourcesRequest(r) => r.method.as_str(), - ClientRequest::ListResourceTemplatesRequest(r) => r.method.as_str(), - ClientRequest::ReadResourceRequest(r) => r.method.as_str(), - ClientRequest::SubscriptionsListenRequest(r) => r.method.as_str(), - ClientRequest::SubscribeRequest(r) => r.method.as_str(), - ClientRequest::UnsubscribeRequest(r) => r.method.as_str(), - ClientRequest::CallToolRequest(r) => r.method.as_str(), - ClientRequest::ListToolsRequest(r) => r.method.as_str(), - ClientRequest::GetTaskRequest(r) => r.method.as_str(), - ClientRequest::UpdateTaskRequest(r) => r.method.as_str(), - ClientRequest::CancelTaskRequest(r) => r.method.as_str(), - ClientRequest::CustomRequest(r) => r.method.as_str(), - } - } -} - -ts_union!( - export type ClientNotification = - | CancelledNotification - | ProgressNotification - | InitializedNotification - | RootsListChangedNotification - | CustomNotification; -); - -ts_union!( - export type ClientResult = - box CreateMessageResult - | ListRootsResult - | ElicitResult - | EmptyResult - | CustomResult; -); - -impl ClientResult { - pub fn empty(_: ()) -> ClientResult { - ClientResult::EmptyResult(EmptyResult {}) - } -} - -pub type ClientJsonRpcMessage = JsonRpcMessage; - -ts_union!( - export type ServerRequest = - | PingRequest - | CreateMessageRequest - | ListRootsRequest - | ElicitRequest - | CustomRequest; -); - -ts_union!( - export type ServerNotification = - | CancelledNotification - | ProgressNotification - | LoggingMessageNotification - | ResourceUpdatedNotification - | ResourceListChangedNotification - | ToolListChangedNotification - | PromptListChangedNotification - | SubscriptionsAcknowledgedNotification - | TaskStatusNotification - | CustomNotification; -); - -ts_union!( - export type ServerResult = - | DiscoverResult - | InitializeResult - | CompleteResult - | GetPromptResult - | ListPromptsResult - | ListResourcesResult - | ListResourceTemplatesResult - | ReadResourceResult - | SubscriptionsListenResult - | ListToolsResult - | ElicitResult - | CreateTaskResult - | GetTaskResult - | CallToolResult - | InputRequiredResult - // TaskAckResult must come after CallToolResult/InputRequiredResult in this - // untagged union: it only carries `resultType`, so it would otherwise - // shadow any result that includes `resultType: "complete"`. - | TaskAckResult - | EmptyResult - | CustomResult - ; -); - -impl ServerResult { - pub fn empty(_: ()) -> ServerResult { - ServerResult::EmptyResult(EmptyResult {}) - } - - /// Empty `tasks/update` / `tasks/cancel` acknowledgement carrying the - /// SEP-2322 `resultType: "complete"` discriminator (SEP-2663). - pub fn task_ack(_: ()) -> ServerResult { - ServerResult::TaskAckResult(TaskAckResult::new()) - } - - /// Strip the SEP-2322 `resultType: "complete"` discriminator so the result - /// keeps the wire shape that predates protocol version `2026-07-28`. - /// - /// The server handler calls this before responding to a peer that - /// negotiated an older protocol version, where the field did not exist and - /// strict peers may reject it. Only the `"complete"` value is stripped: - /// results whose discriminator carries meaning (`"input_required"`, - /// `"task"`) are already gated to `2026-07-28`+ sessions, and custom - /// extension values are preserved. - /// - /// # Examples - /// - /// ``` - /// use rmcp::model::{CallToolResult, ServerResult}; - /// - /// let mut result = ServerResult::CallToolResult(CallToolResult::success(vec![])); - /// result.strip_result_type_for_legacy_peer(); - /// - /// let json = serde_json::to_value(&result).unwrap(); - /// assert!(json.get("resultType").is_none()); - /// ``` - pub fn strip_result_type_for_legacy_peer(&mut self) { - let result_type = match self { - ServerResult::CompleteResult(r) => &mut r.result_type, - ServerResult::GetPromptResult(r) => &mut r.result_type, - ServerResult::ListPromptsResult(r) => &mut r.result_type, - ServerResult::ListResourcesResult(r) => &mut r.result_type, - ServerResult::ListResourceTemplatesResult(r) => &mut r.result_type, - ServerResult::ReadResourceResult(r) => &mut r.result_type, - ServerResult::ListToolsResult(r) => &mut r.result_type, - ServerResult::CallToolResult(r) => &mut r.result_type, - _ => return, - }; - result_type.take_if(|result_type| result_type.is_complete()); - } -} - -pub type ServerJsonRpcMessage = JsonRpcMessage; - -impl TryInto for ServerNotification { - type Error = ServerNotification; - fn try_into(self) -> Result { - if let ServerNotification::CancelledNotification(t) = self { - Ok(t) - } else { - Err(self) - } - } -} - -impl TryInto for ClientNotification { - type Error = ClientNotification; - fn try_into(self) -> Result { - if let ClientNotification::CancelledNotification(t) = self { - Ok(t) - } else { - Err(self) - } - } -} - -// ============================================================================= -// TESTS -// ============================================================================= - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - #[cfg(feature = "transport-streamable-http-client")] - #[test] - fn transport_closed_marker_accepts_only_the_process_local_token() { - let local = ErrorData::transport_closed("closed"); - let spoofed = ErrorData::internal_error( - "spoofed", - Some(json!({ "io.modelcontextprotocol/transportClosed": true })), - ); - - assert!(local.is_transport_closed()); - assert!(!spoofed.is_transport_closed()); - } - - #[test] - fn cancelled_notification_request_id_is_optional_on_wire() { - // None → requestId 생략 - let p = CancelledNotificationParam::new(None, Some("user cancelled".into())); - let v = serde_json::to_value(&p).unwrap(); - assert!(v.get("requestId").is_none()); - - // Some → requestId 방출 + 라운드트립 - let p = CancelledNotificationParam::new(Some(RequestId::Number(1)), None); - let v = serde_json::to_value(&p).unwrap(); - assert_eq!(v["requestId"], json!(1)); - let back: CancelledNotificationParam = serde_json::from_value(v).unwrap(); - assert_eq!(back.request_id, Some(RequestId::Number(1))); - } - - #[test] - fn test_notification_serde() { - let raw = json!( { - "jsonrpc": JsonRpcVersion2_0, - "method": InitializedNotificationMethod, - }); - let message: ClientJsonRpcMessage = - serde_json::from_value(raw.clone()).expect("invalid notification"); - match &message { - ClientJsonRpcMessage::Notification(JsonRpcNotification { - notification: ClientNotification::InitializedNotification(_n), - .. - }) => {} - _ => panic!("Expected Notification"), - } - let json = serde_json::to_value(message).expect("valid json"); - assert_eq!(json, raw); - } - - #[test] - fn test_custom_client_notification_roundtrip() { - let raw = json!( { - "jsonrpc": JsonRpcVersion2_0, - "method": "notifications/custom", - "params": {"foo": "bar"}, - }); - - let message: ClientJsonRpcMessage = - serde_json::from_value(raw.clone()).expect("invalid notification"); - match &message { - ClientJsonRpcMessage::Notification(JsonRpcNotification { - notification: ClientNotification::CustomNotification(notification), - .. - }) => { - assert_eq!(notification.method, "notifications/custom"); - assert_eq!( - notification - .params - .as_ref() - .and_then(|p| p.get("foo")) - .expect("foo present"), - "bar" - ); - } - _ => panic!("Expected custom client notification"), - } - - let json = serde_json::to_value(message).expect("valid json"); - assert_eq!(json, raw); - } - - #[test] - fn test_custom_server_notification_roundtrip() { - let raw = json!( { - "jsonrpc": JsonRpcVersion2_0, - "method": "notifications/custom-server", - "params": {"hello": "world"}, - }); - - let message: ServerJsonRpcMessage = - serde_json::from_value(raw.clone()).expect("invalid notification"); - match &message { - ServerJsonRpcMessage::Notification(JsonRpcNotification { - notification: ServerNotification::CustomNotification(notification), - .. - }) => { - assert_eq!(notification.method, "notifications/custom-server"); - assert_eq!( - notification - .params - .as_ref() - .and_then(|p| p.get("hello")) - .expect("hello present"), - "world" - ); - } - _ => panic!("Expected custom server notification"), - } - - let json = serde_json::to_value(message).expect("valid json"); - assert_eq!(json, raw); - } - - #[test] - fn test_custom_request_roundtrip() { - let raw = json!( { - "jsonrpc": JsonRpcVersion2_0, - "id": 42, - "method": "requests/custom", - "params": {"foo": "bar"}, - }); - - let message: ClientJsonRpcMessage = - serde_json::from_value(raw.clone()).expect("invalid request"); - match &message { - ClientJsonRpcMessage::Request(JsonRpcRequest { id, request, .. }) => { - assert_eq!(id, &RequestId::Number(42)); - match request { - ClientRequest::CustomRequest(custom) => { - let expected_request = json!({ - "method": "requests/custom", - "params": {"foo": "bar"}, - }); - let actual_request = - serde_json::to_value(custom).expect("serialize custom request"); - assert_eq!(actual_request, expected_request); - } - other => panic!("Expected custom request, got: {other:?}"), - } - } - other => panic!("Expected request, got: {other:?}"), - } - - let json = serde_json::to_value(message).expect("valid json"); - assert_eq!(json, raw); - } - - #[test] - fn custom_server_result_with_generic_meta_is_not_a_tool_result() { - let raw = json!({ - "resultType": "complete", - "skills": [{ - "uri": "skill://example/SKILL.md", - "frontmatter": {"name": "example", "description": "example skill"} - }], - "_meta": { - "io.modelcontextprotocol/serverInfo": { - "name": "skills-server", - "version": "1.0.0" - } - } - }); - - let result: ServerResult = serde_json::from_value(raw.clone()).expect("custom result"); - match result { - ServerResult::CustomResult(CustomResult(value)) => assert_eq!(value, raw), - other => panic!("expected CustomResult, got {other:?}"), - } - } - - #[test] - fn call_tool_result_with_meta_and_tool_content_remains_a_tool_result() { - let raw = json!({ - "resultType": "complete", - "content": [], - "_meta": {"trace": "present"} - }); - - let result: ServerResult = serde_json::from_value(raw).expect("tool result"); - assert!(matches!(result, ServerResult::CallToolResult(_))); - } - - #[test] - fn test_request_conversion() { - let raw = json!( { - "jsonrpc": JsonRpcVersion2_0, - "id": 1, - "method": "request", - "params": {"key": "value"}, - }); - let message: JsonRpcMessage = serde_json::from_value(raw.clone()).expect("invalid request"); - - match &message { - JsonRpcMessage::Request(r) => { - assert_eq!(r.id, RequestId::Number(1)); - assert_eq!(r.request.method, "request"); - assert_eq!( - &r.request.params, - json!({"key": "value"}) - .as_object() - .expect("should be an object") - ); - } - _ => panic!("Expected Request"), - } - let json = serde_json::to_value(&message).expect("valid json"); - assert_eq!(json, raw); - } - - #[test] - fn test_initial_request_response_serde() { - let request = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": { - "roots": { - "listChanged": true - }, - "sampling": {} - }, - "clientInfo": { - "name": "ExampleClient", - "version": "1.0.0" - } - } - }); - let raw_response_json = json!({ - "jsonrpc": "2.0", - "id": 1, - "result": { - "protocolVersion": "2024-11-05", - "capabilities": { - "logging": {}, - "prompts": { - "listChanged": true - }, - "resources": { - "subscribe": true, - "listChanged": true - }, - "tools": { - "listChanged": true - } - }, - "serverInfo": { - "name": "ExampleServer", - "version": "1.0.0" - } - } - }); - let request: ClientJsonRpcMessage = - serde_json::from_value(request.clone()).expect("invalid request"); - let (request, id) = request.into_request().expect("should be a request"); - assert_eq!(id, RequestId::Number(1)); - match request { - ClientRequest::InitializeRequest(Request { - method: _, - params: - InitializeRequestParams { - meta: _, - protocol_version: _, - capabilities, - client_info, - }, - .. - }) => { - assert_eq!(capabilities.roots.unwrap().list_changed, Some(true)); - let sampling = capabilities.sampling.unwrap(); - assert_eq!(sampling.tools, None); - assert_eq!(sampling.context, None); - assert_eq!(client_info.name, "ExampleClient"); - assert_eq!(client_info.version, "1.0.0"); - } - _ => panic!("Expected InitializeRequest"), - } - let server_response: ServerJsonRpcMessage = - serde_json::from_value(raw_response_json.clone()).expect("invalid response"); - let (response, id) = server_response - .clone() - .into_response() - .expect("expect response"); - assert_eq!(id, RequestId::Number(1)); - match response { - ServerResult::InitializeResult(InitializeResult { - protocol_version: _, - capabilities, - server_info, - instructions, - .. - }) => { - assert_eq!(capabilities.logging.unwrap().len(), 0); - assert_eq!(capabilities.prompts.unwrap().list_changed, Some(true)); - assert_eq!( - capabilities.resources.as_ref().unwrap().subscribe, - Some(true) - ); - assert_eq!(capabilities.resources.unwrap().list_changed, Some(true)); - assert_eq!(capabilities.tools.unwrap().list_changed, Some(true)); - assert_eq!(server_info.name, "ExampleServer"); - assert_eq!(server_info.version, "1.0.0"); - assert_eq!(server_info.icons, None); - assert_eq!(instructions, None); - } - other => panic!("Expected InitializeResult, got {other:?}"), - } - - let server_response_json: Value = serde_json::to_value(&server_response).expect("msg"); - - assert_eq!(server_response_json, raw_response_json); - } - - #[test] - fn test_negative_and_large_request_ids() { - // Test negative ID - let negative_id_json = json!({ - "jsonrpc": "2.0", - "id": -1, - "method": "test", - "params": {} - }); - - let message: JsonRpcMessage = - serde_json::from_value(negative_id_json.clone()).expect("Should parse negative ID"); - - match &message { - JsonRpcMessage::Request(r) => { - assert_eq!(r.id, RequestId::Number(-1)); - } - _ => panic!("Expected Request"), - } - - // Test roundtrip serialization - let serialized = serde_json::to_value(&message).expect("Should serialize"); - assert_eq!(serialized, negative_id_json); - - // Test large negative ID - let large_negative_json = json!({ - "jsonrpc": "2.0", - "id": -9007199254740991i64, // JavaScript's MIN_SAFE_INTEGER - "method": "test", - "params": {} - }); - - let message: JsonRpcMessage = serde_json::from_value(large_negative_json.clone()) - .expect("Should parse large negative ID"); - - match &message { - JsonRpcMessage::Request(r) => { - assert_eq!(r.id, RequestId::Number(-9007199254740991i64)); - } - _ => panic!("Expected Request"), - } - - // Test large positive ID (JavaScript's MAX_SAFE_INTEGER) - let large_positive_json = json!({ - "jsonrpc": "2.0", - "id": 9007199254740991i64, - "method": "test", - "params": {} - }); - - let message: JsonRpcMessage = serde_json::from_value(large_positive_json.clone()) - .expect("Should parse large positive ID"); - - match &message { - JsonRpcMessage::Request(r) => { - assert_eq!(r.id, RequestId::Number(9007199254740991i64)); - } - _ => panic!("Expected Request"), - } - - // Test zero ID - let zero_id_json = json!({ - "jsonrpc": "2.0", - "id": 0, - "method": "test", - "params": {} - }); - - let message: JsonRpcMessage = - serde_json::from_value(zero_id_json.clone()).expect("Should parse zero ID"); - - match &message { - JsonRpcMessage::Request(r) => { - assert_eq!(r.id, RequestId::Number(0)); - } - _ => panic!("Expected Request"), - } - } - - #[test] - fn test_protocol_version_order() { - let v1 = ProtocolVersion::V_2024_11_05; - let v2 = ProtocolVersion::V_2025_03_26; - let v3 = ProtocolVersion::V_2025_06_18; - let v4 = ProtocolVersion::V_2025_11_25; - assert!(v1 < v2); - assert!(v2 < v3); - assert!(v3 < v4); - } - - #[test] - fn test_icon_serialization() { - let icon = Icon { - src: "https://example.com/icon.png".to_string(), - mime_type: Some("image/png".to_string()), - sizes: Some(vec!["48x48".to_string()]), - theme: Some(IconTheme::Light), - }; - - let json = serde_json::to_value(&icon).unwrap(); - assert_eq!(json["src"], "https://example.com/icon.png"); - assert_eq!(json["mimeType"], "image/png"); - assert_eq!(json["sizes"][0], "48x48"); - assert_eq!(json["theme"], "light"); - - // Test deserialization - let deserialized: Icon = serde_json::from_value(json).unwrap(); - assert_eq!(deserialized, icon); - } - - #[test] - fn test_icon_minimal() { - let icon = Icon { - src: "data:image/svg+xml;base64,PHN2Zy8+".to_string(), - mime_type: None, - sizes: None, - theme: None, - }; - - let json = serde_json::to_value(&icon).unwrap(); - assert_eq!(json["src"], "data:image/svg+xml;base64,PHN2Zy8+"); - assert!(json.get("mimeType").is_none()); - assert!(json.get("sizes").is_none()); - assert!(json.get("theme").is_none()); - } - - #[test] - fn test_implementation_with_icons() { - let implementation = Implementation { - name: "test-server".to_string(), - title: Some("Test Server".to_string()), - version: "1.0.0".to_string(), - description: Some("A test server for unit testing".to_string()), - icons: Some(vec![ - Icon { - src: "https://example.com/icon.png".to_string(), - mime_type: Some("image/png".to_string()), - sizes: Some(vec!["48x48".to_string()]), - theme: Some(IconTheme::Dark), - }, - Icon { - src: "https://example.com/icon.svg".to_string(), - mime_type: Some("image/svg+xml".to_string()), - sizes: Some(vec!["any".to_string()]), - theme: Some(IconTheme::Light), - }, - ]), - website_url: Some("https://example.com".to_string()), - }; - - let json = serde_json::to_value(&implementation).unwrap(); - assert_eq!(json["name"], "test-server"); - assert_eq!(json["description"], "A test server for unit testing"); - assert_eq!(json["websiteUrl"], "https://example.com"); - assert!(json["icons"].is_array()); - assert_eq!(json["icons"][0]["src"], "https://example.com/icon.png"); - assert_eq!(json["icons"][0]["sizes"][0], "48x48"); - assert_eq!(json["icons"][1]["mimeType"], "image/svg+xml"); - assert_eq!(json["icons"][1]["sizes"][0], "any"); - assert_eq!(json["icons"][0]["theme"], "dark"); - assert_eq!(json["icons"][1]["theme"], "light"); - } - - #[test] - fn test_backward_compatibility() { - // Test that old JSON without icons still deserializes correctly - let old_json = json!({ - "name": "legacy-server", - "version": "0.9.0" - }); - - let implementation: Implementation = serde_json::from_value(old_json).unwrap(); - assert_eq!(implementation.name, "legacy-server"); - assert_eq!(implementation.version, "0.9.0"); - assert_eq!(implementation.description, None); - assert_eq!(implementation.icons, None); - assert_eq!(implementation.website_url, None); - } - - #[test] - fn test_initialize_with_icons() { - let init_result = InitializeResult { - protocol_version: ProtocolVersion::default(), - capabilities: ServerCapabilities::default(), - server_info: Implementation { - name: "icon-server".to_string(), - title: None, - version: "2.0.0".to_string(), - description: None, - icons: Some(vec![Icon { - src: "https://example.com/server.png".to_string(), - mime_type: Some("image/png".to_string()), - sizes: Some(vec!["48x48".to_string()]), - theme: Some(IconTheme::Light), - }]), - website_url: Some("https://docs.example.com".to_string()), - }, - instructions: None, - meta: None, - }; - - let json = serde_json::to_value(&init_result).unwrap(); - assert!(json["serverInfo"]["icons"].is_array()); - assert_eq!( - json["serverInfo"]["icons"][0]["src"], - "https://example.com/server.png" - ); - assert_eq!(json["serverInfo"]["icons"][0]["sizes"][0], "48x48"); - assert_eq!(json["serverInfo"]["icons"][0]["theme"], "light"); - assert_eq!(json["serverInfo"]["websiteUrl"], "https://docs.example.com"); - } - - #[test] - fn elicitation_without_mode_deserializes_as_form() { - let json_data_without_tag = json!({ - "message": "Please provide more details.", - "requestedSchema": { - "title": "User Details", - "type": "object", - "properties": { - "name": { "type": "string" }, - "age": { "type": "integer" } - }, - "required": ["name", "age"] - } - }); - let elicitation: ElicitRequestParams = - serde_json::from_value(json_data_without_tag).expect("Deserialization failed"); - if let ElicitRequestParams::FormElicitationParams { - meta, - message, - requested_schema, - } = elicitation - { - assert_eq!(meta, None); - assert_eq!(message, "Please provide more details."); - assert_eq!(requested_schema.title, Some(Cow::from("User Details"))); - assert_eq!(requested_schema.type_, ObjectTypeConst); - } else { - panic!("Expected FormElicitationParams"); - } - } - - #[test] - fn test_elicitation_deserialization() { - let json_data_form = json!({ - "_meta": { "meta_form_key_1": "meta form value 1" }, - "mode": "form", - "message": "Please provide more details.", - "requestedSchema": { - "title": "User Details", - "type": "object", - "properties": { - "name": { "type": "string" }, - "age": { "type": "integer" } - }, - "required": ["name", "age"] - } - }); - let elicitation_form: ElicitRequestParams = - serde_json::from_value(json_data_form).expect("Deserialization failed"); - if let ElicitRequestParams::FormElicitationParams { - meta, - message, - requested_schema, - } = elicitation_form - { - assert_eq!( - meta, - Some(RequestMetaObject(MetaObject( - object!({ "meta_form_key_1": "meta form value 1" }) - ))) - ); - assert_eq!(message, "Please provide more details."); - assert_eq!(requested_schema.title, Some(Cow::from("User Details"))); - assert_eq!(requested_schema.type_, ObjectTypeConst); - } else { - panic!("Expected FormElicitationParams"); - } - - let json_data_url = json!({ - "_meta": { "meta_url_key_1": "meta url value 1" }, - "mode": "url", - "message": "Please fill out the form at the following URL.", - "url": "https://example.com/form", - "elicitationId": "elicitation-123" - }); - let elicitation_url: ElicitRequestParams = - serde_json::from_value(json_data_url).expect("Deserialization failed"); - if let ElicitRequestParams::UrlElicitationParams { - meta, - message, - url, - elicitation_id, - } = elicitation_url - { - assert_eq!( - meta, - Some(RequestMetaObject(MetaObject( - object!({ "meta_url_key_1": "meta url value 1" }) - ))) - ); - assert_eq!(message, "Please fill out the form at the following URL."); - assert_eq!(url, "https://example.com/form"); - assert_eq!(elicitation_id, "elicitation-123"); - } else { - panic!("Expected UrlElicitationParams"); - } - } - - #[test] - fn test_elicitation_serialization() { - let form_elicitation = ElicitRequestParams::FormElicitationParams { - meta: Some(RequestMetaObject(MetaObject( - object!({ "meta_form_key_1": "meta form value 1" }), - ))), - message: "Please provide more details.".to_string(), - requested_schema: ElicitationSchema::builder() - .title("User Details") - .string_property("name", |s| s) - .build() - .expect("Valid schema"), - }; - let json_form = serde_json::to_value(&form_elicitation).expect("Serialization failed"); - let expected_form_json = json!({ - "_meta": { "meta_form_key_1": "meta form value 1" }, - "mode": "form", - "message": "Please provide more details.", - "requestedSchema": { - "title":"User Details", - "type":"object", - "properties":{ - "name": { "type": "string" }, - }, - } - }); - assert_eq!(json_form, expected_form_json); - - let url_elicitation = ElicitRequestParams::UrlElicitationParams { - meta: Some(RequestMetaObject(MetaObject( - object!({ "meta_url_key_1": "meta url value 1" }), - ))), - message: "Please fill out the form at the following URL.".to_string(), - url: "https://example.com/form".to_string(), - elicitation_id: "elicitation-123".to_string(), - }; - let json_url = serde_json::to_value(&url_elicitation).expect("Serialization failed"); - let expected_url_json = json!({ - "_meta": { "meta_url_key_1": "meta url value 1" }, - "mode": "url", - "message": "Please fill out the form at the following URL.", - "url": "https://example.com/form", - "elicitationId": "elicitation-123" - }); - assert_eq!(json_url, expected_url_json); - } - - #[test] - fn notification_without_params_should_deserialize_as_bare_jsonrpc_message() { - let payload = b"{\"method\":\"notifications/initialized\",\"jsonrpc\":\"2.0\"}"; - let result: Result = serde_json::from_slice(payload); - assert!( - matches!(result, Ok(JsonRpcMessage::Notification(_))), - "Expected Ok(Notification), got: {:?}", - result - ); - } -} diff --git a/vendor/rmcp/src/model/annotated.rs b/vendor/rmcp/src/model/annotated.rs deleted file mode 100644 index ac3dbd3ed..000000000 --- a/vendor/rmcp/src/model/annotated.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! Annotations for content blocks and resources. -//! -//! The `Annotations` struct carries optional hints about audience, priority, and freshness. -//! Individual content/resource types embed `annotations: Option` directly. - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -use super::Role; - -/// Optional annotations for the client. The client can use annotations to inform how objects are -/// used or displayed. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct Annotations { - #[serde(skip_serializing_if = "Option::is_none")] - pub audience: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub priority: Option, - #[serde(skip_serializing_if = "Option::is_none", rename = "lastModified")] - pub last_modified: Option, -} - -impl Annotations { - /// Creates annotations for a resource with priority and an RFC 3339 timestamp. - /// - /// # Panics - /// - /// Panics if `priority` is not in the inclusive range `0.0..=1.0`. - pub fn for_resource(priority: f32, timestamp: DateTime) -> Self { - assert!( - (0.0..=1.0).contains(&priority), - "Priority {priority} must be between 0.0 and 1.0" - ); - Self { - priority: Some(priority), - last_modified: Some(timestamp.to_rfc3339()), - audience: None, - } - } - - pub fn with_audience(mut self, audience: Vec) -> Self { - self.audience = Some(audience); - self - } - - pub fn with_priority(mut self, priority: f32) -> Self { - self.priority = Some(priority); - self - } - - /// Sets `lastModified` from a typed timestamp, serialized as RFC 3339. - pub fn with_timestamp(mut self, timestamp: DateTime) -> Self { - self.last_modified = Some(timestamp.to_rfc3339()); - self - } - - /// Sets `lastModified` to the current time, serialized as RFC 3339. - pub fn with_timestamp_now(self) -> Self { - self.with_timestamp(Utc::now()) - } -} - -#[cfg(test)] -mod tests { - use chrono::TimeZone; - use serde_json::json; - - use super::*; - - fn annotations_of(value: serde_json::Value) -> Annotations { - serde_json::from_value::(value).unwrap() - } - - #[test] - fn preserves_date_only_last_modified_verbatim() { - let annotations = annotations_of(json!({ "lastModified": "2025-01-12" })); - assert_eq!(annotations.last_modified.as_deref(), Some("2025-01-12")); - } - - #[test] - fn preserves_rfc3339_last_modified_verbatim() { - let value = "2025-01-12T15:00:58Z"; - let annotations = annotations_of(json!({ "lastModified": value })); - assert_eq!(annotations.last_modified.as_deref(), Some(value)); - } - - #[test] - fn missing_last_modified_is_none() { - let annotations = annotations_of(json!({})); - assert_eq!(annotations.last_modified, None); - } - - #[test] - fn null_last_modified_is_none() { - let annotations = annotations_of(json!({ "lastModified": null })); - assert_eq!(annotations.last_modified, None); - } - - #[test] - fn invalid_last_modified_string_is_preserved() { - let annotations = annotations_of(json!({ "lastModified": "not-a-date" })); - assert_eq!(annotations.last_modified.as_deref(), Some("not-a-date")); - } - - #[test] - fn timestamp_round_trips_as_rfc3339_string() { - let timestamp = Utc.with_ymd_and_hms(2025, 1, 12, 15, 0, 58).unwrap(); - let annotations = Annotations::default().with_timestamp(timestamp); - - let value = serde_json::to_value(&annotations).unwrap(); - assert_eq!(value["lastModified"], json!(timestamp.to_rfc3339())); - - let round_tripped: Annotations = serde_json::from_value(value).unwrap(); - assert_eq!(round_tripped, annotations); - } -} diff --git a/vendor/rmcp/src/model/capabilities.rs b/vendor/rmcp/src/model/capabilities.rs deleted file mode 100644 index f014f569f..000000000 --- a/vendor/rmcp/src/model/capabilities.rs +++ /dev/null @@ -1,680 +0,0 @@ -use std::collections::BTreeMap; -#[cfg(any(feature = "server", feature = "macros"))] -use std::marker::PhantomData; - -#[cfg(any(feature = "server", feature = "macros"))] -use pastey::paste; -use serde::{Deserialize, Serialize}; - -use super::JsonObject; -pub type ExperimentalCapabilities = BTreeMap; - -/// MCP extension capabilities map. -/// -/// Keys are extension identifiers in the format `{vendor-prefix}/{extension-name}` -/// (e.g., `io.modelcontextprotocol/ui`, `io.modelcontextprotocol/oauth-client-credentials`). -/// Values are per-extension settings objects. An empty object indicates support with no settings. -/// -/// # Example -/// -/// ```rust -/// use rmcp::model::ExtensionCapabilities; -/// use serde_json::json; -/// -/// let mut extensions = ExtensionCapabilities::new(); -/// extensions.insert( -/// "io.modelcontextprotocol/ui".to_string(), -/// serde_json::from_value(json!({ -/// "mimeTypes": ["text/html;profile=mcp-app"] -/// })).unwrap() -/// ); -/// ``` -pub type ExtensionCapabilities = BTreeMap; - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct PromptsCapability { - #[serde(skip_serializing_if = "Option::is_none")] - pub list_changed: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ResourcesCapability { - #[serde(skip_serializing_if = "Option::is_none")] - pub subscribe: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub list_changed: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ToolsCapability { - #[serde(skip_serializing_if = "Option::is_none")] - pub list_changed: Option, -} - -/// Roots capability. Deprecated by SEP-2577; remains functional and will be -/// removed in a future release. -/// See . -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct RootsCapabilities { - #[serde(skip_serializing_if = "Option::is_none")] - pub list_changed: Option, -} - -/// Capability for handling elicitation requests from servers. -/// Elicitation allows servers to request interactive input from users during tool execution. -/// This capability indicates that a client can handle elicitation requests and present -/// appropriate UI to users for collecting the requested information. -/// -/// Capability for form mode elicitation. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct FormElicitationCapability { - /// Whether the client supports JSON Schema validation for elicitation responses. - /// When true, the client will validate user input against the requested_schema - /// before sending the response back to the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub schema_validation: Option, -} - -impl FormElicitationCapability { - pub fn new() -> Self { - Self::default() - } - - pub fn with_schema_validation(mut self, enabled: bool) -> Self { - self.schema_validation = Some(enabled); - self - } -} - -/// Capability for URL mode elicitation. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct UrlElicitationCapability {} - -impl UrlElicitationCapability { - pub fn new() -> Self { - Self::default() - } -} - -/// Elicitation allows servers to request interactive input from users during tool execution. -/// This capability indicates that a client can handle elicitation requests and present -/// appropriate UI to users for collecting the requested information. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ElicitationCapability { - /// Whether client supports form-based elicitation. - #[serde(skip_serializing_if = "Option::is_none")] - pub form: Option, - /// Whether client supports URL-based elicitation. - #[serde(skip_serializing_if = "Option::is_none")] - pub url: Option, -} - -impl ElicitationCapability { - pub fn new() -> Self { - Self::default() - } - - pub fn with_form(mut self, form: FormElicitationCapability) -> Self { - self.form = Some(form); - self - } - - pub fn with_url(mut self, url: UrlElicitationCapability) -> Self { - self.url = Some(url); - self - } -} - -/// Sampling capability with optional sub-capabilities (SEP-1577). -/// -/// Deprecated by SEP-2577; remains functional and will be removed in a future -/// release. -/// See . -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct SamplingCapability { - /// Support for `tools` and `toolChoice` parameters - #[serde(skip_serializing_if = "Option::is_none")] - pub tools: Option, - /// Support for `includeContext` (soft-deprecated) - #[serde(skip_serializing_if = "Option::is_none")] - pub context: Option, -} - -/// -/// # Builder -/// ```rust -/// # use rmcp::model::ClientCapabilities; -/// let cap = ClientCapabilities::builder() -/// .enable_experimental() -/// .build(); -/// ``` -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ClientCapabilities { - #[serde(skip_serializing_if = "Option::is_none")] - pub experimental: Option, - /// Optional MCP extensions that the client supports (SEP-1724). - /// Keys are extension identifiers (e.g., `"io.modelcontextprotocol/ui"`), - /// values are per-extension settings objects. An empty object indicates - /// support with no settings. - #[serde(skip_serializing_if = "Option::is_none")] - pub extensions: Option, - /// Capability for filesystem roots (deprecated by SEP-2577). - #[serde(skip_serializing_if = "Option::is_none")] - pub roots: Option, - /// Capability for LLM sampling requests (SEP-1577, deprecated by SEP-2577). - #[serde(skip_serializing_if = "Option::is_none")] - pub sampling: Option, - /// Capability to handle elicitation requests from servers for interactive user input - #[serde(skip_serializing_if = "Option::is_none")] - pub elicitation: Option, -} - -impl ClientCapabilities { - /// Returns `true` if the `io.modelcontextprotocol/tasks` extension - /// (SEP-2663) is declared in [`Self::extensions`]. - pub fn supports_tasks(&self) -> bool { - self.extensions - .as_ref() - .is_some_and(|e| e.contains_key(super::TASKS_EXTENSION_ID)) - } -} - -/// -/// ## Builder -/// ```rust -/// # use rmcp::model::ServerCapabilities; -/// let cap = ServerCapabilities::builder() -/// .enable_experimental() -/// .enable_prompts() -/// .enable_resources() -/// .enable_tools() -/// .enable_tool_list_changed() -/// .build(); -/// ``` -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ServerCapabilities { - #[serde(skip_serializing_if = "Option::is_none")] - pub experimental: Option, - /// Optional MCP extensions that the server supports (SEP-1724). - /// Keys are extension identifiers (e.g., `"io.modelcontextprotocol/apps"`), - /// values are per-extension settings objects. An empty object indicates - /// support with no settings. - #[serde(skip_serializing_if = "Option::is_none")] - pub extensions: Option, - /// Capability for server log message notifications (deprecated by SEP-2577). - #[serde(skip_serializing_if = "Option::is_none")] - pub logging: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub completions: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub prompts: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub resources: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tools: Option, -} - -impl ServerCapabilities { - /// Returns `true` if the `io.modelcontextprotocol/tasks` extension - /// (SEP-2663) is declared in [`Self::extensions`]. - pub fn supports_tasks(&self) -> bool { - self.extensions - .as_ref() - .is_some_and(|e| e.contains_key(super::TASKS_EXTENSION_ID)) - } -} - -#[cfg(any(feature = "server", feature = "macros"))] -macro_rules! builder { - ($Target: ident {$($(#[$fa:meta])* $f: ident: $T: ty),* $(,)?}) => { - paste! { - #[derive(Default, Clone, Copy, Debug)] - #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] - pub struct [<$Target BuilderState>]< - $(const [<$f:upper>]: bool = false,)* - >; - #[derive(Debug, Default)] - #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] - pub struct [<$Target Builder>]]> { - $(pub $f: Option<$T>,)* - pub state: PhantomData - } - impl $Target { - #[doc = "Create a new [`" $Target "`] builder."] - pub fn builder() -> [<$Target Builder>] { - <[<$Target Builder>]>::default() - } - } - impl [<$Target Builder>] { - pub fn build(self) -> $Target { - $Target { - $( $f: self.$f, )* - } - } - } - impl From<[<$Target Builder>]> for $Target { - fn from(builder: [<$Target Builder>]) -> Self { - builder.build() - } - } - } - builder!($Target @toggle $($(#[$fa])* $f: $T,)*); - - }; - ($Target: ident @toggle $(#[$fa0:meta])* $f0: ident: $T0: ty, $($(#[$fa:meta])* $f: ident: $T: ty,)*) => { - builder!($Target @toggle [][$(#[$fa0])* $f0: $T0][$($(#[$fa])* $f: $T,)*]); - }; - ($Target: ident @toggle [$($ff: ident: $Tf: ty,)*][$(#[$fna:meta])* $fn: ident: $TN: ty][$(#[$fn1a:meta])* $fn_1: ident: $Tn_1: ty, $($(#[$fta:meta])* $ft: ident: $Tt: ty,)*]) => { - builder!($Target @impl_toggle [$($ff: $Tf,)*][$(#[$fna])* $fn: $TN][$fn_1: $Tn_1, $($ft:$Tt,)*]); - builder!($Target @toggle [$($ff: $Tf,)* $fn: $TN,][$(#[$fn1a])* $fn_1: $Tn_1][$($(#[$fta])* $ft: $Tt,)*]); - }; - ($Target: ident @toggle [$($ff: ident: $Tf: ty,)*][$(#[$fna:meta])* $fn: ident: $TN: ty][]) => { - builder!($Target @impl_toggle [$($ff: $Tf,)*][$(#[$fna])* $fn: $TN][]); - }; - ($Target: ident @impl_toggle [$($ff: ident: $Tf: ty,)*][$(#[$fna:meta])* $fn: ident: $TN: ty][$($ft: ident: $Tt: ty,)*]) => { - paste! { - impl< - $(const [<$ff:upper>]: bool,)* - $(const [<$ft:upper>]: bool,)* - > [<$Target Builder>]<[<$Target BuilderState>]< - $([<$ff:upper>],)* - false, - $([<$ft:upper>],)* - >> { - $(#[$fna])* - pub fn [](self) -> [<$Target Builder>]<[<$Target BuilderState>]< - $([<$ff:upper>],)* - true, - $([<$ft:upper>],)* - >> { - [<$Target Builder>] { - $( $ff: self.$ff, )* - $fn: Some($TN::default()), - $( $ft: self.$ft, )* - state: PhantomData - } - } - $(#[$fna])* - pub fn [](self, $fn: $TN) -> [<$Target Builder>]<[<$Target BuilderState>]< - $([<$ff:upper>],)* - true, - $([<$ft:upper>],)* - >> { - [<$Target Builder>] { - $( $ff: self.$ff, )* - $fn: Some($fn), - $( $ft: self.$ft, )* - state: PhantomData - } - } - } - // do we really need to disable some thing in builder? - // impl< - // $(const [<$ff:upper>]: bool,)* - // $(const [<$ft:upper>]: bool,)* - // > [<$Target Builder>]<[<$Target BuilderState>]< - // $([<$ff:upper>],)* - // true, - // $([<$ft:upper>],)* - // >> { - // pub fn [](self) -> [<$Target Builder>]<[<$Target BuilderState>]< - // $([<$ff:upper>],)* - // false, - // $([<$ft:upper>],)* - // >> { - // [<$Target Builder>] { - // $( $ff: self.$ff, )* - // $fn: None, - // $( $ft: self.$ft, )* - // state: PhantomData - // } - // } - // } - } - } -} - -#[cfg(any(feature = "server", feature = "macros"))] -builder! { - ServerCapabilities { - experimental: ExperimentalCapabilities, - extensions: ExtensionCapabilities, - #[deprecated( - since = "1.8.0", - note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" - )] - logging: JsonObject, - completions: JsonObject, - prompts: PromptsCapability, - resources: ResourcesCapability, - tools: ToolsCapability, - } -} - -#[cfg(any(feature = "server", feature = "macros"))] -impl - ServerCapabilitiesBuilder> -{ - pub fn enable_tool_list_changed(mut self) -> Self { - if let Some(c) = self.tools.as_mut() { - c.list_changed = Some(true); - } - self - } -} - -#[cfg(any(feature = "server", feature = "macros"))] -impl - ServerCapabilitiesBuilder> -{ - pub fn enable_prompts_list_changed(mut self) -> Self { - if let Some(c) = self.prompts.as_mut() { - c.list_changed = Some(true); - } - self - } -} - -#[cfg(any(feature = "server", feature = "macros"))] -impl - ServerCapabilitiesBuilder> -{ - pub fn enable_resources_list_changed(mut self) -> Self { - if let Some(c) = self.resources.as_mut() { - c.list_changed = Some(true); - } - self - } - - pub fn enable_resources_subscribe(mut self) -> Self { - if let Some(c) = self.resources.as_mut() { - c.subscribe = Some(true); - } - self - } -} - -#[cfg(any(feature = "server", feature = "macros"))] -impl ServerCapabilitiesBuilder { - /// Declare support for the `io.modelcontextprotocol/tasks` extension - /// (SEP-2663) in the `extensions` capability map. - pub fn enable_tasks(mut self) -> Self { - self.extensions - .get_or_insert_with(ExtensionCapabilities::new) - .insert(super::TASKS_EXTENSION_ID.to_string(), JsonObject::new()); - self - } -} - -#[cfg(any(feature = "server", feature = "macros"))] -builder! { - ClientCapabilities{ - experimental: ExperimentalCapabilities, - extensions: ExtensionCapabilities, - #[deprecated( - since = "1.8.0", - note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" - )] - roots: RootsCapabilities, - #[deprecated( - since = "1.8.0", - note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" - )] - sampling: SamplingCapability, - elicitation: ElicitationCapability, - } -} - -#[cfg(any(feature = "server", feature = "macros"))] -impl ClientCapabilitiesBuilder { - /// Declare support for the `io.modelcontextprotocol/tasks` extension - /// (SEP-2663) in the `extensions` capability map. - pub fn enable_tasks(mut self) -> Self { - self.extensions - .get_or_insert_with(ExtensionCapabilities::new) - .insert(super::TASKS_EXTENSION_ID.to_string(), JsonObject::new()); - self - } -} - -#[cfg(any(feature = "server", feature = "macros"))] -impl - ClientCapabilitiesBuilder> -{ - #[deprecated( - since = "1.8.0", - note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" - )] - pub fn enable_roots_list_changed(mut self) -> Self { - if let Some(c) = self.roots.as_mut() { - c.list_changed = Some(true); - } - self - } -} - -#[cfg(any(feature = "server", feature = "macros"))] -impl - ClientCapabilitiesBuilder> -{ - /// Enable tool calling in sampling requests - #[deprecated( - since = "1.8.0", - note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" - )] - pub fn enable_sampling_tools(mut self) -> Self { - if let Some(c) = self.sampling.as_mut() { - c.tools = Some(JsonObject::default()); - } - self - } - - /// Enable context inclusion in sampling (soft-deprecated) - #[deprecated( - since = "1.8.0", - note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" - )] - pub fn enable_sampling_context(mut self) -> Self { - if let Some(c) = self.sampling.as_mut() { - c.context = Some(JsonObject::default()); - } - self - } -} - -#[cfg(all(feature = "elicitation", any(feature = "server", feature = "macros")))] -impl - ClientCapabilitiesBuilder> -{ - /// Enable JSON Schema validation for elicitation responses in form mode. - /// When enabled, the client will validate user input against the requested_schema - /// before sending responses back to the server. - pub fn enable_elicitation_schema_validation(mut self) -> Self { - if let Some(c) = self.elicitation.as_mut() { - c.form = Some(FormElicitationCapability { - schema_validation: Some(true), - }); - } - self - } -} - -#[cfg(test)] -#[cfg(any(feature = "server", feature = "macros"))] -mod test { - use super::*; - #[test] - #[allow(deprecated)] - fn test_builder() { - let builder = ::default() - .enable_logging() - .enable_experimental() - .enable_prompts() - .enable_resources() - .enable_tools() - .enable_tool_list_changed(); - assert_eq!(builder.logging, Some(JsonObject::default())); - assert_eq!(builder.prompts, Some(PromptsCapability::default())); - assert_eq!(builder.resources, Some(ResourcesCapability::default())); - assert_eq!( - builder.tools, - Some(ToolsCapability { - list_changed: Some(true), - }) - ); - assert_eq!( - builder.experimental, - Some(ExperimentalCapabilities::default()) - ); - let client_builder = ::default() - .enable_experimental() - .enable_roots() - .enable_roots_list_changed() - .enable_sampling(); - assert_eq!( - client_builder.experimental, - Some(ExperimentalCapabilities::default()) - ); - assert_eq!( - client_builder.roots, - Some(RootsCapabilities { - list_changed: Some(true), - }) - ); - } - - #[test] - fn test_tasks_extension_capability() { - // SEP-2663: tasks are declared via the extensions map. - let capabilities = ClientCapabilities::builder().enable_tasks().build(); - assert!(capabilities.supports_tasks()); - let json = serde_json::to_value(&capabilities).unwrap(); - assert_eq!( - json["extensions"][crate::model::TASKS_EXTENSION_ID], - serde_json::json!({}) - ); - - let server = ServerCapabilities::builder().enable_tasks().build(); - assert!(server.supports_tasks()); - let json = serde_json::to_value(&server).unwrap(); - assert_eq!( - json["extensions"][crate::model::TASKS_EXTENSION_ID], - serde_json::json!({}) - ); - } - - #[test] - #[allow(deprecated)] - fn test_client_extensions_capability() { - // Test building ClientCapabilities with extensions (MCP Apps support) - let mut extensions = ExtensionCapabilities::new(); - extensions.insert( - "io.modelcontextprotocol/ui".to_string(), - serde_json::from_value(serde_json::json!({ - "mimeTypes": ["text/html;profile=mcp-app"] - })) - .unwrap(), - ); - - let capabilities = ClientCapabilities::builder() - .enable_extensions_with(extensions) - .enable_sampling() - .build(); - - // Verify serialization matches MCP Apps spec format - let json = serde_json::to_value(&capabilities).unwrap(); - assert_eq!( - json["extensions"]["io.modelcontextprotocol/ui"]["mimeTypes"], - serde_json::json!(["text/html;profile=mcp-app"]) - ); - assert!(json["sampling"].is_object()); - } - - #[test] - fn test_server_extensions_capability() { - // Test building ServerCapabilities with extensions - let mut extensions = ExtensionCapabilities::new(); - extensions.insert( - "io.modelcontextprotocol/apps".to_string(), - serde_json::from_value(serde_json::json!({})).unwrap(), - ); - - let capabilities = ServerCapabilities::builder() - .enable_extensions_with(extensions) - .enable_tools() - .build(); - - // Verify serialization - let json = serde_json::to_value(&capabilities).unwrap(); - assert!(json["extensions"]["io.modelcontextprotocol/apps"].is_object()); - assert!(json["tools"].is_object()); - } - - #[test] - fn test_extensions_deserialization() { - // Test deserializing capabilities with extensions from JSON - let json = serde_json::json!({ - "extensions": { - "io.modelcontextprotocol/ui": { - "mimeTypes": ["text/html;profile=mcp-app"] - } - }, - "sampling": {} - }); - - let capabilities: ClientCapabilities = serde_json::from_value(json).unwrap(); - assert!(capabilities.extensions.is_some()); - let extensions = capabilities.extensions.unwrap(); - assert!(extensions.contains_key("io.modelcontextprotocol/ui")); - let ui_ext = extensions.get("io.modelcontextprotocol/ui").unwrap(); - assert!(ui_ext.contains_key("mimeTypes")); - } - - #[test] - fn test_extensions_empty_settings() { - // Test that empty extension settings work (indicates support with no settings) - let mut extensions = ExtensionCapabilities::new(); - extensions.insert( - "io.modelcontextprotocol/oauth-client-credentials".to_string(), - JsonObject::new(), - ); - - let capabilities = ClientCapabilities::builder() - .enable_extensions_with(extensions) - .build(); - - let json = serde_json::to_value(&capabilities).unwrap(); - assert_eq!( - json["extensions"]["io.modelcontextprotocol/oauth-client-credentials"], - serde_json::json!({}) - ); - } -} diff --git a/vendor/rmcp/src/model/content.rs b/vendor/rmcp/src/model/content.rs deleted file mode 100644 index 11b2f0e53..000000000 --- a/vendor/rmcp/src/model/content.rs +++ /dev/null @@ -1,464 +0,0 @@ -//! Content types that flow between agents, tools, prompts, and LLMs. -//! -//! The core union is [`ContentBlock`] (text | image | audio | resource_link | resource), -//! matching the MCP 2025-11-25 `ContentBlock` definition. Each variant carries optional -//! [`Annotations`] and `_meta` inline. -//! -//! [`SamplingMessageContentBlock`] extends the union with `tool_use` and `tool_result` -//! variants for sampling messages (SEP-1577). - -// ToolUseContent/ToolResultContent are SEP-2577-deprecated; internal references are expected. -#![expect(deprecated)] -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; - -use super::{Annotations, MetaObject, resource::ResourceContents}; - -// --------------------------------------------------------------------------- -// Flat content structs -// --------------------------------------------------------------------------- - -/// Text content block (spec `TextContent`). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct TextContent { - /// The text content of the message. - pub text: String, - /// Optional protocol-level metadata for this content block. - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, - /// Optional annotations describing how the client should use this content. - #[serde(skip_serializing_if = "Option::is_none")] - pub annotations: Option, -} - -impl TextContent { - pub fn new(text: impl Into) -> Self { - Self { - text: text.into(), - meta: None, - annotations: None, - } - } - - pub fn with_meta(mut self, meta: MetaObject) -> Self { - self.meta = Some(meta); - self - } - - pub fn with_annotations(mut self, annotations: Annotations) -> Self { - self.annotations = Some(annotations); - self - } -} - -/// Image content with base64-encoded data (spec `ImageContent`). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ImageContent { - /// The base64-encoded image data. - pub data: String, - /// The MIME type of the image (e.g. `image/png`). - pub mime_type: String, - /// Optional protocol-level metadata for this content block. - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, - /// Optional annotations describing how the client should use this content. - #[serde(skip_serializing_if = "Option::is_none")] - pub annotations: Option, -} - -impl ImageContent { - pub fn new(data: impl Into, mime_type: impl Into) -> Self { - Self { - data: data.into(), - mime_type: mime_type.into(), - meta: None, - annotations: None, - } - } - - pub fn with_meta(mut self, meta: MetaObject) -> Self { - self.meta = Some(meta); - self - } - - pub fn with_annotations(mut self, annotations: Annotations) -> Self { - self.annotations = Some(annotations); - self - } -} - -/// Audio content with base64-encoded data (spec `AudioContent`). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct AudioContent { - /// The base64-encoded audio data. - pub data: String, - /// The MIME type of the audio (e.g. `audio/wav`). - pub mime_type: String, - /// Optional protocol-level metadata for this content block. - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, - /// Optional annotations describing how the client should use this content. - #[serde(skip_serializing_if = "Option::is_none")] - pub annotations: Option, -} - -impl AudioContent { - pub fn new(data: impl Into, mime_type: impl Into) -> Self { - Self { - data: data.into(), - mime_type: mime_type.into(), - meta: None, - annotations: None, - } - } - - pub fn with_meta(mut self, meta: MetaObject) -> Self { - self.meta = Some(meta); - self - } - - pub fn with_annotations(mut self, annotations: Annotations) -> Self { - self.annotations = Some(annotations); - self - } -} - -/// Embedded resource content (spec `EmbeddedResource`). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct EmbeddedResource { - /// The embedded resource contents (text or blob). - pub resource: ResourceContents, - /// Optional protocol-level metadata for this content block. - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, - /// Optional annotations describing how the client should use this content. - #[serde(skip_serializing_if = "Option::is_none")] - pub annotations: Option, -} - -impl EmbeddedResource { - pub fn new(resource: ResourceContents) -> Self { - Self { - resource, - meta: None, - annotations: None, - } - } - - pub fn get_text(&self) -> String { - match &self.resource { - ResourceContents::TextResourceContents { text, .. } => text.clone(), - _ => String::new(), - } - } - - pub fn with_meta(mut self, meta: MetaObject) -> Self { - self.meta = Some(meta); - self - } - - pub fn with_annotations(mut self, annotations: Annotations) -> Self { - self.annotations = Some(annotations); - self - } -} - -/// Tool call request from assistant (SEP-1577). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -#[deprecated( - since = "2.0.0", - note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" -)] -pub struct ToolUseContent { - pub id: String, - pub name: String, - pub input: super::JsonObject, - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -/// Tool execution result in user message (SEP-1577). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -#[deprecated( - since = "2.0.0", - note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" -)] -pub struct ToolResultContent { - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, - pub tool_use_id: String, - pub content: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub structured_content: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub is_error: Option, -} - -impl ToolUseContent { - pub fn new(id: impl Into, name: impl Into, input: super::JsonObject) -> Self { - Self { - id: id.into(), - name: name.into(), - input, - meta: None, - } - } -} - -impl ToolResultContent { - pub fn new(tool_use_id: impl Into, content: Vec) -> Self { - Self { - meta: None, - tool_use_id: tool_use_id.into(), - content, - structured_content: None, - is_error: None, - } - } - - pub fn error(tool_use_id: impl Into, content: Vec) -> Self { - Self { - meta: None, - tool_use_id: tool_use_id.into(), - content, - structured_content: None, - is_error: Some(true), - } - } -} - -// --------------------------------------------------------------------------- -// ContentBlock — the unified content union (spec `ContentBlock`) -// --------------------------------------------------------------------------- - -/// Unified content block union (spec `ContentBlock`). -/// -/// `text | image | audio | resource_link | resource` -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub enum ContentBlock { - Text(TextContent), - Image(ImageContent), - Audio(AudioContent), - Resource(EmbeddedResource), - ResourceLink(super::resource::Resource), -} - -impl ContentBlock { - pub fn json(json: S) -> Result { - let json = serde_json::to_string(&json).map_err(|e| { - crate::ErrorData::internal_error( - "fail to serialize response to json", - Some(json!( - {"reason": e.to_string()} - )), - ) - })?; - Ok(ContentBlock::text(json)) - } - - pub fn text(text: impl Into) -> Self { - ContentBlock::Text(TextContent::new(text)) - } - - pub fn image(data: impl Into, mime_type: impl Into) -> Self { - ContentBlock::Image(ImageContent::new(data, mime_type)) - } - - pub fn audio(data: impl Into, mime_type: impl Into) -> Self { - ContentBlock::Audio(AudioContent::new(data, mime_type)) - } - - pub fn resource(resource: ResourceContents) -> Self { - ContentBlock::Resource(EmbeddedResource::new(resource)) - } - - pub fn embedded_text(uri: impl Into, content: impl Into) -> Self { - ContentBlock::Resource(EmbeddedResource::new( - ResourceContents::TextResourceContents { - uri: uri.into(), - mime_type: Some("text/plain".to_string()), - text: content.into(), - meta: None, - }, - )) - } - - pub fn resource_link(resource: super::resource::Resource) -> Self { - ContentBlock::ResourceLink(resource) - } - - pub fn as_text(&self) -> Option<&TextContent> { - match self { - ContentBlock::Text(text) => Some(text), - _ => None, - } - } - - pub fn as_image(&self) -> Option<&ImageContent> { - match self { - ContentBlock::Image(image) => Some(image), - _ => None, - } - } - - pub fn as_resource(&self) -> Option<&EmbeddedResource> { - match self { - ContentBlock::Resource(resource) => Some(resource), - _ => None, - } - } - - pub fn as_resource_link(&self) -> Option<&super::resource::Resource> { - match self { - ContentBlock::ResourceLink(link) => Some(link), - _ => None, - } - } - - pub fn as_audio(&self) -> Option<&AudioContent> { - match self { - ContentBlock::Audio(audio) => Some(audio), - _ => None, - } - } -} - -// --------------------------------------------------------------------------- -// JsonContent (unchanged) -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct JsonContent(S); - -// --------------------------------------------------------------------------- -// IntoContents -// --------------------------------------------------------------------------- - -/// Types that can be converted into a list of content blocks. -pub trait IntoContents { - fn into_contents(self) -> Vec; -} - -impl IntoContents for ContentBlock { - fn into_contents(self) -> Vec { - vec![self] - } -} - -impl IntoContents for String { - fn into_contents(self) -> Vec { - vec![ContentBlock::text(self)] - } -} - -impl IntoContents for () { - fn into_contents(self) -> Vec { - vec![] - } -} - -#[cfg(test)] -mod tests { - use serde_json; - - use super::*; - - #[test] - fn test_image_content_serialization() { - let image = ImageContent::new("base64data", "image/png"); - let json = serde_json::to_string(&image).unwrap(); - assert!(json.contains("mimeType")); - assert!(!json.contains("mime_type")); - } - - #[test] - fn test_audio_content_serialization() { - let audio = AudioContent::new("base64audiodata", "audio/wav"); - let json = serde_json::to_string(&audio).unwrap(); - assert!(json.contains("mimeType")); - assert!(!json.contains("mime_type")); - } - - #[test] - fn test_audio_content_has_meta() { - let audio = AudioContent::new("data", "audio/wav").with_meta(MetaObject::default()); - let json = serde_json::to_value(&audio).unwrap(); - assert!(json.get("_meta").is_some()); - } - - #[test] - fn test_resource_link_serialization() { - use super::super::resource::Resource; - - let resource_link = ContentBlock::ResourceLink(Resource { - uri: "file:///test.txt".to_string(), - name: "test.txt".to_string(), - title: None, - description: Some("A test file".to_string()), - mime_type: Some("text/plain".to_string()), - size: Some(100), - icons: None, - meta: None, - annotations: None, - }); - - let json = serde_json::to_string(&resource_link).unwrap(); - assert!(json.contains("\"type\":\"resource_link\"")); - assert!(json.contains("\"uri\":\"file:///test.txt\"")); - assert!(json.contains("\"name\":\"test.txt\"")); - } - - #[test] - fn test_resource_link_deserialization() { - let json = r#"{ - "type": "resource_link", - "uri": "file:///example.txt", - "name": "example.txt", - "description": "Example file", - "mimeType": "text/plain" - }"#; - - let content: ContentBlock = serde_json::from_str(json).unwrap(); - - if let ContentBlock::ResourceLink(resource) = content { - assert_eq!(resource.uri, "file:///example.txt"); - assert_eq!(resource.name, "example.txt"); - assert_eq!(resource.description, Some("Example file".to_string())); - assert_eq!(resource.mime_type, Some("text/plain".to_string())); - } else { - panic!("Expected ResourceLink variant"); - } - } - - #[test] - fn test_content_block_text_with_annotations() { - let block = ContentBlock::Text( - TextContent::new("hello").with_annotations(Annotations::default().with_priority(0.8)), - ); - let json = serde_json::to_value(&block).unwrap(); - assert_eq!(json["type"], "text"); - assert_eq!(json["text"], "hello"); - assert_eq!(json["annotations"]["priority"], 0.8_f32); - } -} diff --git a/vendor/rmcp/src/model/elicitation_schema.rs b/vendor/rmcp/src/model/elicitation_schema.rs deleted file mode 100644 index 3cc24ca4c..000000000 --- a/vendor/rmcp/src/model/elicitation_schema.rs +++ /dev/null @@ -1,2237 +0,0 @@ -//! Type-safe schema definitions for MCP elicitation requests. -//! -//! This module provides strongly-typed schema definitions for elicitation requests -//! that comply with the MCP 2025-06-18 specification. Elicitation schemas must be -//! objects with primitive-typed properties. -//! -//! # Example -//! -//! ```rust -//! use rmcp::model::*; -//! -//! let schema = ElicitationSchema::builder() -//! .required_email("email") -//! .required_integer("age", 0, 150) -//! .optional_bool("newsletter", false) -//! .build(); -//! ``` - -use std::{borrow::Cow, collections::BTreeMap, marker::PhantomData}; - -use serde::{Deserialize, Serialize}; - -use crate::{const_string, model::ConstString}; - -// ============================================================================= -// CONST TYPES FOR JSON SCHEMA TYPE FIELD -// ============================================================================= - -const_string!(ObjectTypeConst = "object"); -const_string!(StringTypeConst = "string"); -const_string!(NumberTypeConst = "number"); -const_string!(IntegerTypeConst = "integer"); -const_string!(BooleanTypeConst = "boolean"); -const_string!(EnumTypeConst = "string"); -const_string!(ArrayTypeConst = "array"); - -// ============================================================================= -// PRIMITIVE SCHEMA DEFINITIONS -// ============================================================================= - -/// Primitive schema definition for elicitation properties. -/// -/// According to MCP 2025-06-18 specification, elicitation schemas must have -/// properties of primitive types only (string, number, integer, boolean, enum). -/// -/// Note: Put Enum as the first variant to avoid ambiguity during deserialization. -/// This is due to the fact that EnumSchema can contain StringSchema internally and serde -/// uses first match wins strategy when deserializing untagged enums. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(untagged)] -#[non_exhaustive] -pub enum PrimitiveSchemaDefinition { - /// Enum property (explicit enum schema) - Enum(EnumSchema), - /// String property (with optional enum constraint) - String(StringSchema), - /// Number property (with optional enum constraint) - Number(NumberSchema), - /// Integer property (with optional enum constraint) - Integer(IntegerSchema), - /// Boolean property - Boolean(BooleanSchema), -} - -// ============================================================================= -// STRING SCHEMA -// ============================================================================= - -/// String format types allowed by the MCP specification. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(rename_all = "kebab-case")] -#[non_exhaustive] -pub enum StringFormat { - /// Email address format - Email, - /// URI format - Uri, - /// Date format (YYYY-MM-DD) - Date, - /// Date-time format (ISO 8601) - DateTime, -} - -/// Schema definition for string properties. -/// -/// Compliant with MCP 2025-06-18 specification for elicitation schemas. -/// Supports only the fields allowed by the MCP spec: -/// - format limited to: "email", "uri", "date", "date-time" -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct StringSchema { - /// Type discriminator - #[serde(rename = "type")] - pub type_: StringTypeConst, - - /// Optional title for the schema - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option>, - - /// Human-readable description - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option>, - - /// Minimum string length - #[serde(skip_serializing_if = "Option::is_none")] - pub min_length: Option, - - /// Maximum string length - #[serde(skip_serializing_if = "Option::is_none")] - pub max_length: Option, - - /// String format - limited to: "email", "uri", "date", "date-time" - #[serde(skip_serializing_if = "Option::is_none")] - pub format: Option, - - /// Default value - #[serde(skip_serializing_if = "Option::is_none")] - pub default: Option, -} - -impl Default for StringSchema { - fn default() -> Self { - Self { - type_: StringTypeConst, - title: None, - description: None, - min_length: None, - max_length: None, - format: None, - default: None, - } - } -} - -impl StringSchema { - /// Create a new string schema - pub fn new() -> Self { - Self::default() - } - - /// Create an email string schema - pub fn email() -> Self { - Self { - format: Some(StringFormat::Email), - ..Default::default() - } - } - - /// Create a URI string schema - pub fn uri() -> Self { - Self { - format: Some(StringFormat::Uri), - ..Default::default() - } - } - - /// Create a date string schema - pub fn date() -> Self { - Self { - format: Some(StringFormat::Date), - ..Default::default() - } - } - - /// Create a date-time string schema - pub fn date_time() -> Self { - Self { - format: Some(StringFormat::DateTime), - ..Default::default() - } - } - - /// Set title - pub fn title(mut self, title: impl Into>) -> Self { - self.title = Some(title.into()); - self - } - - /// Set description - pub fn description(mut self, description: impl Into>) -> Self { - self.description = Some(description.into()); - self - } - - /// Set minimum and maximum length - pub fn with_length(mut self, min: u32, max: u32) -> Result { - if min > max { - return Err("min_length must be <= max_length"); - } - self.min_length = Some(min); - self.max_length = Some(max); - Ok(self) - } - - /// Set minimum and maximum length (panics on invalid input) - pub fn length(mut self, min: u32, max: u32) -> Self { - assert!(min <= max, "min_length must be <= max_length"); - self.min_length = Some(min); - self.max_length = Some(max); - self - } - - /// Set minimum length - pub fn min_length(mut self, min: u32) -> Self { - self.min_length = Some(min); - self - } - - /// Set maximum length - pub fn max_length(mut self, max: u32) -> Self { - self.max_length = Some(max); - self - } - - /// Set format (limited to: "email", "uri", "date", "date-time") - pub fn format(mut self, format: StringFormat) -> Self { - self.format = Some(format); - self - } - - /// Set default value - pub fn with_default(mut self, default: impl Into) -> Self { - self.default = Some(default.into()); - self - } -} - -// ============================================================================= -// NUMBER SCHEMA -// ============================================================================= - -/// Schema definition for number properties (floating-point). -/// -/// Compliant with MCP 2025-06-18 specification for elicitation schemas. -/// Supports only the fields allowed by the MCP spec. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct NumberSchema { - /// Type discriminator - #[serde(rename = "type")] - pub type_: NumberTypeConst, - - /// Optional title for the schema - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option>, - - /// Human-readable description - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option>, - - /// Minimum value (inclusive) - #[serde(skip_serializing_if = "Option::is_none")] - pub minimum: Option, - - /// Maximum value (inclusive) - #[serde(skip_serializing_if = "Option::is_none")] - pub maximum: Option, - - /// Default value - #[serde(skip_serializing_if = "Option::is_none")] - pub default: Option, -} - -impl Default for NumberSchema { - fn default() -> Self { - Self { - type_: NumberTypeConst, - title: None, - description: None, - minimum: None, - maximum: None, - default: None, - } - } -} - -impl NumberSchema { - /// Create a new number schema - pub fn new() -> Self { - Self::default() - } - - /// Set minimum and maximum (inclusive) - pub fn with_range(mut self, min: f64, max: f64) -> Result { - if min > max { - return Err("minimum must be <= maximum"); - } - self.minimum = Some(min); - self.maximum = Some(max); - Ok(self) - } - - /// Set minimum and maximum (panics on invalid input) - pub fn range(mut self, min: f64, max: f64) -> Self { - assert!(min <= max, "minimum must be <= maximum"); - self.minimum = Some(min); - self.maximum = Some(max); - self - } - - /// Set minimum (inclusive) - pub fn minimum(mut self, min: f64) -> Self { - self.minimum = Some(min); - self - } - - /// Set maximum (inclusive) - pub fn maximum(mut self, max: f64) -> Self { - self.maximum = Some(max); - self - } - - /// Set title - pub fn title(mut self, title: impl Into>) -> Self { - self.title = Some(title.into()); - self - } - - /// Set description - pub fn description(mut self, description: impl Into>) -> Self { - self.description = Some(description.into()); - self - } - - /// Set default value - pub fn with_default(mut self, default: f64) -> Self { - self.default = Some(default); - self - } -} - -// ============================================================================= -// INTEGER SCHEMA -// ============================================================================= - -/// Schema definition for integer properties. -/// -/// Compliant with MCP 2025-06-18 specification for elicitation schemas. -/// Supports only the fields allowed by the MCP spec. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct IntegerSchema { - /// Type discriminator - #[serde(rename = "type")] - pub type_: IntegerTypeConst, - - /// Optional title for the schema - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option>, - - /// Human-readable description - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option>, - - /// Minimum value (inclusive) - #[serde(skip_serializing_if = "Option::is_none")] - pub minimum: Option, - - /// Maximum value (inclusive) - #[serde(skip_serializing_if = "Option::is_none")] - pub maximum: Option, - - /// Default value - #[serde(skip_serializing_if = "Option::is_none")] - pub default: Option, -} - -impl Default for IntegerSchema { - fn default() -> Self { - Self { - type_: IntegerTypeConst, - title: None, - description: None, - minimum: None, - maximum: None, - default: None, - } - } -} - -impl IntegerSchema { - /// Create a new integer schema - pub fn new() -> Self { - Self::default() - } - - /// Set minimum and maximum (inclusive) - pub fn with_range(mut self, min: i64, max: i64) -> Result { - if min > max { - return Err("minimum must be <= maximum"); - } - self.minimum = Some(min); - self.maximum = Some(max); - Ok(self) - } - - /// Set minimum and maximum (panics on invalid input) - pub fn range(mut self, min: i64, max: i64) -> Self { - assert!(min <= max, "minimum must be <= maximum"); - self.minimum = Some(min); - self.maximum = Some(max); - self - } - - /// Set minimum (inclusive) - pub fn minimum(mut self, min: i64) -> Self { - self.minimum = Some(min); - self - } - - /// Set maximum (inclusive) - pub fn maximum(mut self, max: i64) -> Self { - self.maximum = Some(max); - self - } - - /// Set title - pub fn title(mut self, title: impl Into>) -> Self { - self.title = Some(title.into()); - self - } - - /// Set description - pub fn description(mut self, description: impl Into>) -> Self { - self.description = Some(description.into()); - self - } - - /// Set default value - pub fn with_default(mut self, default: i64) -> Self { - self.default = Some(default); - self - } -} - -// ============================================================================= -// BOOLEAN SCHEMA -// ============================================================================= - -/// Schema definition for boolean properties. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct BooleanSchema { - /// Type discriminator - #[serde(rename = "type")] - pub type_: BooleanTypeConst, - - /// Optional title for the schema - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option>, - - /// Human-readable description - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option>, - - /// Default value - #[serde(skip_serializing_if = "Option::is_none")] - pub default: Option, -} - -impl Default for BooleanSchema { - fn default() -> Self { - Self { - type_: BooleanTypeConst, - title: None, - description: None, - default: None, - } - } -} - -impl BooleanSchema { - /// Create a new boolean schema - pub fn new() -> Self { - Self::default() - } - - /// Set title - pub fn title(mut self, title: impl Into>) -> Self { - self.title = Some(title.into()); - self - } - - /// Set description - pub fn description(mut self, description: impl Into>) -> Self { - self.description = Some(description.into()); - self - } - - /// Set default value - pub fn with_default(mut self, default: bool) -> Self { - self.default = Some(default); - self - } -} - -// ============================================================================= -// ENUM SCHEMA -// ============================================================================= - -/// Schema definition for enum properties. -/// -/// Represent single entry for titled item -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ConstTitle { - #[serde(rename = "const")] - pub const_: String, - pub title: String, -} - -impl ConstTitle { - /// Create a new ConstTitle. - pub fn new(const_: impl Into, title: impl Into) -> Self { - Self { - const_: const_.into(), - title: title.into(), - } - } -} - -/// Legacy enum schema, keep for backward compatibility -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct LegacyEnumSchema { - #[serde(rename = "type")] - pub type_: StringTypeConst, - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option>, - #[serde(rename = "enum")] - pub enum_: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub enum_names: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub default: Option, -} - -impl LegacyEnumSchema { - pub fn new(enum_values: Vec) -> Self { - Self { - type_: StringTypeConst, - title: None, - description: None, - enum_: enum_values, - enum_names: None, - default: None, - } - } -} - -/// Untitled single-select -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct UntitledSingleSelectEnumSchema { - #[serde(rename = "type")] - pub type_: StringTypeConst, - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option>, - #[serde(rename = "enum")] - pub enum_: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub default: Option, -} - -/// Titled single-select -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct TitledSingleSelectEnumSchema { - #[serde(rename = "type")] - pub type_: StringTypeConst, - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option>, - #[serde(rename = "oneOf")] - pub one_of: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub default: Option, -} - -impl TitledSingleSelectEnumSchema { - /// Create a new TitledSingleSelectEnumSchema. - pub fn new(one_of: Vec) -> Self { - Self { - type_: StringTypeConst, - title: None, - description: None, - one_of, - default: None, - } - } -} - -/// Combined single-select -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(untagged)] -#[non_exhaustive] -pub enum SingleSelectEnumSchema { - Untitled(UntitledSingleSelectEnumSchema), - Titled(TitledSingleSelectEnumSchema), -} - -/// Items for untitled multi-select options -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct UntitledItems { - #[serde(rename = "type")] - pub type_: StringTypeConst, - #[serde(rename = "enum")] - pub enum_: Vec, -} - -impl UntitledItems { - pub fn new(enum_values: Vec) -> Self { - Self { - type_: StringTypeConst, - enum_: enum_values, - } - } -} - -/// Items for titled multi-select options -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct TitledItems { - // MCP spec requires "anyOf" for multi-select enums (allows any combination) - // Alias "oneOf" for compatibility with schemars - #[serde(rename = "anyOf", alias = "oneOf")] - pub any_of: Vec, -} - -impl TitledItems { - /// Create a new TitledItems. - pub fn new(any_of: Vec) -> Self { - Self { any_of } - } -} - -/// Multi-select untitled options -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct UntitledMultiSelectEnumSchema { - #[serde(rename = "type")] - pub type_: ArrayTypeConst, - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub min_items: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_items: Option, - pub items: UntitledItems, - #[serde(skip_serializing_if = "Option::is_none")] - pub default: Option>, -} - -/// Multi-select titled options -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct TitledMultiSelectEnumSchema { - #[serde(rename = "type")] - pub type_: ArrayTypeConst, - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub min_items: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_items: Option, - pub items: TitledItems, - #[serde(skip_serializing_if = "Option::is_none")] - pub default: Option>, -} - -impl TitledMultiSelectEnumSchema { - /// Create a new TitledMultiSelectEnumSchema. - pub fn new(items: TitledItems) -> Self { - Self { - type_: ArrayTypeConst, - title: None, - description: None, - min_items: None, - max_items: None, - items, - default: None, - } - } - - /// Set the title. - pub fn with_title(mut self, title: impl Into>) -> Self { - self.title = Some(title.into()); - self - } - - /// Set the description. - pub fn with_description(mut self, description: impl Into>) -> Self { - self.description = Some(description.into()); - self - } - - /// Set the minimum number of items. - pub fn with_min_items(mut self, min_items: u64) -> Self { - self.min_items = Some(min_items); - self - } - - /// Set the maximum number of items. - pub fn with_max_items(mut self, max_items: u64) -> Self { - self.max_items = Some(max_items); - self - } - - /// Set the default values. - pub fn with_default(mut self, default: Vec) -> Self { - self.default = Some(default); - self - } -} - -/// Multi-select enum options -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(untagged)] -#[non_exhaustive] -pub enum MultiSelectEnumSchema { - Untitled(UntitledMultiSelectEnumSchema), - Titled(TitledMultiSelectEnumSchema), -} - -/// Compliant with MCP 2025-06-18 specification for elicitation schemas. -/// Enums must have string type for values and can optionally include human-readable names. -/// -/// # Example -/// -/// ```rust -/// use rmcp::model::*; -/// -/// let enum_schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()]) -/// .multiselect() -/// .min_items(1u64).expect("Min items should be correct value") -/// .max_items(4u64).expect("Max items should be correct value") -/// .description("Country code") -/// .build(); -/// ``` -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(untagged)] -#[non_exhaustive] -pub enum EnumSchema { - Single(SingleSelectEnumSchema), - Multi(MultiSelectEnumSchema), - Legacy(LegacyEnumSchema), -} - -/// Marker type for single-select enum builder -#[derive(Debug)] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct SingleSelect; -/// Marker type for multi-select enum builder -#[derive(Debug)] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct MultiSelect; -/// Builder for EnumSchema -/// Allows to create various enum schema types (single/multi select, titled/untitled) -/// with validation of provided parameters -/// -/// # Example -/// ```rust -/// use rmcp::model::*; -/// let enum_schema = EnumSchema::builder(vec!["Red".to_string(), "Green".to_string(), "Blue".to_string()]) -/// .multiselect() -/// .enum_titles(vec!["Red Color".to_string(), "Green Color".to_string(), "Blue Color".to_string()]) -/// .expect("Number of titles should match number of values") -/// .min_items(1u64).expect("Min items should be correct value") -/// .max_items(3u64).expect("Max items should be correct value") -/// .description("Select your favorite colors") -/// .build(); -/// ``` -#[derive(Debug)] -pub struct EnumSchemaBuilder { - /// Enum values - enum_values: Vec, - /// If true generate Titled EnumSchema, UnTitled otherwise - titled: bool, - /// Title of EnumSchema - title: Option>, - /// Description of EnumSchema - description: Option>, - /// Titles of given enum values - enum_titles: Vec, - /// Minimum number of items to choose for MultiSelect - min_items: Option, - /// Maximum number of items to choose for MultiSelect - max_items: Option, - /// Default values for enum - default: Vec, - select_type: PhantomData, -} - -/// Default implementation for single-select enum builder -impl Default for EnumSchemaBuilder { - fn default() -> Self { - Self { - title: None, - description: None, - titled: false, - enum_titles: Vec::new(), - enum_values: Vec::new(), - min_items: None, - max_items: None, - default: Vec::new(), - select_type: PhantomData, - } - } -} - -/// Common enum schema builder methods -impl EnumSchemaBuilder { - /// Set title of enum schema - pub fn title(mut self, value: impl Into>) -> Self { - self.title = Some(value.into()); - self - } - - /// Set description of enum schema - pub fn description(mut self, value: impl Into>) -> Self { - self.description = Some(value.into()); - self - } - - /// Set enum as untitled - /// Clears any previously set titles - pub fn untitled(mut self) -> Self { - self.enum_titles = Vec::new(); - self.titled = false; - self - } - - /// Set titles to enum values. Also, implicitly set this enum schema as titled - pub fn enum_titles(mut self, titles: Vec) -> Result, String> { - if titles.len() != self.enum_values.len() { - return Err(format!( - "Provided number of titles do not match number of values: expected {}, but got {}", - self.enum_values.len(), - titles.len() - )); - } - self.titled = true; - self.enum_titles = titles; - Ok(self) - } -} - -/// Enum selection builder for single-select enums -impl EnumSchemaBuilder { - pub fn new(values: Vec) -> EnumSchemaBuilder { - EnumSchemaBuilder { - enum_values: values, - ..Default::default() - } - } - - /// Transition to multi-select enum builder. - /// - /// Clears any previously set default values and resets min/max items. - /// After this transition, you can use `min_items()`, `max_items()`, and - /// `with_default()` for multi-select semantics. - pub fn multiselect(self) -> EnumSchemaBuilder { - EnumSchemaBuilder { - enum_values: self.enum_values, - titled: self.titled, - title: self.title, - description: self.description, - enum_titles: self.enum_titles, - min_items: None, - max_items: None, - default: Vec::new(), // Clear default for multi-select - select_type: PhantomData, - } - } - - /// Set default value - pub fn with_default( - mut self, - default_value: impl Into, - ) -> Result, String> { - let value: String = default_value.into(); - if !self.enum_values.contains(&value) { - return Err("Provided default value is not in enum values".to_string()); - } - self.default = vec![value]; - Ok(self) - } - - /// Build enum schema - pub fn build(mut self) -> EnumSchema { - match self.titled { - false => EnumSchema::Single(SingleSelectEnumSchema::Untitled( - UntitledSingleSelectEnumSchema { - type_: StringTypeConst, - title: self.title, - description: self.description, - enum_: self.enum_values, - default: self.default.pop(), - }, - )), - true => EnumSchema::Single(SingleSelectEnumSchema::Titled( - TitledSingleSelectEnumSchema { - type_: StringTypeConst, - title: self.title, - description: self.description, - one_of: self - .enum_titles - .into_iter() - .zip(self.enum_values) - .map(|(title, const_)| ConstTitle { const_, title }) - .collect(), - default: self.default.pop(), - }, - )), - } - } -} - -/// Enum selection builder for multi-select enums -impl EnumSchemaBuilder { - /// Set enum as single-select - /// If it was multi-select, clear default values - pub fn single_select(self) -> EnumSchemaBuilder { - EnumSchemaBuilder { - enum_values: self.enum_values, - titled: self.titled, - title: self.title, - description: self.description, - enum_titles: self.enum_titles, - min_items: None, - max_items: None, - default: Vec::new(), // Clear default for single-select - select_type: PhantomData, - } - } - - /// Set default values - pub fn with_default( - mut self, - default_values: Vec, - ) -> Result, String> { - for value in &default_values { - if !self.enum_values.contains(value) { - return Err("One of the provided default values is not in enum values".to_string()); - } - } - if let Some(min) = self.min_items - && (default_values.len() as u64) < min - { - return Err("Number of provided default values is less than min_items".to_string()); - } - if let Some(max) = self.max_items - && (default_values.len() as u64) > max - { - return Err("Number of provided default values is greater than max_items".to_string()); - } - self.default = default_values; - Ok(self) - } - - /// Set minimal number of items for multi-select enum options - pub fn min_items(mut self, value: u64) -> Result, String> { - if let Some(max) = self.max_items - && value > max - { - return Err("Provided value is greater than max_items".to_string()); - } - self.min_items = Some(value); - Ok(self) - } - - /// Set maximal number of items for multi-select enum options - pub fn max_items(mut self, value: u64) -> Result, String> { - if let Some(min) = self.min_items - && value < min - { - return Err("Provided value is less than min_items".to_string()); - } - self.max_items = Some(value); - Ok(self) - } - - /// Build enum schema - pub fn build(self) -> EnumSchema { - match self.titled { - false => EnumSchema::Multi(MultiSelectEnumSchema::Untitled( - UntitledMultiSelectEnumSchema { - type_: ArrayTypeConst, - title: self.title, - description: self.description, - min_items: self.min_items, - max_items: self.max_items, - items: UntitledItems { - type_: StringTypeConst, - enum_: self.enum_values, - }, - default: if self.default.is_empty() { - None - } else { - Some(self.default) - }, - }, - )), - true => EnumSchema::Multi(MultiSelectEnumSchema::Titled(TitledMultiSelectEnumSchema { - type_: ArrayTypeConst, - title: self.title, - description: self.description, - min_items: self.min_items, - max_items: self.max_items, - items: TitledItems { - any_of: self - .enum_titles - .into_iter() - .zip(self.enum_values) - .map(|(title, const_)| ConstTitle { const_, title }) - .collect(), - }, - default: if self.default.is_empty() { - None - } else { - Some(self.default) - }, - })), - } - } -} - -impl EnumSchema { - /// Creates a new `EnumSchemaBuilder` with the given enum values. - /// - /// This convenience method allows you to construct an enum schema by specifying - /// the possible string values for the enum. Use the returned builder to further - /// configure the schema before building it. - /// - /// # Arguments - /// - /// * `values` - A vector of strings representing the allowed enum values. - /// - /// # Example - /// - /// ``` - /// use rmcp::model::*; - /// - /// let enum_schema = EnumSchema::builder(vec!["A".to_string(), "B".to_string()]). - /// with_default("A"). - /// expect("Default value should be valid"). - /// enum_titles(vec!["Option A".to_string(), "Option B".to_string()]). - /// expect("Number of titles should match number of values"). - /// build(); - /// ``` - pub fn builder(values: Vec) -> EnumSchemaBuilder { - EnumSchemaBuilder::new(values) - } -} - -// ============================================================================= -// ELICITATION SCHEMA -// ============================================================================= - -/// Type-safe elicitation schema for requesting structured user input. -/// -/// This enforces the MCP 2025-06-18 specification that elicitation schemas -/// must be objects with primitive-typed properties. -/// -/// # Example -/// -/// ```rust -/// use rmcp::model::*; -/// -/// let schema = ElicitationSchema::builder() -/// .required_email("email") -/// .required_integer("age", 0, 150) -/// .optional_bool("newsletter", false) -/// .build(); -/// ``` -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct ElicitationSchema { - /// Always "object" for elicitation schemas - #[serde(rename = "type")] - pub type_: ObjectTypeConst, - - /// Optional title for the schema - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option>, - - /// Property definitions (must be primitive types) - pub properties: BTreeMap, - - /// List of required property names - #[serde(skip_serializing_if = "Option::is_none")] - pub required: Option>, - - /// Optional description of what this schema represents - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option>, -} - -impl ElicitationSchema { - /// Create a new elicitation schema with the given properties - pub fn new(properties: BTreeMap) -> Self { - Self { - type_: ObjectTypeConst, - title: None, - properties, - required: None, - description: None, - } - } - - /// Convert from a JSON Schema object (typically generated by schemars) - /// - /// This allows converting from JsonObject to ElicitationSchema, which is useful - /// when working with automatically generated schemas from types. - /// - /// # Example - /// - /// ```rust,ignore - /// use rmcp::model::*; - /// - /// let json_schema = schema_for_type::(); - /// let elicitation_schema = ElicitationSchema::from_json_schema(json_schema)?; - /// ``` - /// - /// # Errors - /// - /// Returns a [`serde_json::Error`] if the JSON object cannot be deserialized - /// into a valid ElicitationSchema. - pub fn from_json_schema(schema: crate::model::JsonObject) -> Result { - serde_json::from_value(serde_json::Value::Object(schema)) - } - - /// Generate an ElicitationSchema from a Rust type that implements JsonSchema - /// - /// This is a convenience method that combines schema generation and conversion. - /// It uses the same schema generation settings as the rest of the MCP SDK. - /// - /// # Example - /// - /// ```rust,ignore - /// use rmcp::model::*; - /// use schemars::JsonSchema; - /// use serde::{Deserialize, Serialize}; - /// - /// #[derive(JsonSchema, Serialize, Deserialize)] - /// struct UserInput { - /// name: String, - /// age: u32, - /// } - /// - /// let schema = ElicitationSchema::from_type::()?; - /// ``` - /// - /// # Errors - /// - /// Returns a [`serde_json::Error`] if the generated schema cannot be converted - /// to a valid ElicitationSchema. - #[cfg(feature = "schemars")] - pub fn from_type() -> Result - where - T: schemars::JsonSchema, - { - use crate::schemars::generate::SchemaSettings; - - let mut settings = SchemaSettings::draft07(); - settings.transforms = vec![Box::new(schemars::transform::AddNullable::default())]; - let generator = settings.into_generator(); - let schema = generator.into_root_schema_for::(); - let object = serde_json::to_value(schema).expect("failed to serialize schema"); - match object { - serde_json::Value::Object(object) => Self::from_json_schema(object), - _ => panic!( - "Schema serialization produced non-object value: expected JSON object but got {:?}", - object - ), - } - } - - /// Set the required fields - pub fn with_required(mut self, required: Vec) -> Self { - self.required = Some(required); - self - } - - /// Set the title - pub fn with_title(mut self, title: impl Into>) -> Self { - self.title = Some(title.into()); - self - } - - /// Set the description - pub fn with_description(mut self, description: impl Into>) -> Self { - self.description = Some(description.into()); - self - } - - /// Create a builder for constructing elicitation schemas fluently - pub fn builder() -> ElicitationSchemaBuilder { - ElicitationSchemaBuilder::new() - } -} - -// ============================================================================= -// BUILDER -// ============================================================================= - -/// Fluent builder for constructing elicitation schemas. -/// -/// # Example -/// -/// ```rust -/// use rmcp::model::*; -/// -/// let schema = ElicitationSchema::builder() -/// .required_email("email") -/// .required_integer("age", 0, 150) -/// .optional_bool("newsletter", false) -/// .description("User registration") -/// .build(); -/// ``` -#[derive(Debug, Default)] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct ElicitationSchemaBuilder { - pub properties: BTreeMap, - pub required: Vec, - pub title: Option>, - pub description: Option>, -} - -impl ElicitationSchemaBuilder { - /// Create a new builder - pub fn new() -> Self { - Self::default() - } - - /// Add a property to the schema - pub fn property(mut self, name: impl Into, schema: PrimitiveSchemaDefinition) -> Self { - self.properties.insert(name.into(), schema); - self - } - - /// Add a required property to the schema - pub fn required_property( - mut self, - name: impl Into, - schema: PrimitiveSchemaDefinition, - ) -> Self { - let name_str = name.into(); - self.required.push(name_str.clone()); - self.properties.insert(name_str, schema); - self - } - - // =========================================================================== - // TYPED PROPERTY METHODS - Cleaner API without PrimitiveSchemaDefinition wrapper - // =========================================================================== - - /// Add a string property with custom builder (required) - pub fn string_property( - mut self, - name: impl Into, - f: impl FnOnce(StringSchema) -> StringSchema, - ) -> Self { - self.properties.insert( - name.into(), - PrimitiveSchemaDefinition::String(f(StringSchema::new())), - ); - self - } - - /// Add a required string property with custom builder - pub fn required_string_property( - mut self, - name: impl Into, - f: impl FnOnce(StringSchema) -> StringSchema, - ) -> Self { - let name_str = name.into(); - self.required.push(name_str.clone()); - self.properties.insert( - name_str, - PrimitiveSchemaDefinition::String(f(StringSchema::new())), - ); - self - } - - /// Add a number property with custom builder - pub fn number_property( - mut self, - name: impl Into, - f: impl FnOnce(NumberSchema) -> NumberSchema, - ) -> Self { - self.properties.insert( - name.into(), - PrimitiveSchemaDefinition::Number(f(NumberSchema::new())), - ); - self - } - - /// Add a required number property with custom builder - pub fn required_number_property( - mut self, - name: impl Into, - f: impl FnOnce(NumberSchema) -> NumberSchema, - ) -> Self { - let name_str = name.into(); - self.required.push(name_str.clone()); - self.properties.insert( - name_str, - PrimitiveSchemaDefinition::Number(f(NumberSchema::new())), - ); - self - } - - /// Add an integer property with custom builder - pub fn integer_property( - mut self, - name: impl Into, - f: impl FnOnce(IntegerSchema) -> IntegerSchema, - ) -> Self { - self.properties.insert( - name.into(), - PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())), - ); - self - } - - /// Add a required integer property with custom builder - pub fn required_integer_property( - mut self, - name: impl Into, - f: impl FnOnce(IntegerSchema) -> IntegerSchema, - ) -> Self { - let name_str = name.into(); - self.required.push(name_str.clone()); - self.properties.insert( - name_str, - PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())), - ); - self - } - - /// Add a boolean property with custom builder - pub fn bool_property( - mut self, - name: impl Into, - f: impl FnOnce(BooleanSchema) -> BooleanSchema, - ) -> Self { - self.properties.insert( - name.into(), - PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())), - ); - self - } - - /// Add a required boolean property with custom builder - pub fn required_bool_property( - mut self, - name: impl Into, - f: impl FnOnce(BooleanSchema) -> BooleanSchema, - ) -> Self { - let name_str = name.into(); - self.required.push(name_str.clone()); - self.properties.insert( - name_str, - PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())), - ); - self - } - - // =========================================================================== - // CONVENIENCE METHODS - Simple common cases - // =========================================================================== - - /// Add a required string property - pub fn required_string(self, name: impl Into) -> Self { - self.required_property(name, PrimitiveSchemaDefinition::String(StringSchema::new())) - } - - /// Add an optional string property - pub fn optional_string(self, name: impl Into) -> Self { - self.property(name, PrimitiveSchemaDefinition::String(StringSchema::new())) - } - - /// Add a required email property - pub fn required_email(self, name: impl Into) -> Self { - self.required_property( - name, - PrimitiveSchemaDefinition::String(StringSchema::email()), - ) - } - - /// Add an optional email property - pub fn optional_email(self, name: impl Into) -> Self { - self.property( - name, - PrimitiveSchemaDefinition::String(StringSchema::email()), - ) - } - - /// Add a required string property with custom builder - pub fn required_string_with( - self, - name: impl Into, - f: impl FnOnce(StringSchema) -> StringSchema, - ) -> Self { - self.required_property( - name, - PrimitiveSchemaDefinition::String(f(StringSchema::new())), - ) - } - - /// Add an optional string property with custom builder - pub fn optional_string_with( - self, - name: impl Into, - f: impl FnOnce(StringSchema) -> StringSchema, - ) -> Self { - self.property( - name, - PrimitiveSchemaDefinition::String(f(StringSchema::new())), - ) - } - - // Convenience methods for numbers - - /// Add a required number property with range - pub fn required_number(self, name: impl Into, min: f64, max: f64) -> Self { - self.required_property( - name, - PrimitiveSchemaDefinition::Number(NumberSchema::new().range(min, max)), - ) - } - - /// Add an optional number property with range - pub fn optional_number(self, name: impl Into, min: f64, max: f64) -> Self { - self.property( - name, - PrimitiveSchemaDefinition::Number(NumberSchema::new().range(min, max)), - ) - } - - /// Add a required number property with custom builder - pub fn required_number_with( - self, - name: impl Into, - f: impl FnOnce(NumberSchema) -> NumberSchema, - ) -> Self { - self.required_property( - name, - PrimitiveSchemaDefinition::Number(f(NumberSchema::new())), - ) - } - - /// Add an optional number property with custom builder - pub fn optional_number_with( - self, - name: impl Into, - f: impl FnOnce(NumberSchema) -> NumberSchema, - ) -> Self { - self.property( - name, - PrimitiveSchemaDefinition::Number(f(NumberSchema::new())), - ) - } - - // Convenience methods for integers - - /// Add a required integer property with range - pub fn required_integer(self, name: impl Into, min: i64, max: i64) -> Self { - self.required_property( - name, - PrimitiveSchemaDefinition::Integer(IntegerSchema::new().range(min, max)), - ) - } - - /// Add an optional integer property with range - pub fn optional_integer(self, name: impl Into, min: i64, max: i64) -> Self { - self.property( - name, - PrimitiveSchemaDefinition::Integer(IntegerSchema::new().range(min, max)), - ) - } - - /// Add a required integer property with custom builder - pub fn required_integer_with( - self, - name: impl Into, - f: impl FnOnce(IntegerSchema) -> IntegerSchema, - ) -> Self { - self.required_property( - name, - PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())), - ) - } - - /// Add an optional integer property with custom builder - pub fn optional_integer_with( - self, - name: impl Into, - f: impl FnOnce(IntegerSchema) -> IntegerSchema, - ) -> Self { - self.property( - name, - PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())), - ) - } - - // Convenience methods for booleans - - /// Add a required boolean property - pub fn required_bool(self, name: impl Into) -> Self { - self.required_property( - name, - PrimitiveSchemaDefinition::Boolean(BooleanSchema::new()), - ) - } - - /// Add an optional boolean property with default value - pub fn optional_bool(self, name: impl Into, default: bool) -> Self { - self.property( - name, - PrimitiveSchemaDefinition::Boolean(BooleanSchema::new().with_default(default)), - ) - } - - /// Add a required boolean property with custom builder - pub fn required_bool_with( - self, - name: impl Into, - f: impl FnOnce(BooleanSchema) -> BooleanSchema, - ) -> Self { - self.required_property( - name, - PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())), - ) - } - - /// Add an optional boolean property with custom builder - pub fn optional_bool_with( - self, - name: impl Into, - f: impl FnOnce(BooleanSchema) -> BooleanSchema, - ) -> Self { - self.property( - name, - PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())), - ) - } - - // Enum convenience methods - - /// Add a required enum property using EnumSchema - pub fn required_enum_schema(self, name: impl Into, enum_schema: EnumSchema) -> Self { - self.required_property(name, PrimitiveSchemaDefinition::Enum(enum_schema)) - } - - /// Add an optional enum property using EnumSchema - pub fn optional_enum_schema(self, name: impl Into, enum_schema: EnumSchema) -> Self { - self.property(name, PrimitiveSchemaDefinition::Enum(enum_schema)) - } - - /// Mark an existing property as required - pub fn mark_required(mut self, name: impl Into) -> Self { - self.required.push(name.into()); - self - } - - /// Set the schema title - pub fn title(mut self, title: impl Into>) -> Self { - self.title = Some(title.into()); - self - } - - /// Set the schema description - pub fn description(mut self, description: impl Into>) -> Self { - self.description = Some(description.into()); - self - } - - /// Build the elicitation schema with validation - /// - /// # Errors - /// - /// Returns an error if: - /// - Required fields reference non-existent properties - /// - No properties are defined (empty schema) - pub fn build(self) -> Result { - // Validate that all required fields exist in properties - if !self.required.is_empty() { - for field_name in &self.required { - if !self.properties.contains_key(field_name) { - return Err("Required field does not exist in properties"); - } - } - } - - Ok(ElicitationSchema { - type_: ObjectTypeConst, - title: self.title, - properties: self.properties, - required: if self.required.is_empty() { - None - } else { - Some(self.required) - }, - description: self.description, - }) - } - - /// Build the elicitation schema without validation (panics on invalid schema) - /// - /// # Panics - /// - /// Panics if required fields reference non-existent properties - pub fn build_unchecked(self) -> ElicitationSchema { - self.build().expect("Invalid elicitation schema") - } -} - -#[cfg(test)] -mod tests { - use anyhow::anyhow; - use rstest::rstest; - use serde_json::json; - - use super::*; - - fn string_schema_json() -> serde_json::Value { - serde_json::to_value(StringSchema::email().description("Email address")).unwrap() - } - - fn number_schema_json() -> serde_json::Value { - serde_json::to_value( - NumberSchema::new() - .range(0.0, 100.0) - .description("Percentage"), - ) - .unwrap() - } - - fn integer_schema_json() -> serde_json::Value { - serde_json::to_value(IntegerSchema::new().range(0, 150)).unwrap() - } - - fn boolean_schema_json() -> serde_json::Value { - serde_json::to_value(BooleanSchema::new().with_default(true)).unwrap() - } - - #[rstest] - #[case::string_schema( - string_schema_json, - json!({ - "type": "string", - "format": "email", - "description": "Email address", - }) - )] - #[case::number_schema( - number_schema_json, - json!({ - "type": "number", - "description": "Percentage", - "minimum": 0.0, - "maximum": 100.0, - }) - )] - #[case::integer_schema( - integer_schema_json, - json!({ - "type": "integer", - "minimum": 0, - "maximum": 150, - }) - )] - #[case::boolean_schema( - boolean_schema_json, - json!({ - "type": "boolean", - "default": true, - }) - )] - fn primitive_schema_serializes_to_expected_json( - #[case] schema_json: fn() -> serde_json::Value, - #[case] expected: serde_json::Value, - ) { - assert_eq!(schema_json(), expected); - } - - #[test] - fn test_enum_schema_untitled_single_select_serialization() { - let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()]) - .description("Country code") - .build(); - let json = serde_json::to_value(&schema).unwrap(); - - assert_eq!(json["type"], "string"); - assert_eq!(json["enum"], json!(["US", "UK"])); - assert_eq!(json["description"], "Country code"); - } - - #[test] - fn test_enum_schema_untitled_multi_select_serialization() -> anyhow::Result<()> { - let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()]) - .multiselect() - .min_items(1u64) - .map_err(|e| anyhow!("{e}"))? - .max_items(4u64) - .map_err(|e| anyhow!("{e}"))? - .description("Country code") - .build(); - let json = serde_json::to_value(&schema)?; - - assert_eq!(json["type"], "array"); - assert_eq!(json["minItems"], 1u64); - assert_eq!(json["maxItems"], 4u64); - assert_eq!(json["items"], json!({"type":"string", "enum":["US", "UK"]})); - assert_eq!(json["description"], "Country code"); - Ok(()) - } - - #[test] - fn test_enum_schema_titled_single_select_serialization() -> anyhow::Result<()> { - let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()]) - .enum_titles(vec![ - "United States".to_string(), - "United Kingdom".to_string(), - ]) - .map_err(|e| anyhow!("{e}"))? - .description("Country code") - .build(); - let json = serde_json::to_value(&schema)?; - - assert_eq!(json["type"], "string"); - assert_eq!( - json["oneOf"], - json!([ - {"const": "US", "title":"United States"}, - {"const": "UK", "title":"United Kingdom"} - ]) - ); - assert_eq!(json["description"], "Country code"); - Ok(()) - } - - #[test] - fn test_enum_schema_legacy_serialization() -> anyhow::Result<()> { - let schema = EnumSchema::Legacy(LegacyEnumSchema { - type_: StringTypeConst, - title: Some("Legacy Enum".into()), - description: Some("A legacy enum schema".into()), - enum_: vec!["A".to_string(), "B".to_string()], - enum_names: Some(vec!["Option A".to_string(), "Option B".to_string()]), - default: None, - }); - let json = serde_json::to_value(&schema)?; - - assert_eq!(json["type"], "string"); - assert_eq!(json["title"], "Legacy Enum"); - assert_eq!(json["description"], "A legacy enum schema"); - assert_eq!(json["enum"], json!(["A", "B"])); - assert_eq!(json["enumNames"], json!(["Option A", "Option B"])); - Ok(()) - } - - #[test] - fn test_legacy_enum_schema_roundtrip_preserves_enum_names() -> anyhow::Result<()> { - // Regression test for: legacy enum payload with `enumNames` was silently - // deserialized as `UntitledSingleSelectEnumSchema` (which has no `enumNames` - // field), causing the array to be dropped on re-serialization. - let input = serde_json::json!({ - "type": "object", - "properties": { - "choice": { - "type": "string", - "enum": ["opt1", "opt2", "opt3"], - "enumNames": ["Option One", "Option Two", "Option Three"] - } - } - }); - let schema: ElicitationSchema = serde_json::from_value(input.clone())?; - let output = serde_json::to_value(&schema)?; - assert_eq!( - output["properties"]["choice"]["enumNames"], - serde_json::json!(["Option One", "Option Two", "Option Three"]), - ); - Ok(()) - } - - #[test] - fn test_legacy_enum_schema_no_enum_names_omits_field() -> anyhow::Result<()> { - // `LegacyEnumSchema` with `enum_names: None` must not serialize `"enumNames": null`. - let schema = EnumSchema::Legacy(LegacyEnumSchema { - type_: StringTypeConst, - title: None, - description: None, - enum_: vec!["a".to_string(), "b".to_string()], - enum_names: None, - default: None, - }); - let json = serde_json::to_value(&schema)?; - assert!(!json.as_object().unwrap().contains_key("enumNames")); - Ok(()) - } - - #[test] - fn test_enum_schema_titled_multi_select_serialization() -> anyhow::Result<()> { - let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()]) - .enum_titles(vec![ - "United States".to_string(), - "United Kingdom".to_string(), - ]) - .map_err(|e| anyhow!("{e}"))? - .multiselect() - .min_items(1u64) - .map_err(|e| anyhow!("{e}"))? - .max_items(4u64) - .map_err(|e| anyhow!("{e}"))? - .description("Country code") - .build(); - let json = serde_json::to_value(&schema)?; - - assert_eq!(json["type"], "array"); - assert_eq!(json["minItems"], 1u64); - assert_eq!(json["maxItems"], 4u64); - assert_eq!( - json["items"], - json!({"anyOf":[ - {"const":"US","title":"United States"}, - {"const":"UK","title":"United Kingdom"} - ]}) - ); - assert_eq!(json["description"], "Country code"); - Ok(()) - } - - #[test] - fn test_enum_schema_single_select_with_default() -> anyhow::Result<()> { - let schema = EnumSchema::builder(vec![ - "red".to_string(), - "green".to_string(), - "blue".to_string(), - ]) - .with_default("green") - .map_err(|e| anyhow!("{e}"))? - .description("Favorite color") - .build(); - - let json = serde_json::to_value(&schema)?; - - assert_eq!(json["type"], "string"); - assert_eq!(json["enum"], json!(["red", "green", "blue"])); - assert_eq!(json["default"], "green"); - assert_eq!(json["description"], "Favorite color"); - Ok(()) - } - - #[test] - fn test_enum_schema_multi_select_with_default() -> anyhow::Result<()> { - let schema = EnumSchema::builder(vec![ - "red".to_string(), - "green".to_string(), - "blue".to_string(), - ]) - .multiselect() - .with_default(vec!["red".to_string(), "blue".to_string()]) - .map_err(|e| anyhow!("{e}"))? - .min_items(1) - .map_err(|e| anyhow!("{e}"))? - .max_items(3) - .map_err(|e| anyhow!("{e}"))? - .build(); - - let json = serde_json::to_value(&schema)?; - - assert_eq!(json["type"], "array"); - assert_eq!(json["items"]["enum"], json!(["red", "green", "blue"])); - assert_eq!(json["default"], json!(["red", "blue"])); - assert_eq!(json["minItems"], 1); - assert_eq!(json["maxItems"], 3); - Ok(()) - } - - #[test] - fn test_enum_schema_transition_clears_defaults() -> anyhow::Result<()> { - // Start with single-select with default - let builder = EnumSchema::builder(vec!["A".to_string(), "B".to_string()]) - .with_default("A") - .map_err(|e| anyhow!("{e}"))?; - - // Transition to multi-select should clear the default - let schema = builder.multiselect().build(); - let json = serde_json::to_value(&schema)?; - - assert_eq!(json["type"], "array"); - assert!(json["default"].is_null()); - Ok(()) - } - - #[test] - fn test_enum_schema_multi_to_single_transition() -> anyhow::Result<()> { - // Start with multi-select with defaults - let builder = EnumSchema::builder(vec!["A".to_string(), "B".to_string(), "C".to_string()]) - .multiselect() - .with_default(vec!["A".to_string(), "B".to_string()]) - .map_err(|e| anyhow!("{e}"))? - .min_items(1) - .map_err(|e| anyhow!("{e}"))?; - - // Transition back to single-select should clear defaults and min/max items - let schema = builder.single_select().build(); - let json = serde_json::to_value(&schema)?; - - assert_eq!(json["type"], "string"); - assert!(json["default"].is_null()); - assert!(json["minItems"].is_null()); - assert!(json["maxItems"].is_null()); - Ok(()) - } - - #[test] - fn test_enum_schema_invalid_single_default() { - let result = EnumSchema::builder(vec!["A".to_string(), "B".to_string()]).with_default("C"); - - assert!(result.is_err()); - assert_eq!( - result.unwrap_err(), - "Provided default value is not in enum values" - ); - } - - #[test] - fn test_enum_schema_invalid_multi_default() { - let result = EnumSchema::builder(vec!["A".to_string(), "B".to_string()]) - .multiselect() - .with_default(vec!["A".to_string(), "C".to_string()]); - - assert!(result.is_err()); - assert_eq!( - result.unwrap_err(), - "One of the provided default values is not in enum values" - ); - } - - #[test] - fn test_enum_schema_titled_with_default() -> anyhow::Result<()> { - let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()]) - .enum_titles(vec![ - "United States".to_string(), - "United Kingdom".to_string(), - ]) - .map_err(|e| anyhow!("{e}"))? - .with_default("UK") - .map_err(|e| anyhow!("{e}"))? - .build(); - - let json = serde_json::to_value(&schema)?; - - assert_eq!(json["type"], "string"); - assert_eq!(json["default"], "UK"); - assert_eq!( - json["oneOf"], - json!([ - {"const": "US", "title": "United States"}, - {"const": "UK", "title": "United Kingdom"} - ]) - ); - Ok(()) - } - - #[test] - fn test_enum_schema_untitled_after_titled() -> anyhow::Result<()> { - let schema = EnumSchema::builder(vec!["A".to_string(), "B".to_string()]) - .enum_titles(vec!["Option A".to_string(), "Option B".to_string()]) - .map_err(|e| anyhow!("{e}"))? - .untitled() - .build(); - - let json = serde_json::to_value(&schema)?; - - assert_eq!(json["type"], "string"); - assert_eq!(json["enum"], json!(["A", "B"])); - assert!(json["oneOf"].is_null()); - Ok(()) - } - - #[test] - fn test_primitive_schema_enum_deserialization() { - // Test that enum schemas deserialize as Enum variant, not String - let json = json!({ - "type": "string", - "enum": ["a", "b"] - }); - let schema: PrimitiveSchemaDefinition = serde_json::from_value(json).unwrap(); - assert!(matches!(schema, PrimitiveSchemaDefinition::Enum(_))); - // Test that string schemas deserialize as String variant - let json = json!({ - "type": "string" - }); - let schema: PrimitiveSchemaDefinition = serde_json::from_value(json).unwrap(); - assert!(matches!(schema, PrimitiveSchemaDefinition::String(_))); - } - - #[test] - fn test_elicitation_schema_builder_simple() { - let schema = ElicitationSchema::builder() - .required_email("email") - .optional_bool("newsletter", false) - .build() - .unwrap(); - - assert_eq!(schema.properties.len(), 2); - assert!(schema.properties.contains_key("email")); - assert!(schema.properties.contains_key("newsletter")); - assert_eq!(schema.required, Some(vec!["email".to_string()])); - } - - #[test] - fn test_elicitation_schema_builder_complex() { - let enum_schema = - EnumSchema::builder(vec!["US".to_string(), "UK".to_string(), "CA".to_string()]).build(); - let schema = ElicitationSchema::builder() - .required_string_with("name", |s| s.length(1, 100)) - .required_integer("age", 0, 150) - .optional_bool("newsletter", false) - .required_enum_schema("country", enum_schema) - .description("User registration") - .build() - .unwrap(); - - assert_eq!(schema.properties.len(), 4); - assert_eq!( - schema.required, - Some(vec![ - "name".to_string(), - "age".to_string(), - "country".to_string() - ]) - ); - assert_eq!(schema.description.as_deref(), Some("User registration")); - } - - #[test] - fn test_elicitation_schema_serialization() { - let schema = ElicitationSchema::builder() - .required_string_with("name", |s| s.length(1, 100)) - .build() - .unwrap(); - - let json = serde_json::to_value(&schema).unwrap(); - - assert_eq!(json["type"], "object"); - assert!(json["properties"]["name"].is_object()); - assert_eq!(json["required"], json!(["name"])); - } - - #[test] - #[should_panic(expected = "minimum must be <= maximum")] - fn test_integer_range_validation() { - IntegerSchema::new().range(10, 5); // Should panic - } - - #[test] - #[should_panic(expected = "min_length must be <= max_length")] - fn test_string_length_validation() { - StringSchema::new().length(10, 5); // Should panic - } - - #[test] - fn test_integer_range_validation_with_result() { - let result = IntegerSchema::new().with_range(10, 5); - assert!(result.is_err()); - assert_eq!(result.unwrap_err(), "minimum must be <= maximum"); - } - - #[cfg(feature = "schemars")] - mod schemars_tests { - use anyhow::Result; - use schemars::JsonSchema; - use serde::{Deserialize, Serialize}; - use serde_json::json; - - use crate::model::ElicitationSchema; - - #[derive(Debug, Serialize, Deserialize, JsonSchema, Default)] - #[schemars(inline)] - #[schemars(extend("type" = "string"))] - enum TitledEnum { - #[schemars(title = "Title for the first value")] - #[default] - FirstValue, - #[schemars(title = "Title for the second value")] - SecondValue, - } - - #[derive(Debug, Serialize, Deserialize, JsonSchema)] - #[schemars(inline)] - enum UntitledEnum { - First, - Second, - Third, - } - - fn default_untitled_multi_select() -> Vec { - vec![UntitledEnum::Second, UntitledEnum::Third] - } - - #[derive(Debug, Serialize, Deserialize, JsonSchema)] - #[schemars(description = "User information")] - struct UserInfo { - #[schemars(description = "User's name")] - pub name: String, - pub single_select_untitled: UntitledEnum, - #[schemars( - title = "Single Select Titled", - description = "Description for single select enum", - default - )] - pub single_select_titled: TitledEnum, - #[serde(default = "default_untitled_multi_select")] - pub multi_select_untitled: Vec, - #[schemars( - title = "Multi Select Titled", - description = "Multi Select Description" - )] - pub multi_select_titled: Vec, - } - - #[test] - fn test_schema_inference_for_enum_fields() -> Result<()> { - let schema = ElicitationSchema::from_type::()?; - - let json = serde_json::to_value(&schema)?; - assert_eq!(json["type"], "object"); - assert_eq!(json["description"], "User information"); - assert_eq!( - json["required"], - json!(["name", "single_select_untitled", "multi_select_titled"]) - ); - let properties = match json.get("properties") { - Some(serde_json::Value::Object(map)) => map, - _ => panic!("Schema does not have 'properties' field or it is not object type"), - }; - - assert_eq!(properties.len(), 5); - assert_eq!( - properties["name"], - json!({"description":"User's name", "type":"string"}) - ); - - assert_eq!( - properties["single_select_untitled"], - serde_json::json!({ - "type":"string", - "enum":["First", "Second", "Third"] - }) - ); - - assert_eq!( - properties["single_select_titled"], - json!({ - "type":"string", - "title":"Single Select Titled", - "description":"Description for single select enum", - "oneOf":[ - {"const":"FirstValue", "title":"Title for the first value"}, - {"const":"SecondValue", "title":"Title for the second value"} - ], - "default":"FirstValue" - }) - ); - assert_eq!( - properties["multi_select_untitled"], - serde_json::json!({ - "type":"array", - "items" : { - "type":"string", - "enum":["First", "Second", "Third"] - }, - "default":["Second", "Third"] - }) - ); - assert_eq!( - properties["multi_select_titled"], - serde_json::json!({ - "type":"array", - "title":"Multi Select Titled", - "description":"Multi Select Description", - "items":{ - "anyOf":[ - {"const":"FirstValue", "title":"Title for the first value"}, - {"const":"SecondValue", "title":"Title for the second value"} - ] - } - }) - ); - Ok(()) - } - } -} diff --git a/vendor/rmcp/src/model/extension.rs b/vendor/rmcp/src/model/extension.rs deleted file mode 100644 index 039fdf2eb..000000000 --- a/vendor/rmcp/src/model/extension.rs +++ /dev/null @@ -1,337 +0,0 @@ -//! A container for those extra data could be carried on request or notification. -//! -//! This file is copied and modified from crate [http](https://github.com/hyperium/http). -//! -//! - Original code license: -//! - Original code: -use std::{ - any::{Any, TypeId}, - collections::HashMap, - fmt, - hash::{BuildHasherDefault, Hasher}, -}; - -type AnyMap = HashMap, BuildHasherDefault>; - -// With TypeIds as keys, there's no need to hash them. They are already hashes -// themselves, coming from the compiler. The IdHasher just holds the u64 of -// the TypeId, and then returns it, instead of doing any bit fiddling. -#[derive(Default)] -struct IdHasher(u64); - -impl Hasher for IdHasher { - fn write(&mut self, _: &[u8]) { - unreachable!("TypeId calls write_u64"); - } - - #[inline] - fn write_u64(&mut self, id: u64) { - self.0 = id; - } - - #[inline] - fn finish(&self) -> u64 { - self.0 - } -} - -/// A type map of protocol extensions. -/// -/// `Extensions` can be used by `Request` `Notification` and `Response` to store -/// extra data derived from the underlying protocol. -#[derive(Clone, Default)] -pub struct Extensions { - // If extensions are never used, no need to carry around an empty HashMap. - // That's 3 words. Instead, this is only 1 word. - map: Option>, -} - -impl Extensions { - /// Create an empty `Extensions`. - #[inline] - pub const fn new() -> Extensions { - Extensions { map: None } - } - - /// Insert a type into this `Extensions`. - /// - /// If a extension of this type already existed, it will - /// be returned and replaced with the new one. - /// - /// # Example - /// - /// ``` - /// # use rmcp::model::Extensions; - /// let mut ext = Extensions::new(); - /// assert!(ext.insert(5i32).is_none()); - /// assert!(ext.insert(4u8).is_none()); - /// assert_eq!(ext.insert(9i32), Some(5i32)); - /// ``` - pub fn insert(&mut self, val: T) -> Option { - self.map - .get_or_insert_with(Box::default) - .insert(TypeId::of::(), Box::new(val)) - .and_then(|boxed| boxed.into_any().downcast().ok().map(|boxed| *boxed)) - } - - /// Get a reference to a type previously inserted on this `Extensions`. - /// - /// # Example - /// - /// ``` - /// # use rmcp::model::Extensions; - /// let mut ext = Extensions::new(); - /// assert!(ext.get::().is_none()); - /// ext.insert(5i32); - /// - /// assert_eq!(ext.get::(), Some(&5i32)); - /// ``` - pub fn get(&self) -> Option<&T> { - self.map - .as_ref() - .and_then(|map| map.get(&TypeId::of::())) - .and_then(|boxed| (**boxed).as_any().downcast_ref()) - } - - /// Get a mutable reference to a type previously inserted on this `Extensions`. - /// - /// # Example - /// - /// ``` - /// # use rmcp::model::Extensions; - /// let mut ext = Extensions::new(); - /// ext.insert(String::from("Hello")); - /// ext.get_mut::().unwrap().push_str(" World"); - /// - /// assert_eq!(ext.get::().unwrap(), "Hello World"); - /// ``` - pub fn get_mut(&mut self) -> Option<&mut T> { - self.map - .as_mut() - .and_then(|map| map.get_mut(&TypeId::of::())) - .and_then(|boxed| (**boxed).as_any_mut().downcast_mut()) - } - - /// Get a mutable reference to a type, inserting `value` if not already present on this - /// `Extensions`. - /// - /// # Example - /// - /// ``` - /// # use rmcp::model::Extensions; - /// let mut ext = Extensions::new(); - /// *ext.get_or_insert(1i32) += 2; - /// - /// assert_eq!(*ext.get::().unwrap(), 3); - /// ``` - pub fn get_or_insert(&mut self, value: T) -> &mut T { - self.get_or_insert_with(|| value) - } - - /// Get a mutable reference to a type, inserting the value created by `f` if not already present - /// on this `Extensions`. - /// - /// # Example - /// - /// ``` - /// # use rmcp::model::Extensions; - /// let mut ext = Extensions::new(); - /// *ext.get_or_insert_with(|| 1i32) += 2; - /// - /// assert_eq!(*ext.get::().unwrap(), 3); - /// ``` - pub fn get_or_insert_with T>( - &mut self, - f: F, - ) -> &mut T { - let out = self - .map - .get_or_insert_with(Box::default) - .entry(TypeId::of::()) - .or_insert_with(|| Box::new(f())); - (**out).as_any_mut().downcast_mut().unwrap() - } - - /// Get a mutable reference to a type, inserting the type's default value if not already present - /// on this `Extensions`. - /// - /// # Example - /// - /// ``` - /// # use rmcp::model::Extensions; - /// let mut ext = Extensions::new(); - /// *ext.get_or_insert_default::() += 2; - /// - /// assert_eq!(*ext.get::().unwrap(), 2); - /// ``` - pub fn get_or_insert_default(&mut self) -> &mut T { - self.get_or_insert_with(T::default) - } - - /// Remove a type from this `Extensions`. - /// - /// If a extension of this type existed, it will be returned. - /// - /// # Example - /// - /// ``` - /// # use rmcp::model::Extensions; - /// let mut ext = Extensions::new(); - /// ext.insert(5i32); - /// assert_eq!(ext.remove::(), Some(5i32)); - /// assert!(ext.get::().is_none()); - /// ``` - pub fn remove(&mut self) -> Option { - self.map - .as_mut() - .and_then(|map| map.remove(&TypeId::of::())) - .and_then(|boxed| boxed.into_any().downcast().ok().map(|boxed| *boxed)) - } - - /// Clear the `Extensions` of all inserted extensions. - /// - /// # Example - /// - /// ``` - /// # use rmcp::model::Extensions; - /// let mut ext = Extensions::new(); - /// ext.insert(5i32); - /// ext.clear(); - /// - /// assert!(ext.get::().is_none()); - /// ``` - #[inline] - pub fn clear(&mut self) { - if let Some(ref mut map) = self.map { - map.clear(); - } - } - - /// Check whether the extension set is empty or not. - /// - /// # Example - /// - /// ``` - /// # use rmcp::model::Extensions; - /// let mut ext = Extensions::new(); - /// assert!(ext.is_empty()); - /// ext.insert(5i32); - /// assert!(!ext.is_empty()); - /// ``` - #[inline] - pub fn is_empty(&self) -> bool { - self.map.as_ref().is_none_or(|map| map.is_empty()) - } - - /// Get the number of extensions available. - /// - /// # Example - /// - /// ``` - /// # use rmcp::model::Extensions; - /// let mut ext = Extensions::new(); - /// assert_eq!(ext.len(), 0); - /// ext.insert(5i32); - /// assert_eq!(ext.len(), 1); - /// ``` - #[inline] - pub fn len(&self) -> usize { - self.map.as_ref().map_or(0, |map| map.len()) - } - - /// Extends `self` with another `Extensions`. - /// - /// If an instance of a specific type exists in both, the one in `self` is overwritten with the - /// one from `other`. - /// - /// # Example - /// - /// ``` - /// # use rmcp::model::Extensions; - /// let mut ext_a = Extensions::new(); - /// ext_a.insert(8u8); - /// ext_a.insert(16u16); - /// - /// let mut ext_b = Extensions::new(); - /// ext_b.insert(4u8); - /// ext_b.insert("hello"); - /// - /// ext_a.extend(ext_b); - /// assert_eq!(ext_a.len(), 3); - /// assert_eq!(ext_a.get::(), Some(&4u8)); - /// assert_eq!(ext_a.get::(), Some(&16u16)); - /// assert_eq!(ext_a.get::<&'static str>().copied(), Some("hello")); - /// ``` - pub fn extend(&mut self, other: Self) { - if let Some(other) = other.map { - if let Some(map) = &mut self.map { - map.extend(*other); - } else { - self.map = Some(other); - } - } - } -} - -impl fmt::Debug for Extensions { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Extensions").finish() - } -} - -trait AnyClone: Any { - fn clone_box(&self) -> Box; - fn as_any(&self) -> &dyn Any; - fn as_any_mut(&mut self) -> &mut dyn Any; - fn into_any(self: Box) -> Box; -} - -impl AnyClone for T { - fn clone_box(&self) -> Box { - Box::new(self.clone()) - } - - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn into_any(self: Box) -> Box { - self - } -} - -impl Clone for Box { - fn clone(&self) -> Self { - (**self).clone_box() - } -} - -#[test] -fn test_extensions() { - #[derive(Clone, Debug, PartialEq)] - struct MyType(i32); - - let mut extensions = Extensions::new(); - - extensions.insert(5i32); - extensions.insert(MyType(10)); - - assert_eq!(extensions.get(), Some(&5i32)); - assert_eq!(extensions.get_mut(), Some(&mut 5i32)); - - let ext2 = extensions.clone(); - - assert_eq!(extensions.remove::(), Some(5i32)); - assert!(extensions.get::().is_none()); - - // clone still has it - assert_eq!(ext2.get(), Some(&5i32)); - assert_eq!(ext2.get(), Some(&MyType(10))); - - assert_eq!(extensions.get::(), None); - assert_eq!(extensions.get(), Some(&MyType(10))); -} diff --git a/vendor/rmcp/src/model/meta.rs b/vendor/rmcp/src/model/meta.rs deleted file mode 100644 index 0a7121e4a..000000000 --- a/vendor/rmcp/src/model/meta.rs +++ /dev/null @@ -1,859 +0,0 @@ -use std::ops::{Deref, DerefMut}; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use super::{ - ClientCapabilities, ClientNotification, ClientRequest, CustomNotification, CustomRequest, - Extensions, Implementation, JsonObject, JsonRpcMessage, LoggingLevel, ProgressToken, - ProtocolVersion, RequestId, ServerNotification, ServerRequest, -}; - -/// Access to the metadata carried by a message envelope's [`Extensions`]. -/// -/// The metadata type differs by message kind: requests carry a -/// [`RequestMetaObject`] and notifications carry a [`NotificationMetaObject`]. -/// -/// The envelope extensions are the canonical runtime location for `_meta`: -/// deserialization strips the wire `params._meta` into the extensions (typed -/// params `meta` fields stay empty), and the service loop moves it into -/// [`RequestContext::meta`] / [`NotificationContext::meta`] before dispatch. -/// Typed params `meta` fields are honored when serializing outgoing messages; -/// on key conflicts the extensions-level metadata wins. -/// -/// [`RequestContext::meta`]: crate::service::RequestContext -/// [`NotificationContext::meta`]: crate::service::NotificationContext -pub trait GetMeta { - /// The metadata type for this message kind. - type Metadata: Default; - fn get_meta_mut(&mut self) -> &mut Self::Metadata; - fn get_meta(&self) -> &Self::Metadata; -} - -pub trait GetExtensions { - fn extensions(&self) -> &Extensions; - fn extensions_mut(&mut self) -> &mut Extensions; -} - -/// Trait for request params that contain the `_meta` field. -/// -/// Per the MCP spec, all request params may have an optional `_meta` -/// field ([`RequestMetaObject`]) that can contain a `progressToken` for -/// tracking long-running operations. -pub trait RequestParamsMeta { - /// Get a reference to the meta field - fn meta(&self) -> Option<&RequestMetaObject>; - /// Get a mutable reference to the meta field - fn meta_mut(&mut self) -> &mut Option; - /// Set the meta field - fn set_meta(&mut self, meta: RequestMetaObject) { - *self.meta_mut() = Some(meta); - } - /// Get the progress token from meta, if present - fn progress_token(&self) -> Option { - self.meta().and_then(|m| m.get_progress_token()) - } - /// Set a progress token in meta - fn set_progress_token(&mut self, token: ProgressToken) { - match self.meta_mut() { - Some(meta) => meta.set_progress_token(token), - none => { - let mut meta = RequestMetaObject::new(); - meta.set_progress_token(token); - *none = Some(meta); - } - } - } - /// Get the W3C `traceparent` value from meta, if present (SEP-414) - fn traceparent(&self) -> Option<&str> { - self.meta().and_then(|m| m.get_traceparent()) - } - /// Set the W3C `traceparent` value in meta (SEP-414) - fn set_traceparent(&mut self, value: &str) { - self.meta_or_default().set_traceparent(value); - } - /// Get the W3C `tracestate` value from meta, if present (SEP-414) - fn tracestate(&self) -> Option<&str> { - self.meta().and_then(|m| m.get_tracestate()) - } - /// Set the W3C `tracestate` value in meta (SEP-414) - fn set_tracestate(&mut self, value: &str) { - self.meta_or_default().set_tracestate(value); - } - /// Get the W3C `baggage` value from meta, if present (SEP-414) - fn baggage(&self) -> Option<&str> { - self.meta().and_then(|m| m.get_baggage()) - } - /// Set the W3C `baggage` value in meta (SEP-414) - fn set_baggage(&mut self, value: &str) { - self.meta_or_default().set_baggage(value); - } - /// Get a mutable reference to meta, inserting an empty one if absent. - fn meta_or_default(&mut self) -> &mut RequestMetaObject { - self.meta_mut().get_or_insert_with(RequestMetaObject::new) - } -} - -impl GetExtensions for CustomNotification { - fn extensions(&self) -> &Extensions { - &self.extensions - } - fn extensions_mut(&mut self) -> &mut Extensions { - &mut self.extensions - } -} - -impl GetMeta for CustomNotification { - type Metadata = NotificationMetaObject; - fn get_meta_mut(&mut self) -> &mut NotificationMetaObject { - self.extensions_mut().get_or_insert_default() - } - fn get_meta(&self) -> &NotificationMetaObject { - self.extensions() - .get::() - .unwrap_or(NotificationMetaObject::static_empty()) - } -} - -impl GetExtensions for CustomRequest { - fn extensions(&self) -> &Extensions { - &self.extensions - } - fn extensions_mut(&mut self) -> &mut Extensions { - &mut self.extensions - } -} - -impl GetMeta for CustomRequest { - type Metadata = RequestMetaObject; - fn get_meta_mut(&mut self) -> &mut RequestMetaObject { - self.extensions_mut().get_or_insert_default() - } - fn get_meta(&self) -> &RequestMetaObject { - self.extensions() - .get::() - .unwrap_or(RequestMetaObject::static_empty()) - } -} - -macro_rules! variant_extension { - ( - $Enum: ident: $Metadata: ident { - $($variant: ident)* - } - ) => { - impl GetExtensions for $Enum { - fn extensions(&self) -> &Extensions { - match self { - $( - $Enum::$variant(v) => &v.extensions, - )* - } - } - fn extensions_mut(&mut self) -> &mut Extensions { - match self { - $( - $Enum::$variant(v) => &mut v.extensions, - )* - } - } - } - impl GetMeta for $Enum { - type Metadata = $Metadata; - fn get_meta_mut(&mut self) -> &mut $Metadata { - self.extensions_mut().get_or_insert_default() - } - fn get_meta(&self) -> &$Metadata { - self.extensions().get::<$Metadata>().unwrap_or($Metadata::static_empty()) - } - } - }; -} - -variant_extension! { - ClientRequest: RequestMetaObject { - PingRequest - InitializeRequest - DiscoverRequest - CompleteRequest - SetLevelRequest - GetPromptRequest - ListPromptsRequest - ListResourcesRequest - ListResourceTemplatesRequest - ReadResourceRequest - SubscriptionsListenRequest - SubscribeRequest - UnsubscribeRequest - CallToolRequest - ListToolsRequest - CustomRequest - GetTaskRequest - UpdateTaskRequest - CancelTaskRequest - } -} - -variant_extension! { - ServerRequest: RequestMetaObject { - PingRequest - CreateMessageRequest - ListRootsRequest - ElicitRequest - CustomRequest - } -} - -variant_extension! { - ClientNotification: NotificationMetaObject { - CancelledNotification - ProgressNotification - InitializedNotification - RootsListChangedNotification - CustomNotification - } -} - -variant_extension! { - ServerNotification: NotificationMetaObject { - CancelledNotification - ProgressNotification - LoggingMessageNotification - ResourceUpdatedNotification - ResourceListChangedNotification - ToolListChangedNotification - PromptListChangedNotification - SubscriptionsAcknowledgedNotification - TaskStatusNotification - CustomNotification - } -} - -/// General-purpose `_meta` map (spec `MetaObject`). -/// -/// This is the metadata shape used by results, content blocks, and catalog -/// descriptors (tools, prompts, resources, roots, ...). It preserves arbitrary -/// extension keys and offers helpers for the reserved W3C Trace Context keys -/// (SEP-414). -/// -/// Request and notification `_meta` maps have additional reserved keys; see -/// [`RequestMetaObject`] and [`NotificationMetaObject`]. -#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)] -#[serde(transparent)] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct MetaObject(pub JsonObject); - -impl MetaObject { - /// Reserved `_meta` key for the W3C Trace Context `traceparent` value (SEP-414). - const TRACEPARENT_FIELD: &str = "traceparent"; - /// Reserved `_meta` key for the W3C Trace Context `tracestate` value (SEP-414). - const TRACESTATE_FIELD: &str = "tracestate"; - /// Reserved `_meta` key for the W3C Baggage value (SEP-414). - const BAGGAGE_FIELD: &str = "baggage"; - - /// Create an empty metadata map. - pub fn new() -> Self { - Self(JsonObject::new()) - } - - /// Read a string-valued `_meta` field, or `None` if absent or not a string. - fn get_str(&self, field: &str) -> Option<&str> { - self.0.get(field).and_then(Value::as_str) - } - - /// Write a string-valued `_meta` field. - fn set_str(&mut self, field: &str, value: impl Into) { - self.0 - .insert(field.to_string(), Value::String(value.into())); - } - - /// Get the W3C `traceparent` value (SEP-414), if present. - pub fn get_traceparent(&self) -> Option<&str> { - self.get_str(Self::TRACEPARENT_FIELD) - } - - /// Set the W3C `traceparent` value (SEP-414). - /// - /// ``` - /// use rmcp::model::MetaObject; - /// - /// let mut meta = MetaObject::new(); - /// meta.set_traceparent("00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"); - /// assert_eq!( - /// meta.get_traceparent(), - /// Some("00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"), - /// ); - /// ``` - pub fn set_traceparent(&mut self, value: impl Into) { - self.set_str(Self::TRACEPARENT_FIELD, value); - } - - /// Get the W3C `tracestate` value (SEP-414), if present. - pub fn get_tracestate(&self) -> Option<&str> { - self.get_str(Self::TRACESTATE_FIELD) - } - - /// Set the W3C `tracestate` value (SEP-414). - pub fn set_tracestate(&mut self, value: impl Into) { - self.set_str(Self::TRACESTATE_FIELD, value); - } - - /// Get the W3C `baggage` value (SEP-414), if present. - pub fn get_baggage(&self) -> Option<&str> { - self.get_str(Self::BAGGAGE_FIELD) - } - - /// Set the W3C `baggage` value (SEP-414). - pub fn set_baggage(&mut self, value: impl Into) { - self.set_str(Self::BAGGAGE_FIELD, value); - } - - /// Insert every entry of `other`, overwriting existing keys on conflict. - pub fn extend(&mut self, other: MetaObject) { - self.0.extend(other.0); - } - - fn decode_value(&self, key: &str) -> Option - where - T: for<'de> Deserialize<'de>, - { - self.0.get(key).and_then(|value| T::deserialize(value).ok()) - } - - fn insert_serialized(&mut self, key: &str, value: T) - where - T: Serialize, - { - let value = serde_json::to_value(value) - .expect("MCP meta helper value should serialize to valid JSON"); - self.0.insert(key.to_string(), value); - } -} - -impl Deref for MetaObject { - type Target = JsonObject; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for MetaObject { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -impl From for MetaObject { - fn from(object: JsonObject) -> Self { - Self(object) - } -} - -#[cfg(feature = "schemars")] -impl schemars::JsonSchema for MetaObject { - fn schema_name() -> std::borrow::Cow<'static, str> { - std::borrow::Cow::Borrowed("MetaObject") - } - - fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { - schemars::json_schema!({ - "description": "See [MCP general fields](https://modelcontextprotocol.io/specification/2026-07-28/basic#general-fields) for notes on _meta usage.", - "type": "object", - "additionalProperties": true, - }) - } -} - -/// The `_meta` map carried by requests (spec `RequestMetaObject`). -/// -/// In addition to arbitrary extension keys, requests reserve: -/// - `progressToken` for progress tracking -/// - `io.modelcontextprotocol/protocolVersion` (SEP-2575) -/// - `io.modelcontextprotocol/clientInfo` (SEP-2575) -/// - `io.modelcontextprotocol/clientCapabilities` (SEP-2575) -/// - `io.modelcontextprotocol/logLevel` (SEP-2575) -/// -/// The 2026-07-28 draft schema requires the protocol-version and -/// client-capabilities keys; client-info is optional. Earlier protocol versions -/// do not know them. All keys therefore stay optional at runtime and in the -/// generated (version-shared) JSON schema — use -/// [`RequestMetaObject::missing_required_keys`] to validate a request against -/// the negotiated protocol version. -/// -/// This type dereferences to [`MetaObject`] (and transitively to the underlying -/// map), so general helpers such as the SEP-414 trace-context accessors remain -/// available. -#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)] -#[serde(transparent)] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct RequestMetaObject(pub MetaObject); - -impl RequestMetaObject { - const PROGRESS_TOKEN_FIELD: &str = "progressToken"; - const META_KEY_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion"; - const META_KEY_CLIENT_INFO: &str = "io.modelcontextprotocol/clientInfo"; - const META_KEY_CLIENT_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities"; - const META_KEY_LOG_LEVEL: &str = "io.modelcontextprotocol/logLevel"; - - /// Request `_meta` keys the 2026-07-28 draft schema marks as required. - pub const DRAFT_REQUIRED_KEYS: [&str; 2] = [ - Self::META_KEY_PROTOCOL_VERSION, - Self::META_KEY_CLIENT_CAPABILITIES, - ]; - - /// Create an empty request metadata map. - pub fn new() -> Self { - Self::default() - } - - /// Create a new request meta with a progress token set - pub fn with_progress_token(token: ProgressToken) -> Self { - let mut meta = Self::new(); - meta.set_progress_token(token); - meta - } - - /// Create request metadata with the client context SEP-2575 requires on every request. - pub fn with_client_context( - protocol_version: ProtocolVersion, - client_info: Implementation, - client_capabilities: ClientCapabilities, - ) -> Self { - let mut meta = Self::new(); - meta.set_protocol_version(protocol_version); - meta.set_client_info(client_info); - meta.set_client_capabilities(client_capabilities); - meta - } - - pub(crate) fn static_empty() -> &'static Self { - static EMPTY: std::sync::OnceLock = std::sync::OnceLock::new(); - EMPTY.get_or_init(Default::default) - } - - /// Get the progress token carried in `_meta`, if present and valid. - pub fn get_progress_token(&self) -> Option { - self.0.decode_value(Self::PROGRESS_TOKEN_FIELD) - } - - /// Set the progress token carried in `_meta`. - pub fn set_progress_token(&mut self, token: ProgressToken) { - self.0.insert_serialized(Self::PROGRESS_TOKEN_FIELD, token); - } - - /// Get the MCP protocol version carried in `_meta`, if present and valid. - pub fn protocol_version(&self) -> Option { - self.0.decode_value(Self::META_KEY_PROTOCOL_VERSION) - } - - /// Set the MCP protocol version carried in `_meta`. - pub fn set_protocol_version(&mut self, protocol_version: ProtocolVersion) { - self.0.0.insert( - Self::META_KEY_PROTOCOL_VERSION.to_string(), - Value::String(protocol_version.to_string()), - ); - } - - /// Get the client implementation identity carried in `_meta`, if present and valid. - pub fn client_info(&self) -> Option { - self.0.decode_value(Self::META_KEY_CLIENT_INFO) - } - - /// Set the client implementation identity carried in `_meta`. - pub fn set_client_info(&mut self, client_info: Implementation) { - self.0 - .insert_serialized(Self::META_KEY_CLIENT_INFO, client_info); - } - - /// Get the client capabilities carried in `_meta`, if present and valid. - pub fn client_capabilities(&self) -> Option { - self.0.decode_value(Self::META_KEY_CLIENT_CAPABILITIES) - } - - /// Set the client capabilities carried in `_meta`. - pub fn set_client_capabilities(&mut self, client_capabilities: ClientCapabilities) { - self.0 - .insert_serialized(Self::META_KEY_CLIENT_CAPABILITIES, client_capabilities); - } - - /// Get the requested per-request log level carried in `_meta`, if present and valid. - pub fn log_level(&self) -> Option { - self.0.decode_value(Self::META_KEY_LOG_LEVEL) - } - - /// Set the requested per-request log level carried in `_meta`. - pub fn set_log_level(&mut self, log_level: LoggingLevel) { - self.0 - .insert_serialized(Self::META_KEY_LOG_LEVEL, log_level); - } - - /// Return the [`Self::DRAFT_REQUIRED_KEYS`] whose values are absent or - /// invalid in this map, if `protocol_version` requires them. - /// - /// A key counts as missing when it is not present *or* when its value does - /// not decode into the expected type (e.g. a numeric `protocolVersion` or - /// a string `clientInfo`), matching what the typed accessors return. - /// - /// Protocol versions before 2026-07-28 have no required request metadata, - /// so this always returns an empty list for them. - /// - /// # Examples - /// - /// ``` - /// use rmcp::model::{ProtocolVersion, RequestMetaObject}; - /// - /// let meta = RequestMetaObject::new(); - /// // Older protocols have no required request metadata. - /// assert!( - /// meta.missing_required_keys(&ProtocolVersion::V_2025_11_25) - /// .is_empty() - /// ); - /// // The 2026-07-28 protocol requires per-request context. - /// assert_eq!( - /// meta.missing_required_keys(&ProtocolVersion::V_2026_07_28), - /// RequestMetaObject::DRAFT_REQUIRED_KEYS.to_vec(), - /// ); - /// ``` - pub fn missing_required_keys(&self, protocol_version: &ProtocolVersion) -> Vec<&'static str> { - if protocol_version.as_str() < ProtocolVersion::V_2026_07_28.as_str() { - return Vec::new(); - } - let mut missing = Vec::new(); - if self.protocol_version().is_none() { - missing.push(Self::META_KEY_PROTOCOL_VERSION); - } - if self.client_capabilities().is_none() { - missing.push(Self::META_KEY_CLIENT_CAPABILITIES); - } - missing - } - - /// Insert every entry of `other`, overwriting existing keys on conflict. - pub fn extend(&mut self, other: RequestMetaObject) { - self.0.extend(other.0); - } -} - -impl Deref for RequestMetaObject { - type Target = MetaObject; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for RequestMetaObject { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -impl From for RequestMetaObject { - fn from(meta: MetaObject) -> Self { - Self(meta) - } -} - -impl From for RequestMetaObject { - fn from(object: JsonObject) -> Self { - Self(MetaObject(object)) - } -} - -#[cfg(feature = "schemars")] -impl schemars::JsonSchema for RequestMetaObject { - fn schema_name() -> std::borrow::Cow<'static, str> { - std::borrow::Cow::Borrowed("RequestMetaObject") - } - - fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { - let progress_token = generator.subschema_for::(); - let client_info = generator.subschema_for::(); - let client_capabilities = generator.subschema_for::(); - let log_level = generator.subschema_for::(); - // rmcp generates one schema shared by every supported protocol - // version, so the keys validated for 2026-07-28 are left - // optional here: a 2025-11-25 request whose `_meta` only carries - // `progressToken` is valid. Version-specific validation is available at - // runtime via [`RequestMetaObject::missing_required_keys`]. - schemars::json_schema!({ - "description": "Metadata reserved by MCP on requests. Extension keys are also allowed.", - "type": "object", - "properties": { - "progressToken": progress_token, - "io.modelcontextprotocol/protocolVersion": { - "type": "string", - }, - "io.modelcontextprotocol/clientInfo": client_info, - "io.modelcontextprotocol/clientCapabilities": client_capabilities, - "io.modelcontextprotocol/logLevel": log_level, - }, - "additionalProperties": true, - }) - } -} - -/// The `_meta` map carried by notifications (spec `NotificationMetaObject`). -/// -/// In addition to arbitrary extension keys, notifications reserve -/// `io.modelcontextprotocol/subscriptionId` to correlate a notification with a -/// prior subscription request. -/// -/// This type dereferences to [`MetaObject`] (and transitively to the underlying -/// map), so general helpers such as the SEP-414 trace-context accessors remain -/// available. -#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)] -#[serde(transparent)] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct NotificationMetaObject(pub MetaObject); - -impl NotificationMetaObject { - const META_KEY_SUBSCRIPTION_ID: &str = "io.modelcontextprotocol/subscriptionId"; - - /// Create an empty notification metadata map. - pub fn new() -> Self { - Self::default() - } - - pub(crate) fn static_empty() -> &'static Self { - static EMPTY: std::sync::OnceLock = std::sync::OnceLock::new(); - EMPTY.get_or_init(Default::default) - } - - /// Get the subscription id carried in `_meta`, if present and valid. - /// - /// # Examples - /// - /// ``` - /// use rmcp::model::{NotificationMetaObject, RequestId}; - /// - /// let mut meta = NotificationMetaObject::new(); - /// assert_eq!(meta.subscription_id(), None); - /// meta.set_subscription_id(RequestId::Number(7)); - /// assert_eq!(meta.subscription_id(), Some(RequestId::Number(7))); - /// ``` - pub fn subscription_id(&self) -> Option { - self.0.decode_value(Self::META_KEY_SUBSCRIPTION_ID) - } - - /// Set the subscription id carried in `_meta`. - pub fn set_subscription_id(&mut self, subscription_id: RequestId) { - self.0 - .insert_serialized(Self::META_KEY_SUBSCRIPTION_ID, subscription_id); - } - - /// Insert every entry of `other`, overwriting existing keys on conflict. - pub fn extend(&mut self, other: NotificationMetaObject) { - self.0.extend(other.0); - } -} - -impl Deref for NotificationMetaObject { - type Target = MetaObject; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for NotificationMetaObject { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -impl From for NotificationMetaObject { - fn from(meta: MetaObject) -> Self { - Self(meta) - } -} - -impl From for NotificationMetaObject { - fn from(object: JsonObject) -> Self { - Self(MetaObject(object)) - } -} - -#[cfg(feature = "schemars")] -impl schemars::JsonSchema for NotificationMetaObject { - fn schema_name() -> std::borrow::Cow<'static, str> { - std::borrow::Cow::Borrowed("NotificationMetaObject") - } - - fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { - let subscription_id = generator.subschema_for::(); - schemars::json_schema!({ - "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", - "type": "object", - "properties": { - "io.modelcontextprotocol/subscriptionId": subscription_id, - }, - "additionalProperties": true, - }) - } -} - -impl JsonRpcMessage -where - Req: GetExtensions, - Noti: GetExtensions, -{ - pub fn insert_extension(&mut self, value: T) { - match self { - JsonRpcMessage::Request(json_rpc_request) => { - json_rpc_request.request.extensions_mut().insert(value); - } - JsonRpcMessage::Notification(json_rpc_notification) => { - json_rpc_notification - .notification - .extensions_mut() - .insert(value); - } - _ => {} - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::model::NumberOrString; - - #[derive(Default)] - struct Params { - meta: Option, - } - - impl RequestParamsMeta for Params { - fn meta(&self) -> Option<&RequestMetaObject> { - self.meta.as_ref() - } - fn meta_mut(&mut self) -> &mut Option { - &mut self.meta - } - } - - const TRACEPARENT: &str = "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"; - - #[test] - fn trace_context_round_trip() { - let mut meta = MetaObject::new(); - meta.set_traceparent(TRACEPARENT); - meta.set_tracestate("vendor1=value1,vendor2=value2"); - meta.set_baggage("userId=alice,region=us-east-1"); - assert_eq!(meta.get_traceparent(), Some(TRACEPARENT)); - assert_eq!(meta.get_tracestate(), Some("vendor1=value1,vendor2=value2")); - assert_eq!(meta.get_baggage(), Some("userId=alice,region=us-east-1")); - } - - #[test] - fn absent_field_is_none() { - let meta = MetaObject::new(); - assert_eq!(meta.get_traceparent(), None); - assert_eq!(meta.get_tracestate(), None); - assert_eq!(meta.get_baggage(), None); - } - - #[test] - fn non_string_value_is_none() { - let mut meta = MetaObject::new(); - meta.0 - .insert(MetaObject::TRACEPARENT_FIELD.to_string(), Value::from(42)); - assert_eq!(meta.get_traceparent(), None); - } - - #[test] - fn trait_setter_inserts_meta_when_absent() { - let mut params = Params::default(); - assert_eq!(params.traceparent(), None); - params.set_traceparent(TRACEPARENT); - assert_eq!(params.traceparent(), Some(TRACEPARENT)); - } - - #[test] - fn request_meta_derefs_to_general_helpers() { - let mut meta = RequestMetaObject::new(); - meta.set_traceparent(TRACEPARENT); - meta.set_progress_token(ProgressToken(NumberOrString::Number(7))); - assert_eq!(meta.get_traceparent(), Some(TRACEPARENT)); - assert_eq!( - meta.get_progress_token(), - Some(ProgressToken(NumberOrString::Number(7))) - ); - } - - mod subscription_id { - use super::*; - - #[test] - fn returns_none_when_absent() { - let meta = NotificationMetaObject::new(); - assert_eq!(meta.subscription_id(), None); - } - - #[test] - fn round_trips_number_id() { - let mut meta = NotificationMetaObject::new(); - meta.set_subscription_id(RequestId::Number(42)); - assert_eq!(meta.subscription_id(), Some(RequestId::Number(42))); - } - - #[test] - fn round_trips_string_id() { - let mut meta = NotificationMetaObject::new(); - meta.set_subscription_id(RequestId::String("sub-1".into())); - assert_eq!( - meta.subscription_id(), - Some(RequestId::String("sub-1".into())) - ); - } - } - - mod missing_required_keys { - use super::*; - - #[test] - fn is_empty_for_pre_draft_protocols() { - let meta = RequestMetaObject::new(); - assert!( - meta.missing_required_keys(&ProtocolVersion::V_2025_11_25) - .is_empty() - ); - } - - #[test] - fn lists_all_draft_keys_for_empty_meta() { - let meta = RequestMetaObject::new(); - assert_eq!( - meta.missing_required_keys(&ProtocolVersion::V_2026_07_28), - RequestMetaObject::DRAFT_REQUIRED_KEYS.to_vec() - ); - } - - #[test] - fn treats_malformed_values_as_missing() { - let meta: RequestMetaObject = serde_json::from_value(serde_json::json!({ - "io.modelcontextprotocol/protocolVersion": 123, - "io.modelcontextprotocol/clientCapabilities": null, - })) - .unwrap(); - assert_eq!( - meta.missing_required_keys(&ProtocolVersion::V_2026_07_28), - RequestMetaObject::DRAFT_REQUIRED_KEYS.to_vec() - ); - } - - #[test] - fn is_empty_when_draft_keys_are_present() { - let mut meta = RequestMetaObject::new(); - meta.set_protocol_version(ProtocolVersion::V_2026_07_28); - meta.set_client_info(Implementation::from_build_env()); - meta.set_client_capabilities(ClientCapabilities::default()); - assert!( - meta.missing_required_keys(&ProtocolVersion::V_2026_07_28) - .is_empty() - ); - } - } -} diff --git a/vendor/rmcp/src/model/mrtr.rs b/vendor/rmcp/src/model/mrtr.rs deleted file mode 100644 index 8dac9a391..000000000 --- a/vendor/rmcp/src/model/mrtr.rs +++ /dev/null @@ -1,558 +0,0 @@ -//! Multi Round-Trip Request (MRTR) types for SEP-2322. -//! -//! Provides [`InputRequiredResult`], [`InputRequests`], and [`InputResponses`] -//! for the stateless multi round-trip request pattern defined in the MCP spec. -//! [`ResultType`] lives in the parent [`super`] module alongside other base result types. -//! -//! # Overview -//! -//! A server may respond to `tools/call`, `prompts/get`, or `resources/read` with an -//! [`InputRequiredResult`] instead of the normal result. The client fulfills the -//! [`InputRequests`], then retries the original request with [`InputResponses`] and -//! the echoed `requestState`. -//! -//! # Using MRTR -//! -//! **Server:** return an [`InputRequiredResult`] from a tool/prompt/resource -//! handler via the matching outcome enum ([`CallToolResponse`], -//! [`GetPromptResponse`], [`ReadResourceResponse`]). The SDK only lets an -//! `InputRequiredResult` reach a peer that negotiated protocol version -//! `2026-07-28` or newer; older peers get a protocol error instead. -//! -//! **Client:** the high-level `RunningService` helpers — `call_tool`, -//! `get_prompt`, and `read_resource` — automatically fulfil each -//! [`InputRequest`] through the local `ClientHandler` and retry, up to -//! [`DEFAULT_MRTR_MAX_ROUNDS`]. Use the `*_once` variants (e.g. -//! `call_tool_once`) to receive an [`InputRequiredResult`] directly and drive -//! the rounds yourself. -//! -//! # `requestState` is untrusted -//! -//! The client echoes `requestState` back verbatim, so a stateless server that -//! stores meaningful data in it MUST verify integrity before trusting the echoed -//! value. Enable the `request-state` feature and use `RequestStateCodec` to seal -//! and open it, or keep the state server-side and use `requestState` only as an -//! opaque handle. -//! -//! A complete runnable walkthrough lives in the `servers_mrtr` example. - -use std::collections::BTreeMap; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use super::{ - CallToolResult, CreateMessageRequest, CreateTaskResult, ElicitRequest, GetPromptResult, - ListRootsRequest, MetaObject, ReadResourceResult, ResultType, ServerResult, -}; - -/// Default maximum number of MRTR rounds a high-level client call will drive. -/// -/// This matches the default used by other Tier 1 SDKs and prevents a -/// misbehaving peer from keeping a request alive indefinitely. -pub const DEFAULT_MRTR_MAX_ROUNDS: usize = 10; - -/// A server-initiated request that can appear inside [`InputRequests`]. -/// -/// Per the MCP spec, only `CreateMessageRequest` (sampling), -/// `ElicitRequest` (elicitation), and `ListRootsRequest` (roots) -/// are allowed. This is modeled as an untagged enum rather than a -/// `ServerRequest` alias to prevent `PingRequest` or `CustomRequest` from -/// being included. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub enum InputRequest { - /// A `sampling/createMessage` request. - CreateMessage(CreateMessageRequest), - /// An `elicitation/create` request. - Elicitation(ElicitRequest), - /// A `roots/list` request. - ListRoots(ListRootsRequest), -} - -// Wire-level equality: two `InputRequest`s are equal if they serialize to the -// same JSON. The wrapped request envelopes do not implement `PartialEq` -// structurally (they carry `Extensions`). -impl PartialEq for InputRequest { - fn eq(&self, other: &Self) -> bool { - match (serde_json::to_value(self), serde_json::to_value(other)) { - (Ok(a), Ok(b)) => a == b, - _ => false, - } - } -} - -/// A map of server-initiated requests that the client must fulfill. -/// -/// Keys are server-assigned string identifiers; values are request objects -/// (`ElicitRequest`, `CreateMessageRequest`, or `ListRootsRequest`). -pub type InputRequests = BTreeMap; - -/// A map of client responses to server-initiated requests. -/// -/// Keys correspond to the keys in the [`InputRequests`] map; values are the -/// client's result for each request (`ElicitResult`, `CreateMessageResult`, -/// or `ListRootsResult`), represented as opaque JSON because the -/// heterogeneous `ClientResult` union does not derive the traits required -/// for use as a `BTreeMap` value. -pub type InputResponses = BTreeMap; - -/// Result of a `tools/call` request, including the MRTR intermediate result. -#[derive(Debug, Clone)] -#[non_exhaustive] -pub enum CallToolResponse { - /// The server completed the tool call. - Complete(CallToolResult), - /// The server requires client-side input before the tool call can complete. - InputRequired(InputRequiredResult), - /// The server materialized a task for this call (SEP-2663 Tasks extension, - /// `resultType: "task"`). The client polls `tasks/get` for the result. - Task(CreateTaskResult), -} - -impl From for CallToolResponse { - fn from(result: CallToolResult) -> Self { - Self::Complete(result) - } -} - -impl From for CallToolResponse { - fn from(result: InputRequiredResult) -> Self { - Self::InputRequired(result) - } -} - -impl From for ServerResult { - fn from(response: CallToolResponse) -> Self { - match response { - CallToolResponse::Complete(result) => ServerResult::CallToolResult(result), - CallToolResponse::InputRequired(result) => ServerResult::InputRequiredResult(result), - CallToolResponse::Task(result) => ServerResult::CreateTaskResult(result), - } - } -} - -impl From for CallToolResponse { - fn from(result: CreateTaskResult) -> Self { - Self::Task(result) - } -} - -/// Result of a `prompts/get` request, including the MRTR intermediate result. -#[derive(Debug, Clone)] -#[non_exhaustive] -pub enum GetPromptResponse { - /// The server completed the prompt request. - Complete(GetPromptResult), - /// The server requires client-side input before the prompt can be returned. - InputRequired(InputRequiredResult), -} - -impl From for GetPromptResponse { - fn from(result: GetPromptResult) -> Self { - Self::Complete(result) - } -} - -impl From for GetPromptResponse { - fn from(result: InputRequiredResult) -> Self { - Self::InputRequired(result) - } -} - -impl From for ServerResult { - fn from(response: GetPromptResponse) -> Self { - match response { - GetPromptResponse::Complete(result) => ServerResult::GetPromptResult(result), - GetPromptResponse::InputRequired(result) => ServerResult::InputRequiredResult(result), - } - } -} - -/// Result of a `resources/read` request, including the MRTR intermediate result. -#[derive(Debug, Clone)] -#[non_exhaustive] -pub enum ReadResourceResponse { - /// The server completed the resource read. - Complete(ReadResourceResult), - /// The server requires client-side input before the resource can be returned. - InputRequired(InputRequiredResult), -} - -impl From for ReadResourceResponse { - fn from(result: ReadResourceResult) -> Self { - Self::Complete(result) - } -} - -impl From for ReadResourceResponse { - fn from(result: InputRequiredResult) -> Self { - Self::InputRequired(result) - } -} - -impl From for ServerResult { - fn from(response: ReadResourceResponse) -> Self { - match response { - ReadResourceResponse::Complete(result) => ServerResult::ReadResourceResult(result), - ReadResourceResponse::InputRequired(result) => { - ServerResult::InputRequiredResult(result) - } - } - } -} - -/// A result indicating that additional input is needed before the request -/// can be completed. -/// -/// At least one of [`input_requests`](Self::input_requests) or -/// [`request_state`](Self::request_state) MUST be present. -/// -/// Servers MAY send this in response to `tools/call`, `prompts/get`, or -/// `resources/read`. Servers MUST NOT send this for any other request. -/// -/// # Examples -/// -/// ``` -/// use rmcp::model::InputRequiredResult; -/// -/// let result = InputRequiredResult::from_request_state("opaque-server-state"); -/// assert!(result.input_requests.is_none()); -/// assert_eq!(result.request_state.as_deref(), Some("opaque-server-state")); -/// ``` -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct InputRequiredResult { - /// Always `"input_required"` for this result type. - pub result_type: ResultType, - - /// Server-initiated requests that the client must fulfill before retrying. - #[serde(skip_serializing_if = "Option::is_none")] - pub input_requests: Option, - - /// Opaque request state to be echoed back by the client on retry. - /// Clients MUST NOT inspect, parse, modify, or make any assumptions - /// about the contents. - #[serde(skip_serializing_if = "Option::is_none")] - pub request_state: Option, - - /// Optional protocol-level metadata. - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -/// Custom deserializer that requires `resultType: "input_required"` to prevent -/// greedy matching in the untagged `ServerResult` enum (which would otherwise -/// swallow empty objects or unknown shapes). -impl<'de> Deserialize<'de> for InputRequiredResult { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - struct Helper { - result_type: Option, - input_requests: Option, - request_state: Option, - #[serde(rename = "_meta")] - meta: Option, - } - - let helper = Helper::deserialize(deserializer)?; - - match &helper.result_type { - Some(rt) if rt.is_input_required() => {} - _ => { - return Err(serde::de::Error::custom( - "InputRequiredResult requires resultType to be \"input_required\"", - )); - } - } - - if helper.input_requests.is_none() && helper.request_state.is_none() { - return Err(serde::de::Error::custom( - "InputRequiredResult requires at least one of inputRequests or requestState", - )); - } - - Ok(InputRequiredResult { - result_type: ResultType::INPUT_REQUIRED, - input_requests: helper.input_requests, - request_state: helper.request_state, - meta: helper.meta, - }) - } -} - -impl InputRequiredResult { - /// Creates a new `InputRequiredResult` with both input requests and request state. - pub fn new(input_requests: Option, request_state: Option) -> Self { - Self { - result_type: ResultType::INPUT_REQUIRED, - input_requests, - request_state, - meta: None, - } - } - - /// Creates from input requests only. - pub fn from_input_requests(input_requests: InputRequests) -> Self { - Self::new(Some(input_requests), None) - } - - /// Creates from request state only (e.g. for load shedding). - pub fn from_request_state(request_state: impl Into) -> Self { - Self::new(None, Some(request_state.into())) - } - - /// Sets optional metadata. - pub fn with_meta(mut self, meta: MetaObject) -> Self { - self.meta = Some(meta); - self - } -} - -#[cfg(test)] -mod tests { - use super::*; - - mod result_type { - use super::*; - - #[test] - fn default_is_complete() { - assert_eq!(ResultType::default(), ResultType::COMPLETE); - } - - #[test] - fn serializes_complete() { - assert_eq!( - serde_json::to_value(&ResultType::COMPLETE).unwrap(), - serde_json::json!("complete") - ); - } - - #[test] - fn serializes_input_required() { - assert_eq!( - serde_json::to_value(&ResultType::INPUT_REQUIRED).unwrap(), - serde_json::json!("input_required") - ); - } - - #[test] - fn deserializes_known_values() { - let complete: ResultType = - serde_json::from_value(serde_json::json!("complete")).unwrap(); - assert_eq!(complete, ResultType::COMPLETE); - - let input_required: ResultType = - serde_json::from_value(serde_json::json!("input_required")).unwrap(); - assert_eq!(input_required, ResultType::INPUT_REQUIRED); - } - - #[test] - fn preserves_unknown_extension_values() { - let custom: ResultType = - serde_json::from_value(serde_json::json!("streaming")).unwrap(); - assert_eq!(custom.as_str(), "streaming"); - assert!(!custom.is_complete()); - assert!(!custom.is_input_required()); - - let reserialized = serde_json::to_value(&custom).unwrap(); - assert_eq!(reserialized, serde_json::json!("streaming")); - } - } - - mod input_required_result { - use super::*; - - #[test] - fn deserializes_with_requests_and_state() { - let json = serde_json::json!({ - "resultType": "input_required", - "inputRequests": { - "github_login": { - "method": "elicitation/create", - "params": { - "message": "Please provide your GitHub username", - "requestedSchema": { - "type": "object", - "properties": { "name": { "type": "string" } }, - "required": ["name"] - } - } - }, - "capital_of_france": { - "method": "sampling/createMessage", - "params": { - "messages": [{ - "role": "user", - "content": { "type": "text", "text": "What is the capital of France?" } - }], - "maxTokens": 100 - } - } - }, - "requestState": "eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0" - }); - - let result: InputRequiredResult = serde_json::from_value(json).unwrap(); - - let requests = result - .input_requests - .as_ref() - .expect("should have input_requests"); - assert_eq!(requests.len(), 2); - assert!(requests.contains_key("github_login")); - assert!(requests.contains_key("capital_of_france")); - assert_eq!( - result.request_state.as_deref(), - Some("eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0") - ); - } - - #[test] - fn roundtrip_preserves_all_fields() { - let json = serde_json::json!({ - "resultType": "input_required", - "inputRequests": { - "key": { - "method": "elicitation/create", - "params": { - "message": "test", - "requestedSchema": { "type": "object", "properties": {} } - } - } - }, - "requestState": "abc123" - }); - - let result: InputRequiredResult = serde_json::from_value(json).unwrap(); - let reserialized = serde_json::to_value(&result).unwrap(); - - assert_eq!(reserialized["resultType"], "input_required"); - assert!(reserialized["inputRequests"].is_object()); - assert_eq!(reserialized["requestState"], "abc123"); - } - - #[test] - fn deserializes_with_request_state_only() { - let json = serde_json::json!({ - "resultType": "input_required", - "requestState": "eyJwcm9ncmVzcyI6IjUwJSJ9" - }); - - let result: InputRequiredResult = serde_json::from_value(json).unwrap(); - - assert!(result.input_requests.is_none()); - assert_eq!( - result.request_state.as_deref(), - Some("eyJwcm9ncmVzcyI6IjUwJSJ9") - ); - } - - #[test] - fn rejects_missing_input_requests_and_request_state() { - let json = serde_json::json!({ - "resultType": "input_required", - "_meta": {} - }); - let err = serde_json::from_value::(json).unwrap_err(); - assert!( - err.to_string().contains("inputRequests or requestState"), - "error should mention the required continuation fields, got: {err}" - ); - } - - #[test] - fn rejects_missing_result_type() { - let json = serde_json::json!({ - "requestState": "some-state" - }); - let err = serde_json::from_value::(json).unwrap_err(); - assert!( - err.to_string().contains("input_required"), - "error should mention the required resultType, got: {err}" - ); - } - - #[test] - fn rejects_wrong_result_type() { - let json = serde_json::json!({ - "resultType": "complete", - "requestState": "some-state" - }); - let err = serde_json::from_value::(json).unwrap_err(); - assert!( - err.to_string().contains("input_required"), - "error should mention the required resultType, got: {err}" - ); - } - } - - mod input_responses { - use super::*; - - #[test] - fn deserializes_heterogeneous_results() { - let json = serde_json::json!({ - "github_login": { - "action": "accept", - "content": { "name": "octocat" } - }, - "capital_of_france": { - "role": "assistant", - "content": { "type": "text", "text": "Paris." }, - "model": "claude-3-sonnet-20240307", - "stopReason": "endTurn" - } - }); - - let responses: InputResponses = serde_json::from_value(json).unwrap(); - - assert_eq!(responses.len(), 2); - assert!(responses.contains_key("github_login")); - assert!(responses.contains_key("capital_of_france")); - } - } - - mod constructors { - use super::*; - - #[test] - fn from_request_state_sets_state_only() { - let result = InputRequiredResult::from_request_state("opaque"); - - assert_eq!(result.result_type, ResultType::INPUT_REQUIRED); - assert!(result.input_requests.is_none()); - assert_eq!(result.request_state.as_deref(), Some("opaque")); - } - - #[test] - fn from_input_requests_sets_requests_only() { - let mut requests = InputRequests::new(); - requests.insert( - "key".to_string(), - serde_json::from_value(serde_json::json!({ - "method": "elicitation/create", - "params": { - "message": "test", - "requestedSchema": { "type": "object", "properties": {} } - } - })) - .unwrap(), - ); - - let result = InputRequiredResult::from_input_requests(requests); - - assert!(result.input_requests.is_some()); - assert!(result.request_state.is_none()); - } - } -} diff --git a/vendor/rmcp/src/model/prompt.rs b/vendor/rmcp/src/model/prompt.rs deleted file mode 100644 index 46f5168ea..000000000 --- a/vendor/rmcp/src/model/prompt.rs +++ /dev/null @@ -1,367 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use super::{ - Annotations, ContentBlock, Icon, MetaObject, Role, - content::{AudioContent, EmbeddedResource, ImageContent, TextContent}, - resource::ResourceContents, -}; - -/// A prompt or prompt template that the server offers (spec `Prompt`). -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct Prompt { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub arguments: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub icons: Option>, - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -impl Prompt { - pub fn new( - name: N, - description: Option, - arguments: Option>, - ) -> Self - where - N: Into, - D: Into, - { - Prompt { - name: name.into(), - title: None, - description: description.map(Into::into), - arguments, - icons: None, - meta: None, - } - } - - pub fn from_raw( - name: impl Into, - description: Option>, - arguments: Option>, - ) -> Self { - Prompt { - name: name.into(), - title: None, - description: description.map(Into::into), - arguments, - icons: None, - meta: None, - } - } - - pub fn with_title(mut self, title: impl Into) -> Self { - self.title = Some(title.into()); - self - } - - pub fn with_icons(mut self, icons: Vec) -> Self { - self.icons = Some(icons); - self - } - - pub fn with_meta(mut self, meta: MetaObject) -> Self { - self.meta = Some(meta); - self - } -} - -/// Describes an argument that a prompt can accept (spec `PromptArgument`). -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct PromptArgument { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub required: Option, -} - -impl PromptArgument { - pub fn new>(name: N) -> Self { - PromptArgument { - name: name.into(), - title: None, - description: None, - required: None, - } - } - - pub fn with_title>(mut self, title: T) -> Self { - self.title = Some(title.into()); - self - } - - pub fn with_description>(mut self, description: D) -> Self { - self.description = Some(description.into()); - self - } - - pub fn with_required(mut self, required: bool) -> Self { - self.required = Some(required); - self - } -} - -/// A message returned as part of a prompt (spec `PromptMessage`). -/// -/// Uses the unified `ContentBlock` for its content (text | image | audio | resource_link | resource). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct PromptMessage { - pub role: Role, - pub content: ContentBlock, -} - -impl PromptMessage { - pub fn new(role: Role, content: ContentBlock) -> Self { - Self { role, content } - } - - pub fn new_text>(role: Role, text: S) -> Self { - Self { - role, - content: ContentBlock::text(text), - } - } - - #[cfg(feature = "base64")] - pub fn new_image( - role: Role, - data: &[u8], - mime_type: &str, - meta: Option, - annotations: Option, - ) -> Self { - use base64::{Engine, prelude::BASE64_STANDARD}; - - let base64 = BASE64_STANDARD.encode(data); - Self { - role, - content: ContentBlock::Image(ImageContent { - data: base64, - mime_type: mime_type.into(), - meta, - annotations, - }), - } - } - - #[cfg(feature = "base64")] - pub fn new_audio( - role: Role, - data: &[u8], - mime_type: &str, - meta: Option, - annotations: Option, - ) -> Self { - use base64::{Engine, prelude::BASE64_STANDARD}; - - let base64 = BASE64_STANDARD.encode(data); - Self { - role, - content: ContentBlock::Audio(AudioContent { - data: base64, - mime_type: mime_type.into(), - meta, - annotations, - }), - } - } - - pub fn new_resource( - role: Role, - uri: String, - mime_type: Option, - text: Option, - resource_meta: Option, - resource_content_meta: Option, - annotations: Option, - ) -> Self { - let resource_contents = match text { - Some(t) => ResourceContents::TextResourceContents { - uri, - mime_type, - text: t, - meta: resource_content_meta, - }, - None => ResourceContents::BlobResourceContents { - uri, - mime_type, - blob: String::new(), - meta: resource_content_meta, - }, - }; - Self { - role, - content: ContentBlock::Resource(EmbeddedResource { - meta: resource_meta, - resource: resource_contents, - annotations, - }), - } - } - - pub fn new_text_with_meta>( - role: Role, - text: S, - meta: Option, - ) -> Self { - Self { - role, - content: ContentBlock::Text(TextContent { - text: text.into(), - meta, - annotations: None, - }), - } - } - - pub fn new_resource_link(role: Role, resource: super::resource::Resource) -> Self { - Self { - role, - content: ContentBlock::ResourceLink(resource), - } - } -} - -#[cfg(test)] -mod tests { - use serde_json; - - use super::*; - - #[test] - fn test_prompt_message_image_serialization() { - let image = ImageContent::new("base64data", "image/png"); - let json = serde_json::to_string(&image).unwrap(); - assert!(json.contains("mimeType")); - assert!(!json.contains("mime_type")); - } - - #[test] - fn test_prompt_message_audio_serialization_and_deserialization() { - let content = ContentBlock::Audio(AudioContent::new("YXVkaW8=", "audio/wav")); - let value = serde_json::to_value(&content).unwrap(); - assert_eq!(value.get("type").and_then(|v| v.as_str()), Some("audio")); - assert_eq!(value.get("data").and_then(|v| v.as_str()), Some("YXVkaW8=")); - assert_eq!( - value.get("mimeType").and_then(|v| v.as_str()), - Some("audio/wav"), - "expected camelCase mimeType, got: {value:#?}" - ); - - let json = r#"{"type":"audio","data":"YXVkaW8=","mimeType":"audio/wav"}"#; - let parsed: ContentBlock = serde_json::from_str(json).unwrap(); - assert_eq!(parsed, content); - } - - #[test] - #[cfg(feature = "base64")] - fn test_prompt_message_new_audio_constructor() { - let message = PromptMessage::new_audio(Role::User, b"hello", "audio/wav", None, None); - let value = serde_json::to_value(&message).unwrap(); - let content = value.get("content").expect("content present"); - assert_eq!(content.get("type").and_then(|v| v.as_str()), Some("audio")); - assert_eq!( - content.get("mimeType").and_then(|v| v.as_str()), - Some("audio/wav") - ); - assert_eq!( - content.get("data").and_then(|v| v.as_str()), - Some("aGVsbG8=") - ); - } - - #[test] - fn test_prompt_message_resource_link_serialization() { - use super::super::resource::Resource; - - let resource = Resource::new("file:///test.txt", "test.txt"); - let message = PromptMessage::new_resource_link(Role::User, resource); - - let json = serde_json::to_string(&message).unwrap(); - assert!(json.contains("\"type\":\"resource_link\"")); - assert!(json.contains("\"uri\":\"file:///test.txt\"")); - assert!(json.contains("\"name\":\"test.txt\"")); - } - - #[test] - fn test_prompt_message_resource_serialization_is_flat() { - let message = PromptMessage::new_resource( - Role::User, - "alc://packages/sc/narrative".to_string(), - Some("text/markdown".to_string()), - Some("# Hello".to_string()), - None, - None, - None, - ); - - let value: serde_json::Value = serde_json::to_value(&message).unwrap(); - let content = value.get("content").expect("content present"); - assert_eq!( - content.get("type").and_then(|v| v.as_str()), - Some("resource") - ); - - let resource = content - .get("resource") - .expect("resource field present at content level"); - - assert_eq!( - resource.get("uri").and_then(|v| v.as_str()), - Some("alc://packages/sc/narrative"), - "expected flat resource.uri, got: {resource:#?}" - ); - assert_eq!( - resource.get("mimeType").and_then(|v| v.as_str()), - Some("text/markdown") - ); - assert_eq!( - resource.get("text").and_then(|v| v.as_str()), - Some("# Hello") - ); - - assert!( - resource.get("resource").is_none(), - "double-nested resource detected (regression): {resource:#?}" - ); - } - - #[test] - fn test_prompt_message_content_resource_link_deserialization() { - let json = r#"{ - "type": "resource_link", - "uri": "file:///example.txt", - "name": "example.txt", - "description": "Example file", - "mimeType": "text/plain" - }"#; - - let content: ContentBlock = serde_json::from_str(json).unwrap(); - - if let ContentBlock::ResourceLink(resource) = content { - assert_eq!(resource.uri, "file:///example.txt"); - assert_eq!(resource.name, "example.txt"); - assert_eq!(resource.description, Some("Example file".to_string())); - assert_eq!(resource.mime_type, Some("text/plain".to_string())); - } else { - panic!("Expected ResourceLink variant"); - } - } -} diff --git a/vendor/rmcp/src/model/request_state.rs b/vendor/rmcp/src/model/request_state.rs deleted file mode 100644 index 5ed0f7de8..000000000 --- a/vendor/rmcp/src/model/request_state.rs +++ /dev/null @@ -1,577 +0,0 @@ -//! Integrity protection for SEP-2322 `requestState`. -//! -//! In the multi round-trip request (MRTR) flow, a server places an opaque -//! `requestState` string in an [`InputRequiredResult`](super::InputRequiredResult) -//! and the client echoes it back verbatim on retry. From the server's point of -//! view the echoed value is **untrusted, attacker-controlled input**: a client -//! can send back anything it likes. Per SEP-2322, a server that lets -//! `requestState` influence authorization, resource access, or business logic -//! MUST protect its integrity and reject values that fail verification. -//! -//! [`RequestStateCodec`] provides an opt-in way to do this. It seals a payload -//! into an opaque string with an HMAC-SHA256 tag and opens it again, rejecting -//! any value that was forged or tampered with. -//! -//! To follow the spec's replay-prevention guidance without hand-rolling the -//! checks, the codec supports two bindings via [`SealOptions`]: -//! -//! * **Associated data** — arbitrary context (e.g. the authenticated principal -//! plus a digest of the originating request) that is mixed into the tag but -//! not stored in the token. [`open_with`](RequestStateCodec::open_with) only -//! succeeds when the caller supplies the same context, so a value cannot be -//! replayed by a different principal or against a different request. This is -//! *fail-closed*: forgetting to pass the context makes verification fail. -//! * **TTL** — a relative expiry stamped into the token; opening a value past -//! its expiry fails with [`RequestStateError::Expired`]. -//! -//! Single-use/nonce enforcement (for one-time redemptions) still has to be done -//! server-side, as the spec notes. -//! -//! This helper is only about *integrity*, not *confidentiality*: the sealed -//! payload is signed, not encrypted, so it is base64url-readable by anyone. Do -//! not put secrets in it. -//! -//! Using the codec is entirely optional. A server that keeps its state -//! server-side, or that does not trust `requestState` for anything security -//! sensitive, can keep building the string by hand via -//! [`InputRequiredResult::from_request_state`](super::InputRequiredResult::from_request_state). -//! -//! # Examples -//! -//! ``` -//! use rmcp::model::{RequestStateCodec, SealOptions}; -//! -//! // Derive the key from a per-process secret; keep it out of client reach. -//! let codec = RequestStateCodec::new(b"a-32-byte-or-longer-secret-key!!!"); -//! -//! // Bind the state to the caller and the originating request. -//! let context = b"user:alice|tools/call:weather"; -//! let sealed = codec.seal_with( -//! b"step=2", -//! &SealOptions::new().associated_data(context), -//! ); -//! -//! // On retry the client echoes `sealed` back untouched; the server re-derives -//! // the same context and opens it. -//! let opened = codec.open_with(&sealed, context).expect("integrity check passes"); -//! assert_eq!(opened, b"step=2"); -//! -//! // A different principal (different context) is rejected. -//! assert!(codec.open_with(&sealed, b"user:bob|tools/call:weather").is_err()); -//! ``` - -use std::time::Duration; - -use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use hmac::{Hmac, KeyInit, Mac}; -use serde::{Serialize, de::DeserializeOwned}; -use sha2::Sha256; -use thiserror::Error; - -type HmacSha256 = Hmac; - -/// Version tag prefixing every sealed value, so the wire format can evolve. -const VERSION: &str = "rs1"; - -/// Domain-separation label mixed into the HMAC so a `requestState` tag can never -/// be confused with an HMAC computed for some other purpose using the same key. -const DOMAIN: &[u8] = b"rmcp/mrtr/request-state/v1"; - -/// Length of the big-endian expiry prefix (unix milliseconds) stored at the -/// front of every sealed body. `0` means "no expiry". -const EXPIRY_LEN: usize = 8; - -/// Errors returned when opening a sealed [`RequestStateCodec`] value. -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum RequestStateError { - /// The value is not a well-formed sealed request state (wrong prefix or - /// missing sections). - #[error("request state is malformed or uses an unsupported format")] - MalformedFormat, - - /// A section of the value was not valid base64url. - #[error("request state is not valid base64url")] - InvalidEncoding, - - /// The HMAC tag did not match; the value was forged, tampered with, or - /// opened with the wrong associated data. - #[error("request state failed integrity verification")] - IntegrityCheckFailed, - - /// The value carried a TTL that has already elapsed. - #[error("request state has expired")] - Expired, - - /// The sealed payload could not be serialized to JSON. - #[error("failed to serialize request state payload: {0}")] - Serialization(#[source] serde_json::Error), - - /// The opened payload could not be deserialized from JSON. - #[error("failed to deserialize request state payload: {0}")] - Deserialization(#[source] serde_json::Error), -} - -/// Options controlling how a value is sealed by [`RequestStateCodec`]. -/// -/// Defaults to no associated data and no expiry, which is equivalent to the -/// bare [`seal`](RequestStateCodec::seal) / [`open`](RequestStateCodec::open) -/// methods. -#[derive(Clone, Copy, Debug, Default)] -pub struct SealOptions<'a> { - associated_data: &'a [u8], - ttl: Option, -} - -impl<'a> SealOptions<'a> { - /// Creates empty options (no associated data, no expiry). - pub fn new() -> Self { - Self::default() - } - - /// Binds the sealed value to `associated_data`. The same bytes must be - /// supplied to [`open_with`](RequestStateCodec::open_with); the data is - /// authenticated but not stored in the token. - /// - /// Use this to bind the state to the authenticated principal and/or the - /// originating request (e.g. method name plus a digest of its parameters). - pub fn associated_data(mut self, associated_data: &'a [u8]) -> Self { - self.associated_data = associated_data; - self - } - - /// Sets a relative time-to-live after which opening the value fails with - /// [`RequestStateError::Expired`]. - pub fn ttl(mut self, ttl: Duration) -> Self { - self.ttl = Some(ttl); - self - } -} - -/// A keyed codec that seals and opens SEP-2322 `requestState` values with -/// HMAC-SHA256 integrity protection. -/// -/// Construct one codec per signing key and reuse it for the lifetime of the -/// key. The same key must be used to [`seal`](Self::seal) and -/// [`open`](Self::open) a value, so it has to survive across the rounds of a -/// single MRTR exchange (e.g. a stable per-process or per-deployment secret). -/// -/// The key may be any length; HMAC internally normalizes it. For meaningful -/// security use a high-entropy key of at least 32 bytes. -#[derive(Clone)] -pub struct RequestStateCodec { - key: Box<[u8]>, -} - -impl std::fmt::Debug for RequestStateCodec { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // Never leak the signing key through Debug output. - f.debug_struct("RequestStateCodec") - .field("key", &"") - .finish() - } -} - -impl RequestStateCodec { - /// Creates a codec from a signing key. - pub fn new(key: impl Into>) -> Self { - Self { - key: key.into().into_boxed_slice(), - } - } - - /// Seals raw bytes into an opaque, integrity-protected string suitable for - /// use as `requestState`. - pub fn seal(&self, payload: &[u8]) -> String { - self.seal_with(payload, &SealOptions::default()) - } - - /// Seals raw bytes with [`SealOptions`] (associated data and/or TTL). - pub fn seal_with(&self, payload: &[u8], options: &SealOptions<'_>) -> String { - self.seal_at(payload, options, Self::now_ms()) - } - - /// Seals a serializable value by encoding it as JSON before sealing. - /// - /// # Errors - /// - /// Returns [`RequestStateError::Serialization`] if `value` cannot be encoded - /// as JSON. - pub fn seal_json(&self, value: &T) -> Result { - self.seal_json_with(value, &SealOptions::default()) - } - - /// Seals a serializable value with [`SealOptions`]. - /// - /// # Errors - /// - /// Returns [`RequestStateError::Serialization`] if `value` cannot be encoded - /// as JSON. - pub fn seal_json_with( - &self, - value: &T, - options: &SealOptions<'_>, - ) -> Result { - let payload = serde_json::to_vec(value).map_err(RequestStateError::Serialization)?; - Ok(self.seal_with(&payload, options)) - } - - /// Opens a sealed value that was sealed without associated data, verifying - /// its integrity and expiry and returning the original bytes. - /// - /// # Errors - /// - /// See [`open_with`](Self::open_with). - pub fn open(&self, sealed: &str) -> Result, RequestStateError> { - self.open_with(sealed, &[]) - } - - /// Opens a sealed value, verifying its integrity against `associated_data` - /// and checking its expiry. - /// - /// `associated_data` must match the bytes passed to - /// [`SealOptions::associated_data`] when the value was sealed (use `&[]` for - /// values sealed without it). - /// - /// # Errors - /// - /// - [`RequestStateError::IntegrityCheckFailed`] if the value was not - /// produced by this key or the associated data differs. - /// - [`RequestStateError::Expired`] if the value's TTL has elapsed. - /// - [`RequestStateError::MalformedFormat`] or - /// [`RequestStateError::InvalidEncoding`] if it is not a well-formed sealed - /// value. - pub fn open_with( - &self, - sealed: &str, - associated_data: &[u8], - ) -> Result, RequestStateError> { - self.open_at(sealed, associated_data, Self::now_ms()) - } - - /// Opens a sealed value (no associated data) and deserializes its JSON - /// payload. - /// - /// # Errors - /// - /// See [`open_json_with`](Self::open_json_with). - pub fn open_json(&self, sealed: &str) -> Result { - self.open_json_with(sealed, &[]) - } - - /// Opens a sealed value against `associated_data` and deserializes its JSON - /// payload. - /// - /// # Errors - /// - /// Returns the same integrity, expiry, and format errors as - /// [`open_with`](Self::open_with), plus [`RequestStateError::Deserialization`] - /// if the payload is not valid JSON for `T`. - pub fn open_json_with( - &self, - sealed: &str, - associated_data: &[u8], - ) -> Result { - let payload = self.open_with(sealed, associated_data)?; - serde_json::from_slice(&payload).map_err(RequestStateError::Deserialization) - } - - fn seal_at(&self, payload: &[u8], options: &SealOptions<'_>, now_ms: i64) -> String { - let expiry = match options.ttl { - Some(ttl) => now_ms.saturating_add(ttl.as_millis().min(i64::MAX as u128) as i64), - None => 0, - }; - - // body = big-endian expiry (0 = none) followed by the caller payload. - let mut body = Vec::with_capacity(EXPIRY_LEN + payload.len()); - body.extend_from_slice(&expiry.to_be_bytes()); - body.extend_from_slice(payload); - - let tag = self - .mac_for(options.associated_data, &body) - .finalize() - .into_bytes(); - - // base64url without padding encodes 3 bytes as 4 chars, rounding up. - let b64_len = |n: usize| n.div_ceil(3) * 4; - let mut out = - String::with_capacity(VERSION.len() + 2 + b64_len(body.len()) + b64_len(tag.len())); - out.push_str(VERSION); - out.push('.'); - URL_SAFE_NO_PAD.encode_string(&body, &mut out); - out.push('.'); - URL_SAFE_NO_PAD.encode_string(tag.as_slice(), &mut out); - out - } - - fn open_at( - &self, - sealed: &str, - associated_data: &[u8], - now_ms: i64, - ) -> Result, RequestStateError> { - let mut parts = sealed.split('.'); - let version = parts.next().ok_or(RequestStateError::MalformedFormat)?; - let body_b64 = parts.next().ok_or(RequestStateError::MalformedFormat)?; - let tag_b64 = parts.next().ok_or(RequestStateError::MalformedFormat)?; - if parts.next().is_some() || version != VERSION { - return Err(RequestStateError::MalformedFormat); - } - - let body = URL_SAFE_NO_PAD - .decode(body_b64) - .map_err(|_| RequestStateError::InvalidEncoding)?; - let tag = URL_SAFE_NO_PAD - .decode(tag_b64) - .map_err(|_| RequestStateError::InvalidEncoding)?; - - // `verify_slice` compares in constant time and rejects wrong-length tags. - self.mac_for(associated_data, &body) - .verify_slice(&tag) - .map_err(|_| RequestStateError::IntegrityCheckFailed)?; - - // The body is now authenticated, so its framing can be trusted. - if body.len() < EXPIRY_LEN { - return Err(RequestStateError::MalformedFormat); - } - let expiry = i64::from_be_bytes(body[..EXPIRY_LEN].try_into().expect("checked length")); - if expiry != 0 && now_ms > expiry { - return Err(RequestStateError::Expired); - } - - Ok(body[EXPIRY_LEN..].to_vec()) - } - - /// Builds an HMAC keyed for request-state tags, pre-fed with the - /// domain-separation label, a length-prefixed `associated_data`, and the - /// body. The length prefix keeps the `associated_data`/`body` boundary - /// unambiguous so distinct inputs cannot collide. - fn mac_for(&self, associated_data: &[u8], body: &[u8]) -> HmacSha256 { - let mut mac = - HmacSha256::new_from_slice(&self.key).expect("HMAC accepts keys of any length"); - mac.update(DOMAIN); - mac.update(&(associated_data.len() as u64).to_be_bytes()); - mac.update(associated_data); - mac.update(body); - mac - } - - fn now_ms() -> i64 { - chrono::Utc::now().timestamp_millis() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn seal_open_roundtrips_bytes() { - let codec = RequestStateCodec::new(b"test-key-test-key-test-key-32byte".to_vec()); - let sealed = codec.seal(b"hello world"); - assert!(sealed.starts_with("rs1.")); - assert_eq!(codec.open(&sealed).unwrap(), b"hello world"); - } - - #[test] - fn seal_open_roundtrips_json() { - #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)] - struct State { - tool: String, - round: u32, - } - let codec = RequestStateCodec::new(b"another-strong-signing-key-here!!".to_vec()); - let state = State { - tool: "weather".into(), - round: 3, - }; - let sealed = codec.seal_json(&state).unwrap(); - let opened: State = codec.open_json(&sealed).unwrap(); - assert_eq!(opened, state); - } - - #[test] - fn empty_payload_roundtrips() { - let codec = RequestStateCodec::new(b"k".to_vec()); - let sealed = codec.seal(b""); - assert_eq!(codec.open(&sealed).unwrap(), b""); - } - - #[test] - fn tampered_payload_is_rejected() { - let codec = RequestStateCodec::new(b"signing-key-signing-key-signing!!".to_vec()); - let sealed = codec.seal(b"amount=100"); - - // Replace the body section but keep the original tag. - let mut parts: Vec<&str> = sealed.split('.').collect(); - let forged_body = URL_SAFE_NO_PAD.encode(b"amount=999"); - parts[1] = &forged_body; - let forged = parts.join("."); - - assert!(matches!( - codec.open(&forged), - Err(RequestStateError::IntegrityCheckFailed) - )); - } - - #[test] - fn different_key_is_rejected() { - let signer = RequestStateCodec::new(b"the-real-signing-key-value-here!!".to_vec()); - let attacker = RequestStateCodec::new(b"a-totally-different-forged-key!!!".to_vec()); - let sealed = signer.seal(b"trusted"); - assert!(matches!( - attacker.open(&sealed), - Err(RequestStateError::IntegrityCheckFailed) - )); - } - - #[test] - fn appended_bytes_are_rejected() { - let codec = RequestStateCodec::new(b"key-key-key-key-key-key-key-key!!".to_vec()); - let mut sealed = codec.seal(b"state"); - sealed.push('x'); - assert!(codec.open(&sealed).is_err()); - } - - #[test] - fn wrong_version_prefix_is_malformed() { - let codec = RequestStateCodec::new(b"key".to_vec()); - let sealed = codec.seal(b"state"); - let bumped = sealed.replacen("rs1.", "rs2.", 1); - assert!(matches!( - codec.open(&bumped), - Err(RequestStateError::MalformedFormat) - )); - } - - #[test] - fn missing_sections_are_malformed() { - let codec = RequestStateCodec::new(b"key".to_vec()); - assert!(matches!( - codec.open("rs1"), - Err(RequestStateError::MalformedFormat) - )); - assert!(matches!( - codec.open("rs1.onlybody"), - Err(RequestStateError::MalformedFormat) - )); - assert!(matches!( - codec.open("rs1.a.b.c"), - Err(RequestStateError::MalformedFormat) - )); - } - - #[test] - fn non_base64_sections_are_invalid_encoding() { - let codec = RequestStateCodec::new(b"key".to_vec()); - assert!(matches!( - codec.open("rs1.!!!!.!!!!"), - Err(RequestStateError::InvalidEncoding) - )); - } - - #[test] - fn debug_does_not_leak_key() { - let codec = RequestStateCodec::new(b"super-secret-key".to_vec()); - let rendered = format!("{codec:?}"); - assert!(!rendered.contains("super-secret-key")); - assert!(rendered.contains("redacted")); - } - - mod associated_data { - use super::*; - - #[test] - fn matching_context_opens() { - let codec = RequestStateCodec::new(b"key-key-key-key-key-key-key-key!!".to_vec()); - let ctx = b"user:alice|tools/call:weather"; - let sealed = codec.seal_with(b"state", &SealOptions::new().associated_data(ctx)); - assert_eq!(codec.open_with(&sealed, ctx).unwrap(), b"state"); - } - - #[test] - fn different_context_is_rejected() { - let codec = RequestStateCodec::new(b"key-key-key-key-key-key-key-key!!".to_vec()); - let sealed = - codec.seal_with(b"state", &SealOptions::new().associated_data(b"user:alice")); - assert!(matches!( - codec.open_with(&sealed, b"user:bob"), - Err(RequestStateError::IntegrityCheckFailed) - )); - } - - #[test] - fn missing_context_is_rejected() { - let codec = RequestStateCodec::new(b"key-key-key-key-key-key-key-key!!".to_vec()); - let sealed = - codec.seal_with(b"state", &SealOptions::new().associated_data(b"user:alice")); - // Opening without the associated data must fail closed. - assert!(matches!( - codec.open(&sealed), - Err(RequestStateError::IntegrityCheckFailed) - )); - } - } - - mod ttl { - use super::*; - - const KEY: &[u8] = b"ttl-signing-key-ttl-signing-key!!"; - - #[test] - fn within_ttl_opens() { - let codec = RequestStateCodec::new(KEY.to_vec()); - let sealed = codec.seal_at( - b"state", - &SealOptions::new().ttl(Duration::from_secs(60)), - 1_000, - ); - // 30s later, still valid. - assert_eq!(codec.open_at(&sealed, &[], 31_000).unwrap(), b"state"); - } - - #[test] - fn past_ttl_is_expired() { - let codec = RequestStateCodec::new(KEY.to_vec()); - let sealed = codec.seal_at( - b"state", - &SealOptions::new().ttl(Duration::from_secs(60)), - 1_000, - ); - // 61s later, expired. - assert!(matches!( - codec.open_at(&sealed, &[], 62_000), - Err(RequestStateError::Expired) - )); - } - - #[test] - fn no_ttl_never_expires() { - let codec = RequestStateCodec::new(KEY.to_vec()); - let sealed = codec.seal_at(b"state", &SealOptions::new(), 1_000); - assert_eq!(codec.open_at(&sealed, &[], i64::MAX).unwrap(), b"state"); - } - - #[test] - fn ttl_and_associated_data_combine() { - let codec = RequestStateCodec::new(KEY.to_vec()); - let ctx = b"user:alice"; - let sealed = codec.seal_at( - b"state", - &SealOptions::new() - .associated_data(ctx) - .ttl(Duration::from_secs(60)), - 1_000, - ); - assert_eq!(codec.open_at(&sealed, ctx, 10_000).unwrap(), b"state"); - assert!(matches!( - codec.open_at(&sealed, b"user:bob", 10_000), - Err(RequestStateError::IntegrityCheckFailed) - )); - assert!(matches!( - codec.open_at(&sealed, ctx, 99_000), - Err(RequestStateError::Expired) - )); - } - } -} diff --git a/vendor/rmcp/src/model/resource.rs b/vendor/rmcp/src/model/resource.rs deleted file mode 100644 index 9c27dce2f..000000000 --- a/vendor/rmcp/src/model/resource.rs +++ /dev/null @@ -1,310 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use super::{Annotations, Icon, MetaObject}; - -/// A known resource that the server is capable of reading (spec `Resource`). -/// -/// Also used as the inner type of `ContentBlock::ResourceLink` (spec `ResourceLink extends Resource`). -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct Resource { - /// The URI of this resource (e.g. `file:///path/to/file`). - pub uri: String, - /// The programmatic name of the resource. - pub name: String, - /// Optional human-readable display title. - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option, - /// Optional description of what this resource represents. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - /// The MIME type of this resource, if known. - #[serde(skip_serializing_if = "Option::is_none")] - pub mime_type: Option, - /// The size of the raw resource content in bytes (before base64/tokenization), if known. - #[serde(skip_serializing_if = "Option::is_none")] - pub size: Option, - /// Optional set of icons the client may display for this resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub icons: Option>, - /// Optional protocol-level metadata for this resource. - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, - /// Optional annotations describing how the client should use this resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub annotations: Option, -} - -impl Resource { - pub fn new(uri: impl Into, name: impl Into) -> Self { - Self { - uri: uri.into(), - name: name.into(), - title: None, - description: None, - mime_type: None, - size: None, - icons: None, - meta: None, - annotations: None, - } - } - - pub fn with_title(mut self, title: impl Into) -> Self { - self.title = Some(title.into()); - self - } - - pub fn with_description(mut self, description: impl Into) -> Self { - self.description = Some(description.into()); - self - } - - pub fn with_mime_type(mut self, mime_type: impl Into) -> Self { - self.mime_type = Some(mime_type.into()); - self - } - - pub fn with_size(mut self, size: u64) -> Self { - self.size = Some(size); - self - } - - pub fn with_icons(mut self, icons: Vec) -> Self { - self.icons = Some(icons); - self - } - - pub fn with_meta(mut self, meta: MetaObject) -> Self { - self.meta = Some(meta); - self - } - - pub fn with_annotations(mut self, annotations: Annotations) -> Self { - self.annotations = Some(annotations); - self - } -} - -/// A template description for resources available on the server (spec `ResourceTemplate`). -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ResourceTemplate { - /// An RFC 6570 URI template for constructing resource URIs. - pub uri_template: String, - /// The programmatic name of the resource template. - pub name: String, - /// Optional human-readable display title. - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option, - /// Optional description of what this template is for. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - /// The MIME type for resources matching this template, if uniform. - #[serde(skip_serializing_if = "Option::is_none")] - pub mime_type: Option, - /// Optional set of icons the client may display for this template. - #[serde(skip_serializing_if = "Option::is_none")] - pub icons: Option>, - /// Optional protocol-level metadata for this resource template. - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, - /// Optional annotations describing how the client should use this template. - #[serde(skip_serializing_if = "Option::is_none")] - pub annotations: Option, -} - -impl ResourceTemplate { - pub fn new(uri_template: impl Into, name: impl Into) -> Self { - Self { - uri_template: uri_template.into(), - name: name.into(), - title: None, - description: None, - mime_type: None, - icons: None, - meta: None, - annotations: None, - } - } - - pub fn with_title(mut self, title: impl Into) -> Self { - self.title = Some(title.into()); - self - } - - pub fn with_description(mut self, description: impl Into) -> Self { - self.description = Some(description.into()); - self - } - - pub fn with_mime_type(mut self, mime_type: impl Into) -> Self { - self.mime_type = Some(mime_type.into()); - self - } - - pub fn with_icons(mut self, icons: Vec) -> Self { - self.icons = Some(icons); - self - } - - pub fn with_meta(mut self, meta: MetaObject) -> Self { - self.meta = Some(meta); - self - } - - pub fn with_annotations(mut self, annotations: Annotations) -> Self { - self.annotations = Some(annotations); - self - } -} - -/// The contents of a specific resource or sub-resource. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(untagged)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub enum ResourceContents { - #[serde(rename_all = "camelCase")] - TextResourceContents { - uri: String, - #[serde(skip_serializing_if = "Option::is_none")] - mime_type: Option, - text: String, - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - meta: Option, - }, - #[serde(rename_all = "camelCase")] - BlobResourceContents { - uri: String, - #[serde(skip_serializing_if = "Option::is_none")] - mime_type: Option, - blob: String, - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - meta: Option, - }, -} - -impl ResourceContents { - pub fn text(text: impl Into, uri: impl Into) -> Self { - Self::TextResourceContents { - uri: uri.into(), - mime_type: Some("text/plain".into()), - text: text.into(), - meta: None, - } - } - - pub fn blob(blob: impl Into, uri: impl Into) -> Self { - Self::BlobResourceContents { - uri: uri.into(), - mime_type: None, - blob: blob.into(), - meta: None, - } - } - - pub fn with_mime_type(mut self, mime_type: impl Into) -> Self { - match &mut self { - Self::TextResourceContents { mime_type: mt, .. } => *mt = Some(mime_type.into()), - Self::BlobResourceContents { mime_type: mt, .. } => *mt = Some(mime_type.into()), - } - self - } - - pub fn with_meta(mut self, meta: MetaObject) -> Self { - match &mut self { - Self::TextResourceContents { meta: m, .. } => *m = Some(meta), - Self::BlobResourceContents { meta: m, .. } => *m = Some(meta), - } - self - } -} - -#[cfg(test)] -mod tests { - use serde_json; - - use super::*; - use crate::model::IconTheme; - - #[test] - fn test_resource_serialization() { - let resource = Resource::new("file:///test.txt", "test") - .with_description("Test resource") - .with_mime_type("text/plain") - .with_size(100); - - let json = serde_json::to_string(&resource).unwrap(); - assert!(json.contains("mimeType")); - assert!(!json.contains("mime_type")); - } - - #[test] - fn test_resource_contents_serialization() { - let text_contents = ResourceContents::TextResourceContents { - uri: "file:///test.txt".to_string(), - mime_type: Some("text/plain".to_string()), - text: "Hello world".to_string(), - meta: None, - }; - - let json = serde_json::to_string(&text_contents).unwrap(); - assert!(json.contains("mimeType")); - assert!(!json.contains("mime_type")); - } - - #[test] - fn test_resource_template_with_icons() { - let resource_template = ResourceTemplate::new("file:///{path}", "template") - .with_title("Test Template") - .with_description("A test resource template") - .with_mime_type("text/plain") - .with_icons(vec![Icon { - src: "https://example.com/icon.png".to_string(), - mime_type: Some("image/png".to_string()), - sizes: Some(vec!["48x48".to_string()]), - theme: Some(IconTheme::Light), - }]); - - let json = serde_json::to_value(&resource_template).unwrap(); - assert!(json["icons"].is_array()); - assert_eq!(json["icons"][0]["src"], "https://example.com/icon.png"); - assert_eq!(json["icons"][0]["sizes"][0], "48x48"); - assert_eq!(json["icons"][0]["theme"], "light"); - } - - #[test] - fn test_resource_template_without_icons() { - let resource_template = ResourceTemplate::new("file:///{path}", "template"); - let json = serde_json::to_value(&resource_template).unwrap(); - assert!(json.get("icons").is_none()); - } - - #[test] - fn test_resource_size_u64() { - let resource = Resource::new("file:///big", "big").with_size(5_000_000_000); - let json = serde_json::to_value(&resource).unwrap(); - assert_eq!(json["size"], 5_000_000_000_u64); - } - - #[test] - fn test_resource_with_annotations() { - let resource = Resource::new("file:///test.txt", "test") - .with_annotations(Annotations::default().with_priority(0.9)); - let json = serde_json::to_value(&resource).unwrap(); - assert_eq!(json["annotations"]["priority"], 0.9_f32); - } - - #[test] - fn test_resource_template_with_meta() { - let resource_template = - ResourceTemplate::new("file:///{path}", "template").with_meta(MetaObject::default()); - let json = serde_json::to_value(&resource_template).unwrap(); - assert!(json.get("_meta").is_some()); - } -} diff --git a/vendor/rmcp/src/model/serde_impl.rs b/vendor/rmcp/src/model/serde_impl.rs deleted file mode 100644 index bfd3b5783..000000000 --- a/vendor/rmcp/src/model/serde_impl.rs +++ /dev/null @@ -1,851 +0,0 @@ -use std::borrow::Cow; - -use serde::{Deserialize, Serialize}; - -use super::{ - CustomNotification, CustomRequest, Extensions, JsonObject, MetaObject, Notification, - NotificationMetaObject, NotificationNoParam, Request, RequestMetaObject, RequestNoParam, - RequestOptionalParam, -}; - -/// Wire-side view of `params`: the `_meta` map plus the remaining fields. -/// -/// All metadata types are transparent wrappers over [`JsonObject`], so the -/// serde plumbing works on the raw map; call sites wrap/unwrap the typed -/// metadata ([`RequestMetaObject`] / [`NotificationMetaObject`]). -#[derive(Deserialize)] -struct WithMeta<'a, P> { - _meta: Option>, - #[serde(flatten)] - _rest: P, -} - -impl Serialize for WithMeta<'_, P> { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - use serde::ser::SerializeMap; - - // Serialize _rest to a Value so we can inspect and strip any duplicate _meta - let mut rest_value = - serde_json::to_value(&self._rest).map_err(serde::ser::Error::custom)?; - - // Extract _meta from the serialized params (if it's an object containing one) - let params_meta: Option = rest_value - .as_object_mut() - .and_then(|obj| obj.remove("_meta")) - .and_then(|v| serde_json::from_value(v).ok()); - - // Merge: params-level _meta as base, extensions-level _meta overwrites on conflict - let merged_meta = match (self._meta.as_deref(), params_meta) { - (Some(ext_meta), Some(mut params_meta)) => { - params_meta.extend(ext_meta.clone()); - Some(params_meta) - } - (Some(ext_meta), None) => Some(ext_meta.clone()), - (None, Some(params_meta)) => Some(params_meta), - (None, None) => None, - }; - - // Serialize as a flat map: single _meta + remaining params fields - let rest_obj = match rest_value { - serde_json::Value::Object(map) => map, - _ => serde_json::Map::new(), - }; - let meta_count = usize::from(merged_meta.is_some()); - let mut map = serializer.serialize_map(Some(rest_obj.len() + meta_count))?; - - if let Some(meta) = &merged_meta { - map.serialize_entry("_meta", meta)?; - } - - for (k, v) in &rest_obj { - map.serialize_entry(k, v)?; - } - - map.end() - } -} - -#[derive(Serialize, Deserialize)] -struct Proxy<'a, M, P> { - method: M, - params: WithMeta<'a, P>, -} - -#[derive(Serialize, Deserialize)] -struct ProxyOptionalParam<'a, M, P> { - method: M, - params: Option>, -} - -#[derive(Serialize, Deserialize)] -struct ProxyNoParam { - method: M, -} - -/// Combine the message-specific `_meta` map with a legacy [`MetaObject`] -/// extension so metadata stored in [`Extensions`] is not lost on the wire. -/// On key conflicts the message-specific map wins. -fn merge_legacy_meta<'a>( - typed: Option<&'a JsonObject>, - extensions: &'a Extensions, -) -> Option> { - let legacy = extensions.get::().map(|meta| &meta.0); - match (typed, legacy) { - (Some(typed), None) => Some(Cow::Borrowed(typed)), - (None, Some(legacy)) => Some(Cow::Borrowed(legacy)), - (Some(typed), Some(legacy)) => { - let mut merged = legacy.clone(); - merged.extend( - typed - .iter() - .map(|(key, value)| (key.clone(), value.clone())), - ); - Some(Cow::Owned(merged)) - } - (None, None) => None, - } -} - -/// Borrow the request `_meta` map from extensions, if any. -fn request_meta(extensions: &Extensions) -> Option> { - let typed = extensions.get::().map(|meta| &meta.0.0); - merge_legacy_meta(typed, extensions) -} - -/// Borrow the notification `_meta` map from extensions, if any. -fn notification_meta(extensions: &Extensions) -> Option> { - let typed = extensions - .get::() - .map(|meta| &meta.0.0); - merge_legacy_meta(typed, extensions) -} - -/// Build extensions holding a typed metadata map deserialized from `params._meta`. -fn extensions_with_meta(meta: Option>) -> Extensions -where - T: From + Clone + Send + Sync + 'static, -{ - let mut extensions = Extensions::new(); - if let Some(meta) = meta { - extensions.insert(T::from(meta.into_owned())); - } - extensions -} - -impl Serialize for Request -where - M: Serialize, - R: Serialize, -{ - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - Proxy::serialize( - &Proxy { - method: &self.method, - params: WithMeta { - _rest: &self.params, - _meta: request_meta(&self.extensions), - }, - }, - serializer, - ) - } -} - -impl<'de, M, R> Deserialize<'de> for Request -where - M: Deserialize<'de>, - R: Deserialize<'de>, -{ - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let body = Proxy::deserialize(deserializer)?; - Ok(Request { - extensions: extensions_with_meta::(body.params._meta), - method: body.method, - params: body.params._rest, - }) - } -} - -impl Serialize for RequestOptionalParam -where - M: Serialize, - R: Serialize, -{ - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - Proxy::serialize( - &Proxy { - method: &self.method, - params: WithMeta { - _rest: &self.params, - _meta: request_meta(&self.extensions), - }, - }, - serializer, - ) - } -} - -impl<'de, M, R> Deserialize<'de> for RequestOptionalParam -where - M: Deserialize<'de>, - R: Deserialize<'de>, -{ - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let body = ProxyOptionalParam::<'_, _, Option>::deserialize(deserializer)?; - let mut params = None; - let mut _meta = None; - if let Some(body_params) = body.params { - params = body_params._rest; - _meta = body_params._meta; - } - Ok(RequestOptionalParam { - extensions: extensions_with_meta::(_meta), - method: body.method, - params, - }) - } -} - -impl Serialize for RequestNoParam -where - M: Serialize, -{ - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - // Emit `params` only when metadata is present, so the wire shape of - // meta-less requests stays `{"method": ...}`. - match request_meta(&self.extensions) { - Some(_meta) => Proxy::serialize( - &Proxy { - method: &self.method, - params: WithMeta { - _meta: Some(_meta), - _rest: (), - }, - }, - serializer, - ), - None => ProxyNoParam::serialize( - &ProxyNoParam { - method: &self.method, - }, - serializer, - ), - } - } -} - -impl<'de, M> Deserialize<'de> for RequestNoParam -where - M: Deserialize<'de>, -{ - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let body = ProxyOptionalParam::<'_, _, Option>::deserialize(deserializer)?; - let _meta = body.params.and_then(|params| params._meta); - Ok(RequestNoParam { - extensions: extensions_with_meta::(_meta), - method: body.method, - }) - } -} - -impl Serialize for Notification -where - M: Serialize, - R: Serialize, -{ - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - Proxy::serialize( - &Proxy { - method: &self.method, - params: WithMeta { - _rest: &self.params, - _meta: notification_meta(&self.extensions), - }, - }, - serializer, - ) - } -} - -impl<'de, M, R> Deserialize<'de> for Notification -where - M: Deserialize<'de>, - R: Deserialize<'de>, -{ - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let body = ProxyOptionalParam::<'_, _, R>::deserialize(deserializer)?; - let (_meta, params) = match body.params { - Some(with_meta) => (with_meta._meta, with_meta._rest), - None => { - // JSON-RPC 2.0: params is optional. Treat absent params as {}. - let empty = serde_json::Value::Object(serde_json::Map::new()); - let r = R::deserialize(empty).map_err(serde::de::Error::custom)?; - (None, r) - } - }; - Ok(Notification { - extensions: extensions_with_meta::(_meta), - method: body.method, - params, - }) - } -} - -impl Serialize for NotificationNoParam -where - M: Serialize, -{ - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - // Emit `params` only when metadata is present, so the wire shape of - // meta-less notifications stays `{"method": ...}`. - match notification_meta(&self.extensions) { - Some(_meta) => Proxy::serialize( - &Proxy { - method: &self.method, - params: WithMeta { - _meta: Some(_meta), - _rest: (), - }, - }, - serializer, - ), - None => ProxyNoParam::serialize( - &ProxyNoParam { - method: &self.method, - }, - serializer, - ), - } - } -} - -impl<'de, M> Deserialize<'de> for NotificationNoParam -where - M: Deserialize<'de>, -{ - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let body = ProxyOptionalParam::<'_, _, Option>::deserialize(deserializer)?; - let _meta = body.params.and_then(|params| params._meta); - Ok(NotificationNoParam { - extensions: extensions_with_meta::(_meta), - method: body.method, - }) - } -} - -impl Serialize for CustomRequest { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - let _meta = request_meta(&self.extensions); - let params = self.params.as_ref(); - - let params = if _meta.is_some() || params.is_some() { - Some(WithMeta { - _meta, - _rest: &self.params, - }) - } else { - None - }; - - ProxyOptionalParam::serialize( - &ProxyOptionalParam { - method: &self.method, - params, - }, - serializer, - ) - } -} - -impl<'de> Deserialize<'de> for CustomRequest { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let body = - ProxyOptionalParam::<'_, _, Option>::deserialize(deserializer)?; - let mut params = None; - let mut _meta = None; - if let Some(body_params) = body.params { - params = body_params._rest; - _meta = body_params._meta; - } - Ok(CustomRequest { - extensions: extensions_with_meta::(_meta), - method: body.method, - params, - }) - } -} - -impl Serialize for CustomNotification { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - let _meta = notification_meta(&self.extensions); - let params = self.params.as_ref(); - - let params = if _meta.is_some() || params.is_some() { - Some(WithMeta { - _meta, - _rest: &self.params, - }) - } else { - None - }; - - ProxyOptionalParam::serialize( - &ProxyOptionalParam { - method: &self.method, - params, - }, - serializer, - ) - } -} - -impl<'de> Deserialize<'de> for CustomNotification { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let body = - ProxyOptionalParam::<'_, _, Option>::deserialize(deserializer)?; - let mut params = None; - let mut _meta = None; - if let Some(body_params) = body.params { - params = body_params._rest; - _meta = body_params._meta; - } - Ok(CustomNotification { - extensions: extensions_with_meta::(_meta), - method: body.method, - params, - }) - } -} - -#[cfg(test)] -mod test { - use serde_json::json; - - use crate::model::{ - CallToolRequest, CallToolRequestParams, CustomRequest, Extensions, InitializedNotification, - ListToolsRequest, NotificationMetaObject, PingRequest, RequestMetaObject, - }; - - #[test] - fn test_deserialize_lost_tools_request() { - let _req: ListToolsRequest = serde_json::from_value(json!( - { - "method": "tools/list", - } - )) - .unwrap(); - } - - #[test] - fn test_no_duplicate_meta_both_sources() { - // When both extensions and params contain _meta, the output should have - // a single merged _meta key (not two separate ones). - let mut extensions = Extensions::new(); - let mut ext_meta = RequestMetaObject::new(); - ext_meta.insert("traceId".to_string(), json!("abc")); - extensions.insert(ext_meta); - - let mut params_meta = RequestMetaObject::new(); - params_meta.insert("progressToken".to_string(), json!(1)); - - let req = CallToolRequest { - extensions, - method: Default::default(), - params: CallToolRequestParams { - meta: Some(params_meta), - name: "my_tool".into(), - arguments: None, - input_responses: None, - request_state: None, - }, - }; - - let value = serde_json::to_value(&req).unwrap(); - let params = value.get("params").unwrap(); - - // There should be exactly one _meta key (JSON objects naturally deduplicate) - let meta = params.get("_meta").unwrap(); - - // Both entries should be present in the merged _meta - assert_eq!(meta.get("traceId").unwrap(), "abc"); - assert_eq!(meta.get("progressToken").unwrap(), 1); - - // Verify the raw JSON string has exactly one occurrence of "_meta" - let raw = serde_json::to_string(&req).unwrap(); - assert_eq!( - raw.matches("\"_meta\"").count(), - 1, - "Expected exactly one _meta key in serialized output, got: {}", - raw - ); - } - - #[test] - fn test_meta_only_from_extensions() { - let mut extensions = Extensions::new(); - let mut ext_meta = RequestMetaObject::new(); - ext_meta.insert("traceId".to_string(), json!("ext-only")); - extensions.insert(ext_meta); - - let req = CallToolRequest { - extensions, - method: Default::default(), - params: CallToolRequestParams { - meta: None, - name: "my_tool".into(), - arguments: None, - input_responses: None, - request_state: None, - }, - }; - - let value = serde_json::to_value(&req).unwrap(); - let meta = value["params"]["_meta"].as_object().unwrap(); - assert_eq!(meta.get("traceId").unwrap(), "ext-only"); - } - - #[test] - fn test_meta_only_from_params() { - let mut params_meta = RequestMetaObject::new(); - params_meta.insert("progressToken".to_string(), json!(42)); - - let req = CallToolRequest { - extensions: Extensions::new(), - method: Default::default(), - params: CallToolRequestParams { - meta: Some(params_meta), - name: "my_tool".into(), - arguments: None, - input_responses: None, - request_state: None, - }, - }; - - let value = serde_json::to_value(&req).unwrap(); - let meta = value["params"]["_meta"].as_object().unwrap(); - assert_eq!(meta.get("progressToken").unwrap(), 42); - } - - #[test] - fn test_no_meta_emitted_when_neither_source() { - let req = CallToolRequest { - extensions: Extensions::new(), - method: Default::default(), - params: CallToolRequestParams { - meta: None, - name: "my_tool".into(), - arguments: None, - input_responses: None, - request_state: None, - }, - }; - - let value = serde_json::to_value(&req).unwrap(); - assert!( - value["params"].get("_meta").is_none(), - "Expected no _meta when neither source is populated" - ); - } - - #[test] - fn test_extensions_meta_takes_priority_on_conflict() { - // When both sources have the same key, extensions should win. - let mut extensions = Extensions::new(); - let mut ext_meta = RequestMetaObject::new(); - ext_meta.insert("shared_key".to_string(), json!("from_extensions")); - extensions.insert(ext_meta); - - let mut params_meta = RequestMetaObject::new(); - params_meta.insert("shared_key".to_string(), json!("from_params")); - params_meta.insert("params_only".to_string(), json!("kept")); - - let req = CallToolRequest { - extensions, - method: Default::default(), - params: CallToolRequestParams { - meta: Some(params_meta), - name: "my_tool".into(), - arguments: None, - input_responses: None, - request_state: None, - }, - }; - - let value = serde_json::to_value(&req).unwrap(); - let meta = value["params"]["_meta"].as_object().unwrap(); - assert_eq!(meta.get("shared_key").unwrap(), "from_extensions"); - assert_eq!(meta.get("params_only").unwrap(), "kept"); - } - - #[test] - fn test_round_trip_preserves_meta() { - let mut extensions = Extensions::new(); - let mut ext_meta = RequestMetaObject::new(); - ext_meta.insert("traceId".to_string(), json!("round-trip")); - extensions.insert(ext_meta); - - let req = CallToolRequest { - extensions, - method: Default::default(), - params: CallToolRequestParams { - meta: None, - name: "my_tool".into(), - arguments: Some(serde_json::Map::from_iter([("x".to_string(), json!(1))])), - input_responses: None, - request_state: None, - }, - }; - - let serialized = serde_json::to_string(&req).unwrap(); - let deserialized: CallToolRequest = serde_json::from_str(&serialized).unwrap(); - - // Extensions should have the meta after round-trip - let meta = deserialized.extensions.get::().unwrap(); - assert_eq!(meta.get("traceId").unwrap(), "round-trip"); - - // Params should be preserved - assert_eq!(deserialized.params.name, "my_tool"); - assert_eq!( - deserialized - .params - .arguments - .as_ref() - .unwrap() - .get("x") - .unwrap(), - &json!(1) - ); - } - - #[test] - fn test_custom_request_no_duplicate_meta() { - // CustomRequest uses Option as params — verify no duplicate _meta. - let mut extensions = Extensions::new(); - let mut ext_meta = RequestMetaObject::new(); - ext_meta.insert("traceId".to_string(), json!("custom-ext")); - extensions.insert(ext_meta); - - let params = Some(json!({ - "_meta": { "progressToken": 99 }, - "foo": "bar" - })); - - let req = CustomRequest { - extensions, - method: "custom/method".into(), - params, - }; - - let raw = serde_json::to_string(&req).unwrap(); - assert_eq!( - raw.matches("\"_meta\"").count(), - 1, - "Expected exactly one _meta key in CustomRequest output, got: {}", - raw - ); - - let value: serde_json::Value = serde_json::from_str(&raw).unwrap(); - let meta = value["params"]["_meta"].as_object().unwrap(); - assert_eq!(meta.get("traceId").unwrap(), "custom-ext"); - assert_eq!(meta.get("progressToken").unwrap(), 99); - } - - #[test] - fn test_request_no_param_meta_round_trip() { - // Ping-shaped requests must carry `params._meta` on the wire. - let mut extensions = Extensions::new(); - let mut meta = RequestMetaObject::new(); - meta.insert("traceId".to_string(), json!("ping-trace")); - extensions.insert(meta); - - let req = PingRequest { - method: Default::default(), - extensions, - }; - - let value = serde_json::to_value(&req).unwrap(); - assert_eq!(value["params"]["_meta"]["traceId"], json!("ping-trace")); - - let deserialized: PingRequest = serde_json::from_value(value).unwrap(); - let meta = deserialized - .extensions - .get::() - .expect("meta should survive the round-trip"); - assert_eq!(meta.get("traceId").unwrap(), &json!("ping-trace")); - } - - #[test] - fn test_request_no_param_without_meta_has_no_params_key() { - let req = PingRequest { - method: Default::default(), - extensions: Extensions::new(), - }; - let value = serde_json::to_value(&req).unwrap(); - assert!( - value.get("params").is_none(), - "meta-less no-param requests must keep the historical wire shape: {value}" - ); - } - - #[test] - fn test_notification_no_param_meta_round_trip() { - // Initialized-shaped notifications must carry `params._meta` on the wire. - let mut extensions = Extensions::new(); - let mut meta = NotificationMetaObject::new(); - meta.insert("traceId".to_string(), json!("init-trace")); - extensions.insert(meta); - - let notification = InitializedNotification { - method: Default::default(), - extensions, - }; - - let value = serde_json::to_value(¬ification).unwrap(); - assert_eq!(value["params"]["_meta"]["traceId"], json!("init-trace")); - - let deserialized: InitializedNotification = serde_json::from_value(value).unwrap(); - let meta = deserialized - .extensions - .get::() - .expect("meta should survive the round-trip"); - assert_eq!(meta.get("traceId").unwrap(), &json!("init-trace")); - } - - #[test] - fn test_notification_no_param_without_meta_has_no_params_key() { - let notification = InitializedNotification { - method: Default::default(), - extensions: Extensions::new(), - }; - let value = serde_json::to_value(¬ification).unwrap(); - assert!( - value.get("params").is_none(), - "meta-less no-param notifications must keep the historical wire shape: {value}" - ); - } - - #[test] - fn test_no_param_ignores_unknown_params_fields() { - // Old/foreign peers may send params without _meta; both shapes must parse. - let _req: PingRequest = - serde_json::from_value(json!({"method": "ping", "params": {}})).unwrap(); - let _req: PingRequest = - serde_json::from_value(json!({"method": "ping", "params": {"unknown": 1}})).unwrap(); - let _req: PingRequest = serde_json::from_value(json!({"method": "ping"})).unwrap(); - } - - #[test] - fn test_legacy_meta_extension_still_serializes() { - let mut extensions = Extensions::new(); - let mut legacy = crate::model::MetaObject::new(); - legacy.insert("traceId".to_string(), json!("legacy")); - extensions.insert(legacy); - - let req = CallToolRequest { - extensions, - method: Default::default(), - params: CallToolRequestParams { - meta: None, - name: "my_tool".into(), - arguments: None, - input_responses: None, - request_state: None, - }, - }; - - let value = serde_json::to_value(&req).unwrap(); - assert_eq!(value["params"]["_meta"]["traceId"], json!("legacy")); - } - - #[test] - fn test_typed_meta_wins_over_legacy_extension_on_conflict() { - let mut extensions = Extensions::new(); - let mut legacy = crate::model::MetaObject::new(); - legacy.insert("shared".to_string(), json!("legacy")); - legacy.insert("legacy_only".to_string(), json!("kept")); - extensions.insert(legacy); - let mut typed = RequestMetaObject::new(); - typed.insert("shared".to_string(), json!("typed")); - extensions.insert(typed); - - let req = CallToolRequest { - extensions, - method: Default::default(), - params: CallToolRequestParams { - meta: None, - name: "my_tool".into(), - arguments: None, - input_responses: None, - request_state: None, - }, - }; - - let value = serde_json::to_value(&req).unwrap(); - let meta = value["params"]["_meta"].as_object().unwrap(); - assert_eq!(meta.get("shared").unwrap(), "typed"); - assert_eq!(meta.get("legacy_only").unwrap(), "kept"); - } - - #[test] - fn test_arbitrary_meta_keys_round_trip_unchanged() { - let input = json!({ - "method": "tools/call", - "params": { - "_meta": { - "progressToken": 5, - "vendor.example/custom": {"nested": ["a", 1, null]}, - "another-key": true - }, - "name": "my_tool" - } - }); - let req: CallToolRequest = serde_json::from_value(input.clone()).unwrap(); - let output = serde_json::to_value(&req).unwrap(); - assert_eq!(input, output); - } -} diff --git a/vendor/rmcp/src/model/task.rs b/vendor/rmcp/src/model/task.rs deleted file mode 100644 index bbdd1c99d..000000000 --- a/vendor/rmcp/src/model/task.rs +++ /dev/null @@ -1,544 +0,0 @@ -//! Task types for the MCP Tasks extension (SEP-2663). -//! -//! Tasks are defined by the official `io.modelcontextprotocol/tasks` extension. -//! A server may respond to a supported request (currently `tools/call`) with a -//! [`CreateTaskResult`] (`resultType: "task"`) instead of the standard result. -//! The client then polls `tasks/get`, answers in-task server-to-client requests -//! via `tasks/update`, and may signal cancellation via `tasks/cancel`. - -use serde::{Deserialize, Serialize}; - -use super::{InputRequests, JsonObject, MetaObject, ResultType}; - -/// Extension identifier for the MCP Tasks extension (SEP-2663). -pub const TASKS_EXTENSION_ID: &str = "io.modelcontextprotocol/tasks"; - -/// Canonical task lifecycle status (SEP-2663). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub enum TaskStatus { - /// The request is currently being processed. - #[default] - Working, - /// The server needs input from the client before the task can proceed. - InputRequired, - /// The request completed successfully and the result is available. - /// This includes tool calls that returned results with `isError: true`. - Completed, - /// The request failed due to a JSON-RPC error during execution. - Failed, - /// The request was cancelled before completion. - Cancelled, -} - -impl TaskStatus { - /// Returns `true` for terminal statuses (`completed`, `failed`, `cancelled`). - pub fn is_terminal(&self) -> bool { - matches!(self, Self::Completed | Self::Failed | Self::Cancelled) - } -} - -/// Operational metadata about ongoing work (spec `Task`, SEP-2663). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct Task { - /// Stable identifier for this task, generated by the server. - pub task_id: String, - /// Current task status. - pub status: TaskStatus, - /// Optional message describing the current task state. - /// This MAY be exposed to the end-user or model. - #[serde(skip_serializing_if = "Option::is_none")] - pub status_message: Option, - /// ISO 8601 timestamp when the task was created. - pub created_at: String, - /// ISO 8601 timestamp when the task was last updated. - pub last_updated_at: String, - /// Time-to-live duration from creation in integer milliseconds; `None` - /// (serialized as `null`) means unlimited. The server may discard the task - /// after the TTL elapses. This value MAY change over the lifetime of a task. - pub ttl_ms: Option, - /// Suggested polling interval in integer milliseconds. Clients SHOULD honor - /// this value to avoid overwhelming the server. This value MAY change over - /// the lifetime of a task. - #[serde(skip_serializing_if = "Option::is_none")] - pub poll_interval_ms: Option, -} - -impl Task { - /// Create a new task with required fields. - pub fn new( - task_id: impl Into, - status: TaskStatus, - created_at: impl Into, - last_updated_at: impl Into, - ) -> Self { - Self { - task_id: task_id.into(), - status, - status_message: None, - created_at: created_at.into(), - last_updated_at: last_updated_at.into(), - ttl_ms: None, - poll_interval_ms: None, - } - } - - /// Set the status message. - pub fn with_status_message(mut self, status_message: impl Into) -> Self { - self.status_message = Some(status_message.into()); - self - } - - /// Set the TTL in milliseconds. `None` means unlimited retention. - pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self { - self.ttl_ms = Some(ttl_ms); - self - } - - /// Set the suggested poll interval in milliseconds. - pub fn with_poll_interval_ms(mut self, poll_interval_ms: u64) -> Self { - self.poll_interval_ms = Some(poll_interval_ms); - self - } -} - -/// Status-specific payload carried alongside the base [`Task`] fields in a -/// [`DetailedTask`]. -/// -/// Mirrors the spec's `WorkingTask` / `InputRequiredTask` / `CompletedTask` / -/// `FailedTask` / `CancelledTask` union: the variant is discriminated by the -/// `status` field on the wire, with the payload fields inlined at the top level. -#[derive(Debug, Clone, PartialEq)] -#[non_exhaustive] -pub enum TaskPayload { - /// `status: "working"` — no additional payload. - Working, - /// `status: "input_required"` — outstanding server-to-client requests. - InputRequired { - /// Server-to-client requests that need to be fulfilled during task - /// execution. Keys are arbitrary identifiers for matching requests - /// to responses, unique over the lifetime of the task. - input_requests: InputRequests, - }, - /// `status: "completed"` — the final result of the task. The structure - /// matches the result type of the original request (e.g. `CallToolResult`). - Completed { - /// The final result of the original request. - result: JsonObject, - }, - /// `status: "failed"` — the JSON-RPC error that caused the task to fail. - Failed { - /// The JSON-RPC error object. - error: JsonObject, - }, - /// `status: "cancelled"` — no additional payload. - Cancelled, -} - -impl TaskPayload { - /// The [`TaskStatus`] this payload corresponds to. - pub fn status(&self) -> TaskStatus { - match self { - Self::Working => TaskStatus::Working, - Self::InputRequired { .. } => TaskStatus::InputRequired, - Self::Completed { .. } => TaskStatus::Completed, - Self::Failed { .. } => TaskStatus::Failed, - Self::Cancelled => TaskStatus::Cancelled, - } - } -} - -/// A task with its status-specific payload inlined (spec `DetailedTask`). -/// -/// Used by `tasks/get` responses ([`GetTaskResult`]) and `notifications/tasks` -/// ([`TaskStatusNotificationParams`](crate::model::TaskStatusNotificationParams)). -/// On the wire, the payload fields (`inputRequests` / `result` / `error`) are -/// flattened at the top level next to the base [`Task`] fields, and `status` -/// discriminates the variant. -#[derive(Debug, Clone, PartialEq)] -#[non_exhaustive] -pub struct DetailedTask { - /// Base task metadata. Its `status` always agrees with the payload. - pub task: Task, - /// Status-specific payload. - pub payload: TaskPayload, -} - -impl DetailedTask { - /// Build a `DetailedTask`, forcing `task.status` to match the payload. - pub fn new(mut task: Task, payload: TaskPayload) -> Self { - task.status = payload.status(); - Self { task, payload } - } - - /// The current status. - pub fn status(&self) -> TaskStatus { - self.task.status - } -} - -// Wire shape helper: base Task fields + optional payload fields, all flattened. -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -struct DetailedTaskWire { - #[serde(flatten)] - task: Task, - #[serde(skip_serializing_if = "Option::is_none")] - input_requests: Option, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -impl From for DetailedTaskWire { - fn from(value: DetailedTask) -> Self { - let DetailedTask { task, payload } = value; - let (input_requests, result, error) = match payload { - TaskPayload::Working | TaskPayload::Cancelled => (None, None, None), - TaskPayload::InputRequired { input_requests } => (Some(input_requests), None, None), - TaskPayload::Completed { result } => (None, Some(result), None), - TaskPayload::Failed { error } => (None, None, Some(error)), - }; - Self { - task, - input_requests, - result, - error, - } - } -} - -impl TryFrom for DetailedTask { - type Error = String; - fn try_from(wire: DetailedTaskWire) -> Result { - let payload = match wire.task.status { - TaskStatus::Working => TaskPayload::Working, - TaskStatus::Cancelled => TaskPayload::Cancelled, - TaskStatus::InputRequired => TaskPayload::InputRequired { - input_requests: wire.input_requests.ok_or_else(|| { - "task with status \"input_required\" is missing `inputRequests`".to_owned() - })?, - }, - TaskStatus::Completed => TaskPayload::Completed { - result: wire.result.ok_or_else(|| { - "task with status \"completed\" is missing `result`".to_owned() - })?, - }, - TaskStatus::Failed => TaskPayload::Failed { - error: wire - .error - .ok_or_else(|| "task with status \"failed\" is missing `error`".to_owned())?, - }, - }; - Ok(DetailedTask { - task: wire.task, - payload, - }) - } -} - -impl Serialize for DetailedTask { - fn serialize(&self, serializer: S) -> Result { - DetailedTaskWire::from(self.clone()).serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for DetailedTask { - fn deserialize>(deserializer: D) -> Result { - let wire = DetailedTaskWire::deserialize(deserializer)?; - Self::try_from(wire).map_err(serde::de::Error::custom) - } -} - -#[cfg(feature = "schemars")] -impl schemars::JsonSchema for DetailedTask { - fn schema_name() -> std::borrow::Cow<'static, str> { - "DetailedTask".into() - } - fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { - // The actual wire shape: base Task fields plus the optional - // status-specific payload fields (inputRequests / result / error). - ::json_schema(generator) - } -} - -/// Result returned in lieu of a standard result to indicate the request will -/// be processed asynchronously (spec `CreateTaskResult`, `resultType: "task"`). -/// -/// The embedded task is the seed state for the task; the client uses -/// `task.task_id` for all subsequent `tasks/get`, `tasks/update`, and -/// `tasks/cancel` calls. -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct CreateTaskResult { - /// Always `"task"`. - pub result_type: ResultType, - /// Seed state of the newly created task, flattened at the top level. - #[serde(flatten)] - pub task: Task, - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -// Custom deserializer that requires `resultType: "task"`. Without this, -// `CreateTaskResult` would greedily match other task-shaped results (e.g. -// `tasks/get` responses, which also carry `taskId`/`status` at the top level -// but use `resultType: "complete"`) inside `#[serde(untagged)]` unions such -// as `ServerResult`. -impl<'de> Deserialize<'de> for CreateTaskResult { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - struct Helper { - result_type: ResultType, - #[serde(flatten)] - task: Task, - #[serde(rename = "_meta", default)] - meta: Option, - } - let helper = Helper::deserialize(deserializer)?; - if !helper.result_type.is_task() { - return Err(serde::de::Error::custom( - "CreateTaskResult requires resultType to be \"task\"", - )); - } - Ok(CreateTaskResult { - result_type: helper.result_type, - task: helper.task, - meta: helper.meta, - }) - } -} - -impl CreateTaskResult { - /// Create a new `CreateTaskResult` from the seed task state. - pub fn new(task: Task) -> Self { - Self { - result_type: ResultType::TASK, - task, - meta: None, - } - } - - /// Sets the protocol-level metadata for this result. - pub fn with_meta(mut self, meta: MetaObject) -> Self { - self.meta = Some(meta); - self - } -} - -/// Response to a `tasks/get` request (spec `GetTaskResult = Result & DetailedTask`). -/// -/// `resultType` is `"complete"` — this is the standard result shape for -/// `tasks/get`, not a task handle. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct GetTaskResult { - /// Result type discriminator. `tasks/get` responses are standard results: - /// `"complete"` (SEP-2322). Absent values deserialize as `"complete"`. - #[serde(default)] - pub result_type: ResultType, - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, - /// The task with status-specific payload inlined. - #[serde(flatten)] - pub task: DetailedTask, -} - -impl GetTaskResult { - pub fn new(task: DetailedTask) -> Self { - Self { - result_type: ResultType::COMPLETE, - meta: None, - task, - } - } -} - -/// Empty acknowledgement for `tasks/update` and `tasks/cancel` (SEP-2663). -/// -/// The spec requires these acks to be empty results carrying the SEP-2322 -/// `resultType: "complete"` discriminator; task state changes are observed -/// via the next `tasks/get`. -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct TaskAckResult { - /// Always `"complete"`. - pub result_type: ResultType, - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -// Custom deserializer that requires `resultType: "complete"` and rejects any -// other fields. Without this, `TaskAckResult` would greedily match arbitrary -// result objects carrying a `resultType` key inside `#[serde(untagged)]` -// unions such as `ServerResult`, shadowing `CustomResult` and losing data. -impl<'de> Deserialize<'de> for TaskAckResult { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(rename_all = "camelCase", deny_unknown_fields)] - struct Helper { - result_type: ResultType, - #[serde(rename = "_meta", default)] - meta: Option, - } - let helper = Helper::deserialize(deserializer)?; - if !helper.result_type.is_complete() { - return Err(serde::de::Error::custom( - "TaskAckResult requires resultType to be \"complete\"", - )); - } - Ok(TaskAckResult { - result_type: helper.result_type, - meta: helper.meta, - }) - } -} - -impl Default for TaskAckResult { - fn default() -> Self { - Self { - result_type: ResultType::COMPLETE, - meta: None, - } - } -} - -impl TaskAckResult { - pub fn new() -> Self { - Self::default() - } -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - fn base_task(status: TaskStatus) -> Task { - Task::new( - "task-1", - status, - "2025-11-25T10:30:00Z", - "2025-11-25T10:40:00Z", - ) - .with_ttl_ms(60000) - .with_poll_interval_ms(5000) - } - - #[test] - fn create_task_result_wire_shape() { - let result = CreateTaskResult::new(base_task(TaskStatus::Working)); - let value = serde_json::to_value(&result).unwrap(); - assert_eq!( - value, - json!({ - "resultType": "task", - "taskId": "task-1", - "status": "working", - "createdAt": "2025-11-25T10:30:00Z", - "lastUpdatedAt": "2025-11-25T10:40:00Z", - "ttlMs": 60000, - "pollIntervalMs": 5000 - }) - ); - let roundtrip: CreateTaskResult = serde_json::from_value(value).unwrap(); - assert_eq!(roundtrip, result); - } - - #[test] - fn ttl_ms_null_means_unlimited() { - let mut task = base_task(TaskStatus::Working); - task.ttl_ms = None; - let value = serde_json::to_value(&task).unwrap(); - assert_eq!(value["ttlMs"], serde_json::Value::Null); - let roundtrip: Task = serde_json::from_value(value).unwrap(); - assert_eq!(roundtrip.ttl_ms, None); - } - - #[test] - fn detailed_task_completed_roundtrip() { - let detailed = DetailedTask::new( - base_task(TaskStatus::Working), - TaskPayload::Completed { - result: serde_json::from_value(json!({ - "content": [{"type": "text", "text": "ok"}], - "isError": false - })) - .unwrap(), - }, - ); - // Status is forced to match the payload. - assert_eq!(detailed.status(), TaskStatus::Completed); - let value = serde_json::to_value(&detailed).unwrap(); - assert_eq!(value["status"], "completed"); - assert_eq!(value["result"]["isError"], false); - let roundtrip: DetailedTask = serde_json::from_value(value).unwrap(); - assert_eq!(roundtrip, detailed); - } - - #[test] - fn detailed_task_input_required_requires_input_requests() { - let err = serde_json::from_value::(json!({ - "taskId": "task-1", - "status": "input_required", - "createdAt": "2025-11-25T10:30:00Z", - "lastUpdatedAt": "2025-11-25T10:40:00Z", - "ttlMs": null - })) - .unwrap_err(); - assert!(err.to_string().contains("inputRequests")); - } - - #[test] - fn detailed_task_failed_roundtrip() { - let detailed = DetailedTask::new( - base_task(TaskStatus::Failed), - TaskPayload::Failed { - error: serde_json::from_value(json!({ - "code": -32603, - "message": "boom" - })) - .unwrap(), - }, - ); - let value = serde_json::to_value(&detailed).unwrap(); - assert_eq!(value["status"], "failed"); - assert_eq!(value["error"]["code"], -32603); - let roundtrip: DetailedTask = serde_json::from_value(value).unwrap(); - assert_eq!(roundtrip, detailed); - } - - #[test] - fn get_task_result_flattens_detailed_task() { - let result = GetTaskResult::new(DetailedTask::new( - base_task(TaskStatus::Working), - TaskPayload::Working, - )); - let value = serde_json::to_value(&result).unwrap(); - assert_eq!(value["taskId"], "task-1"); - assert_eq!(value["status"], "working"); - let roundtrip: GetTaskResult = serde_json::from_value(value).unwrap(); - assert_eq!(roundtrip, result); - } -} diff --git a/vendor/rmcp/src/model/tool.rs b/vendor/rmcp/src/model/tool.rs deleted file mode 100644 index be7cbbd0a..000000000 --- a/vendor/rmcp/src/model/tool.rs +++ /dev/null @@ -1,259 +0,0 @@ -use std::{borrow::Cow, sync::Arc}; - -#[cfg(feature = "server")] -use schemars::JsonSchema; -/// Tools represent a routine that a server can execute -/// Tool calls represent requests from the client to execute one -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use super::{Icon, JsonObject, MetaObject}; - -/// A tool that can be used by a model. -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct Tool { - /// The name of the tool - pub name: Cow<'static, str>, - /// A human-readable title for the tool - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option, - /// A description of what the tool does - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option>, - /// A JSON Schema object defining the expected parameters for the tool - pub input_schema: Arc, - /// An optional JSON Schema object defining the structure of the tool's output - #[serde(skip_serializing_if = "Option::is_none")] - pub output_schema: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional additional tool information. - pub annotations: Option, - /// Optional list of icons for the tool - #[serde(skip_serializing_if = "Option::is_none")] - pub icons: Option>, - /// Optional additional metadata for this tool - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -/// Additional properties describing a Tool to clients. -/// -/// NOTE: all properties in ToolAnnotations are **hints**. -/// They are not guaranteed to provide a faithful description of -/// tool behavior (including descriptive properties like `title`). -/// -/// Clients should never make tool use decisions based on ToolAnnotations -/// received from untrusted servers. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ToolAnnotations { - /// A human-readable title for the tool. - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option, - - /// If true, the tool does not modify its environment. - /// - /// Default: false - #[serde(skip_serializing_if = "Option::is_none")] - pub read_only_hint: Option, - - /// If true, the tool may perform destructive updates to its environment. - /// If false, the tool performs only additive updates. - /// - /// (This property is meaningful only when `readOnlyHint == false`) - /// - /// Default: true - /// A human-readable description of the tool's purpose. - #[serde(skip_serializing_if = "Option::is_none")] - pub destructive_hint: Option, - - /// If true, calling the tool repeatedly with the same arguments - /// will have no additional effect on the its environment. - /// - /// (This property is meaningful only when `readOnlyHint == false`) - /// - /// Default: false. - #[serde(skip_serializing_if = "Option::is_none")] - pub idempotent_hint: Option, - - /// If true, this tool may interact with an "open world" of external - /// entities. If false, the tool's domain of interaction is closed. - /// For example, the world of a web search tool is open, whereas that - /// of a memory tool is not. - /// - /// Default: true - #[serde(skip_serializing_if = "Option::is_none")] - pub open_world_hint: Option, -} - -impl ToolAnnotations { - pub fn new() -> Self { - Self::default() - } - - /// Create a new ToolAnnotations with all fields specified - pub fn from_raw( - title: Option, - read_only_hint: Option, - destructive_hint: Option, - idempotent_hint: Option, - open_world_hint: Option, - ) -> Self { - ToolAnnotations { - title, - read_only_hint, - destructive_hint, - idempotent_hint, - open_world_hint, - } - } - - pub fn with_title(title: T) -> Self - where - T: Into, - { - ToolAnnotations { - title: Some(title.into()), - ..Self::default() - } - } - pub fn read_only(self, read_only: bool) -> Self { - ToolAnnotations { - read_only_hint: Some(read_only), - ..self - } - } - pub fn destructive(self, destructive: bool) -> Self { - ToolAnnotations { - destructive_hint: Some(destructive), - ..self - } - } - pub fn idempotent(self, idempotent: bool) -> Self { - ToolAnnotations { - idempotent_hint: Some(idempotent), - ..self - } - } - pub fn open_world(self, open_world: bool) -> Self { - ToolAnnotations { - open_world_hint: Some(open_world), - ..self - } - } - - /// If not set, defaults to true. - pub fn is_destructive(&self) -> bool { - self.destructive_hint.unwrap_or(true) - } - - /// If not set, defaults to false. - pub fn is_idempotent(&self) -> bool { - self.idempotent_hint.unwrap_or(false) - } -} - -impl Tool { - /// Create a new tool with the given name and description - pub fn new(name: N, description: D, input_schema: S) -> Self - where - N: Into>, - D: Into>, - S: Into>, - { - Tool { - name: name.into(), - title: None, - description: Some(description.into()), - input_schema: input_schema.into(), - output_schema: None, - annotations: None, - icons: None, - meta: None, - } - } - - /// Create a new tool with just a name and input schema (no description) - pub fn new_with_raw( - name: N, - description: Option>, - input_schema: S, - ) -> Self - where - N: Into>, - S: Into>, - { - Tool { - name: name.into(), - title: None, - description, - input_schema: input_schema.into(), - output_schema: None, - annotations: None, - icons: None, - meta: None, - } - } - - /// Set the human-readable title - pub fn with_title(mut self, title: impl Into) -> Self { - self.title = Some(title.into()); - self - } - - /// Set the output schema from a raw value - pub fn with_raw_output_schema(mut self, output_schema: Arc) -> Self { - self.output_schema = Some(output_schema); - self - } - - /// Set the annotations - pub fn with_annotations(mut self, annotations: ToolAnnotations) -> Self { - self.annotations = Some(annotations); - self - } - - /// Set the icons - pub fn with_icons(mut self, icons: Vec) -> Self { - self.icons = Some(icons); - self - } - - /// Set the metadata - pub fn with_meta(mut self, meta: MetaObject) -> Self { - self.meta = Some(meta); - self - } - - pub fn annotate(self, annotations: ToolAnnotations) -> Self { - Tool { - annotations: Some(annotations), - ..self - } - } - - /// Set the output schema using a type that implements JsonSchema - #[cfg(feature = "server")] - pub fn with_output_schema(mut self) -> Self { - self.output_schema = Some(crate::handler::server::tool::schema_for_output::()); - self - } - - /// Set the input schema using a type that implements JsonSchema - #[cfg(feature = "server")] - pub fn with_input_schema(mut self) -> Self { - self.input_schema = crate::handler::server::tool::schema_for_input::() - .unwrap_or_else(|e| panic!("Invalid input schema for tool '{}': {}", self.name, e)); - self - } - - /// Get the schema as json value - pub fn schema_as_json_value(&self) -> Value { - Value::Object(self.input_schema.as_ref().clone()) - } -} diff --git a/vendor/rmcp/src/service.rs b/vendor/rmcp/src/service.rs deleted file mode 100644 index 20fd2e981..000000000 --- a/vendor/rmcp/src/service.rs +++ /dev/null @@ -1,1889 +0,0 @@ -use std::{borrow::Cow, sync::OnceLock}; - -use futures::FutureExt; -#[cfg(not(feature = "local"))] -use futures::future::BoxFuture; -#[cfg(feature = "local")] -use futures::future::LocalBoxFuture; -use thiserror::Error; - -// --------------------------------------------------------------------------- -// Conditional Send helpers -// -// `MaybeSend` – supertrait alias: `Send + Sync` without `local`, empty with `local` -// `MaybeSendFuture` – future bound alias: `Send` without `local`, empty with `local` -// `MaybeBoxFuture` – boxed future type: `BoxFuture` without `local`, `LocalBoxFuture` with `local` -// --------------------------------------------------------------------------- - -#[cfg(not(feature = "local"))] -#[doc(hidden)] -pub trait MaybeSend: Send + Sync {} -#[cfg(not(feature = "local"))] -impl MaybeSend for T {} - -#[cfg(feature = "local")] -#[doc(hidden)] -pub trait MaybeSend {} -#[cfg(feature = "local")] -impl MaybeSend for T {} - -#[cfg(not(feature = "local"))] -#[doc(hidden)] -pub trait MaybeSendFuture: Send {} -#[cfg(not(feature = "local"))] -impl MaybeSendFuture for T {} - -#[cfg(feature = "local")] -#[doc(hidden)] -pub trait MaybeSendFuture {} -#[cfg(feature = "local")] -impl MaybeSendFuture for T {} - -#[cfg(not(feature = "local"))] -pub(crate) type MaybeBoxFuture<'a, T> = BoxFuture<'a, T>; -#[cfg(feature = "local")] -pub(crate) type MaybeBoxFuture<'a, T> = LocalBoxFuture<'a, T>; - -#[cfg(feature = "server")] -use crate::model::ClientNotification; -#[cfg(feature = "server")] -use crate::model::ServerJsonRpcMessage; -#[cfg(feature = "client")] -use crate::model::ServerNotification; -use crate::{ - error::ErrorData as McpError, - model::{ - CancelledNotification, CancelledNotificationParam, ClientCapabilities, Extensions, - GetExtensions, GetMeta, Implementation, JsonRpcError, JsonRpcMessage, JsonRpcNotification, - JsonRpcRequest, JsonRpcResponse, NotificationMetaObject, NumberOrString, ProgressToken, - ProtocolVersion, RequestId, RequestMetaObject, - }, - transport::{DynamicTransportError, IntoTransport, Transport}, -}; -#[cfg(feature = "client")] -mod client; -#[cfg(feature = "client")] -pub use client::*; -#[cfg(feature = "server")] -mod server; -#[cfg(feature = "server")] -pub use server::*; -#[cfg(feature = "tower")] -mod tower; -use tokio_util::sync::{CancellationToken, DropGuard}; -#[cfg(feature = "tower")] -pub use tower::*; -use tracing::{Instrument as _, instrument}; -#[derive(Error, Debug)] -#[non_exhaustive] -pub enum ServiceError { - #[error("Mcp error: {0}")] - McpError(McpError), - #[error("Transport send error: {0}")] - TransportSend(DynamicTransportError), - #[error("Transport closed")] - TransportClosed, - #[error("Unexpected response type")] - UnexpectedResponse, - #[error("subscription consumer lagged behind its {capacity}-message buffer")] - SubscriptionLagged { capacity: usize }, - #[error("task cancelled for reason {}", reason.as_deref().unwrap_or(""))] - Cancelled { reason: Option }, - #[error("request timeout after {}", chrono::Duration::from_std(*timeout).unwrap_or_default())] - Timeout { timeout: Duration }, - /// The peer kept returning `input_required` beyond the configured round cap. - #[error("input_required did not complete within {max_rounds} MRTR rounds")] - InputRequiredRoundsExceeded { max_rounds: usize }, -} - -trait TransferObject: - std::fmt::Debug + Clone + serde::Serialize + serde::de::DeserializeOwned + Send + Sync + 'static -{ -} - -impl TransferObject for T where - T: std::fmt::Debug - + serde::Serialize - + serde::de::DeserializeOwned - + Send - + Sync - + 'static - + Clone -{ -} - -#[allow(private_bounds, reason = "there's no the third implementation")] -pub trait ServiceRole: std::fmt::Debug + Send + Sync + 'static + Copy + Clone { - type Req: TransferObject + GetMeta + GetExtensions; - type Resp: TransferObject; - type Not: TryInto - + From - + TransferObject; - type PeerReq: TransferObject + GetMeta + GetExtensions; - type PeerResp: TransferObject; - type PeerNot: TryInto - + From - + TransferObject - + GetMeta - + GetExtensions; - type InitializeError; - const IS_CLIENT: bool; - type Info: TransferObject; - type PeerInfo: TransferObject; - #[doc(hidden)] - fn configure_direct_peer(_peer: &Peer, _info: &Self::Info) {} - #[doc(hidden)] - fn peer_cancelled_params(_notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> { - None - } - /// Invalidate any response cache affected by an inbound peer notification. - /// - /// The serve loop calls this for every notification *before* subscription - /// routing, so cache invalidation still runs when a notification is - /// delivered through a `listen` subscription channel rather than the - /// [`Service::handle_notification`] callbacks. - #[doc(hidden)] - fn invalidate_response_cache( - _peer: &Peer, - _notification: &Self::PeerNot, - ) -> impl Future + MaybeSendFuture { - async {} - } - - #[doc(hidden)] - fn enforce_request_association( - _request: &Self::Req, - _peer_info: Option<&Self::PeerInfo>, - _in_request_handler_scope: bool, - ) -> Result<(), ServiceError> { - Ok(()) - } - - /// Receive-side counterpart of [`Self::enforce_request_association`]: - /// SEP-2260 says clients receiving a server-to-client request with no - /// associated outbound request should reject it with invalid params. An - /// error return is sent back to the peer instead of dispatching to the - /// handler. - #[doc(hidden)] - fn enforce_peer_request_association( - _peer_request: &Self::PeerReq, - _peer_info: Option<&Self::PeerInfo>, - _association: PeerRequestAssociation, - ) -> Result<(), McpError> { - Ok(()) - } -} - -/// How an inbound peer request relates to this side's in-flight outbound -/// requests (SEP-2260). -/// -/// SEP-2260 defines no wire field for association, so only stream-separating -/// transports (streamable HTTP) can observe it; other transports yield -/// [`Self::Unknown`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] -pub enum PeerRequestAssociation { - /// Arrived on the response stream of an in-flight outbound request. - Associated, - /// Arrived on a stream tied to no in-flight outbound request (e.g. the - /// streamable HTTP standalone GET stream). - Unassociated, - /// The transport cannot distinguish streams; only the coarse in-flight - /// signal is available. - Unknown { has_pending_outbound_request: bool }, -} - -pub(crate) fn uses_legacy_lifecycle( - protocol_version: Option<&ProtocolVersion>, - uses_discover_lifecycle: bool, -) -> bool { - !uses_discover_lifecycle - && protocol_version.is_none_or(|version| version < &ProtocolVersion::V_2026_07_28) -} - -pub(crate) fn peer_request_association( - request: &Req, - local_responder_pool: &std::collections::HashMap, -) -> PeerRequestAssociation { - match request.extensions().get::() { - None => PeerRequestAssociation::Unknown { - has_pending_outbound_request: !local_responder_pool.is_empty(), - }, - Some(InboundStreamOrigin::Unassociated) => PeerRequestAssociation::Unassociated, - Some(InboundStreamOrigin::OutboundRequest(id)) => { - if local_responder_pool.contains_key(id) { - PeerRequestAssociation::Associated - } else { - PeerRequestAssociation::Unassociated - } - } - } -} - -tokio::task_local! { - pub(crate) static ORIGINATING_REQUEST: RequestId; -} - -pub(crate) fn in_request_handler_scope() -> bool { - ORIGINATING_REQUEST.try_with(|_| ()).is_ok() -} - -/// Marker in an outbound request's non-serialized [`Extensions`] identifying -/// the in-flight peer request it was issued from (SEP-2260). Attached for both -/// roles whenever a request is sent from within a request handler; the -/// streamable HTTP server reads it to deliver server-initiated requests on the -/// originating request's SSE stream. Never on the wire (SEP-2260 defines no -/// wire field), so session managers that serialize messages between processes -/// lose it and such requests fall back to the standalone stream with a warning. -/// -/// # Caller requirements -/// -/// From protocol version `2026-07-28`, server-to-client sampling, roots, and -/// elicitation requests must be issued while handling a client request; -/// outside a handler they return an `invalid_request` error. The association -/// is task-local and does not cross `tokio::spawn`, so use the task manager -/// for long-running work. -/// -/// The client receive-side mirror is [`InboundStreamOrigin`]. -#[derive(Debug, Clone, PartialEq, Eq)] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct OriginatingRequestId(pub RequestId); - -/// Marker in an inbound request's non-serialized [`Extensions`] recording -/// which HTTP response stream it arrived on: the receive-side mirror of -/// [`OriginatingRequestId`]. Never on the wire (SEP-2260 defines no wire -/// field); when absent, the coarse in-flight check applies. -#[derive(Debug, Clone, PartialEq, Eq)] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] -pub enum InboundStreamOrigin { - /// The standalone GET stream, or a POST response stream not tied to an - /// outbound request. - Unassociated, - /// The SSE response stream of the POST that carried this outbound request. - OutboundRequest(RequestId), -} - -pub type TxJsonRpcMessage = - JsonRpcMessage<::Req, ::Resp, ::Not>; -pub type RxJsonRpcMessage = JsonRpcMessage< - ::PeerReq, - ::PeerResp, - ::PeerNot, ->; - -#[cfg(not(feature = "local"))] -pub trait Service: Send + Sync + 'static { - fn handle_request( - &self, - request: R::PeerReq, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_; - fn handle_notification( - &self, - notification: R::PeerNot, - context: NotificationContext, - ) -> impl Future> + MaybeSendFuture + '_; - fn get_info(&self) -> R::Info; - /// The protocol versions this service can speak, bounding what `initialize` - /// negotiation may agree to. - /// - /// Servers normally override - /// [`ServerHandler::supported_protocol_versions`] instead of this method; - /// the blanket `Service` impl forwards to it. This method exists so the - /// transport and handshake layers, which see only a `Service`, can read the - /// list and avoid agreeing to a version the server cannot serve. - /// - /// [`ServerHandler::supported_protocol_versions`]: crate::handler::server::ServerHandler::supported_protocol_versions - fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { - Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS) - } -} - -#[cfg(feature = "local")] -pub trait Service: 'static { - fn handle_request( - &self, - request: R::PeerReq, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_; - fn handle_notification( - &self, - notification: R::PeerNot, - context: NotificationContext, - ) -> impl Future> + MaybeSendFuture + '_; - fn get_info(&self) -> R::Info; - /// The protocol versions this service can speak. - /// - /// See the non-`local` variant of this trait for details. - fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { - Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS) - } -} - -pub trait ServiceExt: Service + Sized { - /// Convert this service to a dynamic boxed service - /// - /// This could be very helpful when you want to store the services in a collection - fn into_dyn(self) -> Box> { - Box::new(self) - } - fn serve( - self, - transport: T, - ) -> impl Future, R::InitializeError>> + MaybeSendFuture - where - T: IntoTransport, - E: std::error::Error + Send + Sync + 'static, - Self: Sized, - { - Self::serve_with_ct(self, transport, Default::default()) - } - fn serve_with_ct( - self, - transport: T, - ct: CancellationToken, - ) -> impl Future, R::InitializeError>> + MaybeSendFuture - where - T: IntoTransport, - E: std::error::Error + Send + Sync + 'static, - Self: Sized; -} - -impl Service for Box> { - fn handle_request( - &self, - request: R::PeerReq, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - DynService::handle_request(self.as_ref(), request, context) - } - - fn handle_notification( - &self, - notification: R::PeerNot, - context: NotificationContext, - ) -> impl Future> + MaybeSendFuture + '_ { - DynService::handle_notification(self.as_ref(), notification, context) - } - - fn get_info(&self) -> R::Info { - DynService::get_info(self.as_ref()) - } - - fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { - DynService::supported_protocol_versions(self.as_ref()) - } -} - -#[cfg(not(feature = "local"))] -pub trait DynService: Send + Sync { - fn handle_request( - &self, - request: R::PeerReq, - context: RequestContext, - ) -> MaybeBoxFuture<'_, Result>; - fn handle_notification( - &self, - notification: R::PeerNot, - context: NotificationContext, - ) -> MaybeBoxFuture<'_, Result<(), McpError>>; - fn get_info(&self) -> R::Info; - /// See [`Service::supported_protocol_versions`]. - fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { - Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS) - } -} - -#[cfg(feature = "local")] -pub trait DynService { - fn handle_request( - &self, - request: R::PeerReq, - context: RequestContext, - ) -> MaybeBoxFuture<'_, Result>; - fn handle_notification( - &self, - notification: R::PeerNot, - context: NotificationContext, - ) -> MaybeBoxFuture<'_, Result<(), McpError>>; - fn get_info(&self) -> R::Info; - /// See [`Service::supported_protocol_versions`]. - fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { - Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS) - } -} - -impl> DynService for S { - fn handle_request( - &self, - request: R::PeerReq, - context: RequestContext, - ) -> MaybeBoxFuture<'_, Result> { - Box::pin(self.handle_request(request, context)) - } - fn handle_notification( - &self, - notification: R::PeerNot, - context: NotificationContext, - ) -> MaybeBoxFuture<'_, Result<(), McpError>> { - Box::pin(self.handle_notification(notification, context)) - } - fn get_info(&self) -> R::Info { - self.get_info() - } - fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { - Service::supported_protocol_versions(self) - } -} - -use std::{ - collections::{HashMap, VecDeque}, - ops::Deref, - sync::{Arc, atomic::AtomicU64}, - time::Duration, -}; - -use tokio::sync::mpsc; - -pub trait RequestIdProvider: Send + Sync + 'static { - fn next_request_id(&self) -> RequestId; -} - -pub trait ProgressTokenProvider: Send + Sync + 'static { - fn next_progress_token(&self) -> ProgressToken; -} - -pub type AtomicU32RequestIdProvider = AtomicU32Provider; -pub type AtomicU32ProgressTokenProvider = AtomicU32Provider; - -pub(crate) fn remove_pending_request( - pending_requests: &mut HashMap, - response_id: &RequestId, -) -> Option { - pending_requests.remove(response_id).or_else(|| { - response_id - .numeric_string_value() - .and_then(|id| pending_requests.remove(&RequestId::Number(id))) - }) -} - -#[derive(Debug, Default)] -pub struct AtomicU32Provider { - id: AtomicU64, -} - -impl RequestIdProvider for AtomicU32Provider { - fn next_request_id(&self) -> RequestId { - let id = self.id.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - // Safe conversion: we start at 0 and increment by 1, so we won't overflow i64::MAX in practice - RequestId::Number(id as i64) - } -} - -impl ProgressTokenProvider for AtomicU32Provider { - fn next_progress_token(&self) -> ProgressToken { - let id = self.id.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - ProgressToken(NumberOrString::Number(id as i64)) - } -} - -#[doc(hidden)] -pub trait ProgressNotificationToken { - fn progress_token(&self) -> Option<&ProgressToken>; -} - -#[cfg(feature = "server")] -impl ProgressNotificationToken for ClientNotification { - fn progress_token(&self) -> Option<&ProgressToken> { - match self { - ClientNotification::ProgressNotification(notification) => { - Some(¬ification.params.progress_token) - } - _ => None, - } - } -} - -#[cfg(feature = "client")] -impl ProgressNotificationToken for ServerNotification { - fn progress_token(&self) -> Option<&ProgressToken> { - match self { - ServerNotification::ProgressNotification(notification) => { - Some(¬ification.params.progress_token) - } - _ => None, - } - } -} - -type Responder = tokio::sync::oneshot::Sender; -type ProgressTimeoutWatchers = Arc>>>; -type SubscriptionChannel = (mpsc::Sender, usize); -type SubscriptionChannelMap = HashMap>; - -/// A handle to a remote request -/// -/// You can cancel it by call [`RequestHandle::cancel`] with a reason, -/// -/// or wait for response by call [`RequestHandle::await_response`] -#[derive(Debug)] -#[non_exhaustive] -pub struct RequestHandle { - pub rx: tokio::sync::oneshot::Receiver>, - pub options: PeerRequestOptions, - pub peer: Peer, - pub id: RequestId, - pub progress_token: ProgressToken, - progress_reset_rx: Option>, -} - -impl RequestHandle { - pub const REQUEST_TIMEOUT_REASON: &str = "request timeout"; - pub const REQUEST_MAX_TOTAL_TIMEOUT_REASON: &str = "maximum total timeout exceeded"; - - pub async fn await_response(mut self) -> Result { - let timeout = self.options.timeout; - let max_total_timeout = self.options.max_total_timeout; - let reset_timeout_on_progress = self.options.reset_timeout_on_progress; - - let has_progress_reset_rx = self.progress_reset_rx.is_some(); - let progress_token = self.progress_token.clone(); - - let result = match (timeout, max_total_timeout, reset_timeout_on_progress) { - (Some(timeout), None, false) => match tokio::time::timeout(timeout, &mut self.rx).await - { - Ok(response) => response.map_err(|_e| ServiceError::TransportClosed)?, - Err(_) => { - let error = Err(ServiceError::Timeout { timeout }); - // cancel this request - self.send_timeout_cancel_notification(Self::REQUEST_TIMEOUT_REASON) - .await; - error - } - }, - (None, None, _) => (&mut self.rx) - .await - .map_err(|_e| ServiceError::TransportClosed)?, - _ => { - self.await_response_with_progress_timeout( - timeout, - max_total_timeout, - reset_timeout_on_progress, - ) - .await - } - }; - - Self::cleanup_progress_timeout_watcher( - &self.peer.progress_timeout_watchers, - &progress_token, - has_progress_reset_rx, - ) - .await; - self.peer.unregister_subscription(&self.id); - result - } - - async fn send_timeout_cancel_notification(&self, reason: &str) { - self.peer.unregister_subscription(&self.id); - let notification = CancelledNotification { - params: CancelledNotificationParam { - request_id: Some(self.id.clone()), - reason: Some(reason.to_owned()), - meta: None, - }, - method: crate::model::CancelledNotificationMethod, - extensions: Default::default(), - }; - let _ = self.peer.send_notification(notification.into()).await; - } - - async fn await_response_with_progress_timeout( - &mut self, - timeout: Option, - max_total_timeout: Option, - reset_timeout_on_progress: bool, - ) -> Result { - let mut idle_sleep = - timeout.map(|timeout| (timeout, Box::pin(tokio::time::sleep(timeout)))); - let mut max_total_sleep = - max_total_timeout.map(|timeout| (timeout, Box::pin(tokio::time::sleep(timeout)))); - - loop { - tokio::select! { - biased; - - response = &mut self.rx => { - return response.map_err(|_e| ServiceError::TransportClosed)?; - } - _ = async { - if let Some((_, sleep)) = idle_sleep.as_mut() { - sleep.as_mut().await; - } - }, if idle_sleep.is_some() => { - if let Some((timeout, _)) = idle_sleep.as_ref() { - self.send_timeout_cancel_notification(Self::REQUEST_TIMEOUT_REASON).await; - return Err(ServiceError::Timeout { timeout: *timeout }); - } - } - _ = async { - if let Some((_, sleep)) = max_total_sleep.as_mut() { - sleep.as_mut().await; - } - }, if max_total_sleep.is_some() => { - if let Some((timeout, _)) = max_total_sleep.as_ref() { - self.send_timeout_cancel_notification(Self::REQUEST_MAX_TOTAL_TIMEOUT_REASON).await; - return Err(ServiceError::Timeout { timeout: *timeout }); - } - } - progress = async { - match self.progress_reset_rx.as_mut() { - Some(rx) => rx.recv().await, - None => None, - } - }, if reset_timeout_on_progress && idle_sleep.is_some() && self.progress_reset_rx.is_some() => { - if progress.is_some() - && let Some((timeout, sleep)) = idle_sleep.as_mut() { - sleep.as_mut().reset(tokio::time::Instant::now() + *timeout); - } - } - } - } - } - - /// Cancel this request - pub async fn cancel(self, reason: Option) -> Result<(), ServiceError> { - Self::cleanup_progress_timeout_watcher( - &self.peer.progress_timeout_watchers, - &self.progress_token, - self.progress_reset_rx.is_some(), - ) - .await; - self.peer.unregister_subscription(&self.id); - let notification = CancelledNotification { - params: CancelledNotificationParam { - request_id: Some(self.id), - reason, - meta: None, - }, - method: crate::model::CancelledNotificationMethod, - extensions: Default::default(), - }; - self.peer.send_notification(notification.into()).await?; - Ok(()) - } - - async fn cleanup_progress_timeout_watcher( - progress_timeout_watchers: &ProgressTimeoutWatchers, - progress_token: &ProgressToken, - has_progress_reset_rx: bool, - ) { - if has_progress_reset_rx { - progress_timeout_watchers - .write() - .await - .remove(progress_token); - } - } -} - -#[derive(Debug)] -pub(crate) enum PeerSinkMessage { - Request { - request: R::Req, - id: RequestId, - responder: Responder>, - }, - Notification { - notification: R::Not, - responder: Responder>, - }, -} - -#[derive(Debug, Clone)] -pub(crate) struct ClientRequestMetadata { - pub protocol_version: ProtocolVersion, - pub client_info: Implementation, - pub client_capabilities: ClientCapabilities, -} - -/// An interface to fetch the remote client or server -/// -/// For general purpose, call [`Peer::send_request`] or [`Peer::send_notification`] to send message to remote peer. -/// -/// To create a cancellable request, call [`Peer::send_request_with_option`]. -pub struct Peer { - tx: mpsc::Sender>, - request_id_provider: Arc, - progress_token_provider: Arc, - progress_timeout_watchers: ProgressTimeoutWatchers, - info: Arc>>>, - client_request_metadata: Arc>, - request_metadata_required: Arc, - subscription_channels: Arc>>, - #[cfg(feature = "client")] - response_cache: client::cache::PeerResponseCache, -} - -impl Clone for Peer -where - R::PeerInfo: Clone, -{ - fn clone(&self) -> Peer { - Self { - tx: self.tx.clone(), - request_id_provider: self.request_id_provider.clone(), - progress_token_provider: self.progress_token_provider.clone(), - progress_timeout_watchers: self.progress_timeout_watchers.clone(), - info: self.info.clone(), - client_request_metadata: self.client_request_metadata.clone(), - request_metadata_required: self.request_metadata_required.clone(), - subscription_channels: self.subscription_channels.clone(), - #[cfg(feature = "client")] - response_cache: self.response_cache.clone(), - } - } -} - -impl std::fmt::Debug for Peer { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("PeerSink") - .field("tx", &self.tx) - .field("is_client", &R::IS_CLIENT) - .finish() - } -} - -type ProxyOutbound = mpsc::Receiver>; - -#[derive(Debug, Default)] -#[non_exhaustive] -pub struct PeerRequestOptions { - pub timeout: Option, - pub meta: Option, - /// Reset the request timeout when a matching progress notification is received. - pub reset_timeout_on_progress: bool, - /// Maximum total time to wait for the request, regardless of progress notifications. - pub max_total_timeout: Option, -} - -impl PeerRequestOptions { - pub fn no_options() -> Self { - Self::default() - } - - pub fn with_timeout(timeout: Duration) -> Self { - Self { - timeout: Some(timeout), - ..Self::default() - } - } - - /// Adds request metadata while preserving any other configured options. - /// - /// Explicit values take precedence over discover-lifecycle metadata defaults. - pub fn with_meta(mut self, meta: RequestMetaObject) -> Self { - self.meta = Some(meta); - self - } - - pub fn reset_timeout_on_progress(mut self) -> Self { - self.reset_timeout_on_progress = true; - self - } - - pub fn with_max_total_timeout(mut self, timeout: Duration) -> Self { - self.max_total_timeout = Some(timeout); - self - } -} - -impl Peer { - const CLIENT_CHANNEL_BUFFER_SIZE: usize = 1024; - pub(crate) fn new( - request_id_provider: Arc, - peer_info: Option, - ) -> (Peer, ProxyOutbound) { - let (tx, rx) = mpsc::channel(Self::CLIENT_CHANNEL_BUFFER_SIZE); - ( - Self { - tx, - request_id_provider, - progress_token_provider: Arc::new(AtomicU32ProgressTokenProvider::default()), - progress_timeout_watchers: Default::default(), - info: Arc::new(std::sync::RwLock::new(peer_info.map(Arc::new))), - client_request_metadata: Default::default(), - request_metadata_required: Default::default(), - subscription_channels: Default::default(), - #[cfg(feature = "client")] - response_cache: Default::default(), - }, - rx, - ) - } - pub async fn send_notification(&self, notification: R::Not) -> Result<(), ServiceError> { - let (responder, receiver) = tokio::sync::oneshot::channel(); - self.tx - .send(PeerSinkMessage::Notification { - notification, - responder, - }) - .await - .map_err(|_m| ServiceError::TransportClosed)?; - receiver.await.map_err(|_e| ServiceError::TransportClosed)? - } - pub async fn send_request(&self, request: R::Req) -> Result { - self.send_request_with_option(request, PeerRequestOptions::no_options()) - .await? - .await_response() - .await - } - - pub async fn send_cancellable_request( - &self, - request: R::Req, - options: PeerRequestOptions, - ) -> Result, ServiceError> { - self.send_request_with_option(request, options).await - } - - pub async fn send_request_with_option( - &self, - request: R::Req, - options: PeerRequestOptions, - ) -> Result, ServiceError> { - self.send_request_with_option_and_subscription(request, options, None) - .await - } - - async fn send_request_with_option_and_subscription( - &self, - mut request: R::Req, - options: PeerRequestOptions, - subscription_sender: Option>, - ) -> Result, ServiceError> { - R::enforce_request_association( - &request, - self.peer_info().as_deref(), - in_request_handler_scope(), - )?; - if let Ok(originating) = ORIGINATING_REQUEST.try_with(|id| id.clone()) { - request - .extensions_mut() - .insert(OriginatingRequestId(originating)); - } - let id = self.request_id_provider.next_request_id(); - let progress_token = self.progress_token_provider.next_progress_token(); - if let Some(metadata) = self.client_request_metadata.get() { - let meta = request.get_meta_mut(); - meta.set_protocol_version(metadata.protocol_version.clone()); - meta.set_client_info(metadata.client_info.clone()); - meta.set_client_capabilities(metadata.client_capabilities.clone()); - } - if let Some(meta) = options.meta.clone() { - request.get_meta_mut().extend(meta); - } - request - .get_meta_mut() - .set_progress_token(progress_token.clone()); - let (responder, receiver) = tokio::sync::oneshot::channel(); - let progress_reset_rx = if options.reset_timeout_on_progress && options.timeout.is_some() { - let (sender, receiver) = mpsc::channel(1); - self.progress_timeout_watchers - .write() - .await - .insert(progress_token.clone(), sender); - Some(receiver) - } else { - None - }; - if let Some(channel) = subscription_sender { - self.subscription_channels_write() - .insert(id.clone(), channel); - } - if self - .tx - .send(PeerSinkMessage::Request { - request, - id: id.clone(), - responder, - }) - .await - .is_err() - { - if progress_reset_rx.is_some() { - self.progress_timeout_watchers - .write() - .await - .remove(&progress_token); - } - self.unregister_subscription(&id); - return Err(ServiceError::TransportClosed); - } - Ok(RequestHandle { - id, - rx: receiver, - progress_token, - options, - peer: self.clone(), - progress_reset_rx, - }) - } - - #[cfg(feature = "client")] - pub(crate) async fn send_subscription_request( - &self, - request: R::Req, - options: PeerRequestOptions, - channel_capacity: usize, - ) -> Result<(RequestHandle, mpsc::Receiver), ServiceError> { - let (sender, receiver) = mpsc::channel(channel_capacity); - let handle = self - .send_request_with_option_and_subscription( - request, - options, - Some((sender, channel_capacity)), - ) - .await?; - Ok((handle, receiver)) - } - - fn subscription_sender(&self, id: &RequestId) -> Option> { - self.subscription_channels_read().get(id).cloned() - } - - pub(crate) fn unregister_subscription(&self, id: &RequestId) { - self.subscription_channels_write().remove(id); - } - - fn subscription_channels_read( - &self, - ) -> std::sync::RwLockReadGuard<'_, SubscriptionChannelMap> { - match self.subscription_channels.read() { - Ok(channels) => channels, - Err(poisoned) => poisoned.into_inner(), - } - } - - fn subscription_channels_write( - &self, - ) -> std::sync::RwLockWriteGuard<'_, SubscriptionChannelMap> { - match self.subscription_channels.write() { - Ok(channels) => channels, - Err(poisoned) => poisoned.into_inner(), - } - } - - pub(crate) fn try_cancel_request(&self, id: RequestId, reason: Option) { - let notification = CancelledNotification { - params: CancelledNotificationParam { - request_id: Some(id), - reason, - meta: None, - }, - method: crate::model::CancelledNotificationMethod, - extensions: Default::default(), - }; - let (responder, _receiver) = tokio::sync::oneshot::channel(); - let _ = self.tx.try_send(PeerSinkMessage::Notification { - notification: notification.into(), - responder, - }); - } - - async fn notify_progress_timeout_watcher(&self, progress_token: &ProgressToken) { - let sender = self - .progress_timeout_watchers - .read() - .await - .get(progress_token) - .cloned(); - if let Some(sender) = sender { - match sender.try_send(()) { - Ok(()) => {} - Err(mpsc::error::TrySendError::Full(_)) => { - tracing::trace!(?progress_token, "progress timeout watcher channel is full"); - } - Err(mpsc::error::TrySendError::Closed(_)) => { - self.progress_timeout_watchers - .write() - .await - .remove(progress_token); - } - } - } - } - - /// Snapshot of the peer's handshake info. - pub fn peer_info(&self) -> Option> { - self.info.read().expect("peer info lock poisoned").clone() - } - - /// Stores the peer's handshake info, overwriting any previous value. - pub fn set_peer_info(&self, info: R::PeerInfo) { - *self.info.write().expect("peer info lock poisoned") = Some(Arc::new(info)); - } - - pub(crate) fn set_client_request_metadata(&self, metadata: ClientRequestMetadata) { - let result = self.client_request_metadata.set(metadata); - debug_assert!(result.is_ok(), "client request metadata set more than once"); - } - - pub(crate) fn require_request_metadata(&self) { - self.request_metadata_required - .store(true, std::sync::atomic::Ordering::Release); - } - - pub(crate) fn request_metadata_required(&self) -> bool { - self.request_metadata_required - .load(std::sync::atomic::Ordering::Acquire) - } - - pub fn is_transport_closed(&self) -> bool { - self.tx.is_closed() - } -} - -#[derive(Debug)] -pub struct RunningService> { - service: Arc, - peer: Peer, - handle: Option>, - cancellation_token: CancellationToken, - dg: DropGuard, -} -impl> Deref for RunningService { - type Target = Peer; - - fn deref(&self) -> &Self::Target { - &self.peer - } -} - -impl> RunningService { - #[inline] - pub fn peer(&self) -> &Peer { - &self.peer - } - #[inline] - pub fn service(&self) -> &S { - self.service.as_ref() - } - #[inline] - pub fn cancellation_token(&self) -> RunningServiceCancellationToken { - RunningServiceCancellationToken(self.cancellation_token.clone()) - } - - /// Returns true if the service has been closed or cancelled. - #[inline] - pub fn is_closed(&self) -> bool { - self.handle.is_none() || self.cancellation_token.is_cancelled() - } - - /// Wait for the service to complete. - /// - /// This will block until the service loop terminates (either due to - /// cancellation, transport closure, or an error). - #[inline] - pub async fn waiting(mut self) -> Result { - match self.handle.take() { - Some(handle) => handle.await, - None => Ok(QuitReason::Closed), - } - } - - /// Gracefully close the connection and wait for cleanup to complete. - /// - /// This method cancels the service, waits for the background task to finish - /// (which includes calling `transport.close()`), and ensures all cleanup - /// operations complete before returning. - /// - /// Unlike [`cancel`](Self::cancel), this method takes `&mut self` and can be - /// called without consuming the `RunningService`. After calling this method, - /// the service is considered closed and subsequent operations will fail. - /// - /// # Example - /// - /// ```rust,ignore - /// let mut client = ().serve(transport).await?; - /// // ... use the client ... - /// client.close().await?; - /// ``` - pub async fn close(&mut self) -> Result { - if let Some(handle) = self.handle.take() { - // Disarm the drop guard so it doesn't try to cancel again - // We need to cancel manually and wait for completion - self.cancellation_token.cancel(); - handle.await - } else { - // Already closed - Ok(QuitReason::Closed) - } - } - - /// Gracefully close the connection with a timeout. - /// - /// Similar to [`close`](Self::close), but returns after the specified timeout - /// if the cleanup doesn't complete in time. This is useful for ensuring - /// a bounded shutdown time. - /// - /// Returns `Ok(Some(reason))` if shutdown completed within the timeout, - /// `Ok(None)` if the timeout was reached, or `Err` if there was a join error. - pub async fn close_with_timeout( - &mut self, - timeout: Duration, - ) -> Result, tokio::task::JoinError> { - if let Some(handle) = self.handle.take() { - self.cancellation_token.cancel(); - match tokio::time::timeout(timeout, handle).await { - Ok(result) => result.map(Some), - Err(_elapsed) => { - tracing::warn!( - "close_with_timeout: cleanup did not complete within {:?}", - timeout - ); - Ok(None) - } - } - } else { - Ok(Some(QuitReason::Closed)) - } - } - - /// Cancel the service and wait for cleanup to complete. - /// - /// This consumes the `RunningService` and ensures the connection is properly - /// closed. For a non-consuming alternative, see [`close`](Self::close). - pub async fn cancel(mut self) -> Result { - // Disarm the drop guard since we're handling cancellation explicitly - let _ = std::mem::replace(&mut self.dg, self.cancellation_token.clone().drop_guard()); - self.close().await - } -} - -impl> Drop for RunningService { - fn drop(&mut self) { - if self.handle.is_some() && !self.cancellation_token.is_cancelled() { - tracing::debug!( - "RunningService dropped without explicit close(). \ - The connection will be closed asynchronously. \ - For guaranteed cleanup, call close() or cancel() before dropping." - ); - } - // The DropGuard will handle cancellation - } -} - -// use a wrapper type so we can tweak the implementation if needed -pub struct RunningServiceCancellationToken(CancellationToken); - -impl RunningServiceCancellationToken { - pub fn cancel(self) { - self.0.cancel(); - } -} - -#[derive(Debug)] -#[non_exhaustive] -pub enum QuitReason { - Cancelled, - Closed, - JoinError(tokio::task::JoinError), -} - -/// Request execution context -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct RequestContext { - /// this token will be cancelled when the [`CancelledNotification`] is received. - pub ct: CancellationToken, - pub id: RequestId, - pub meta: RequestMetaObject, - pub extensions: Extensions, - /// An interface to fetch the remote client or server - pub peer: Peer, -} - -impl RequestContext { - /// Create a new RequestContext. - pub fn new(id: RequestId, peer: Peer) -> Self { - Self { - ct: CancellationToken::new(), - id, - meta: RequestMetaObject::default(), - extensions: Extensions::default(), - peer, - } - } -} - -#[cfg(feature = "server")] -impl RequestContext { - /// The current request's protocol version, falling back to legacy handshake state. - pub fn protocol_version(&self) -> Option { - self.meta.protocol_version().or_else(|| { - self.peer - .peer_info() - .map(|info| info.protocol_version.clone()) - }) - } - - /// The current request's client implementation, falling back only for legacy sessions. - pub fn client_info(&self) -> Option { - if self.peer.request_metadata_required() { - self.meta.client_info() - } else { - self.meta - .client_info() - .or_else(|| self.peer.peer_info().map(|info| info.client_info.clone())) - } - } - - /// The current request's client capabilities, falling back only for legacy sessions. - pub fn client_capabilities(&self) -> Option { - if self.peer.request_metadata_required() { - self.meta.client_capabilities() - } else { - self.meta - .client_capabilities() - .or_else(|| self.peer.peer_info().map(|info| info.capabilities.clone())) - } - } -} - -/// Request execution context -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct NotificationContext { - pub meta: NotificationMetaObject, - pub extensions: Extensions, - /// An interface to fetch the remote client or server - pub peer: Peer, -} - -/// Use this function to skip initialization process -pub fn serve_directly( - service: S, - transport: T, - peer_info: Option, -) -> RunningService -where - R: ServiceRole, - R::PeerNot: ProgressNotificationToken, - S: Service, - T: IntoTransport, - E: std::error::Error + Send + Sync + 'static, -{ - serve_directly_with_ct(service, transport, peer_info, Default::default()) -} - -/// Use this function to skip initialization process -pub fn serve_directly_with_ct( - service: S, - transport: T, - peer_info: Option, - ct: CancellationToken, -) -> RunningService -where - R: ServiceRole, - R::PeerNot: ProgressNotificationToken, - S: Service, - T: IntoTransport, - E: std::error::Error + Send + Sync + 'static, -{ - let (peer, peer_rx) = Peer::new(Arc::new(AtomicU32RequestIdProvider::default()), peer_info); - R::configure_direct_peer(&peer, &service.get_info()); - serve_inner(service, transport.into_transport(), peer, peer_rx, ct) -} - -/// Spawn a task that may hold `!Send` state when the `local` feature is active. -/// -/// Without the `local` feature this is `tokio::spawn` (requires `Future: Send + 'static`). -/// With `local` it uses `tokio::task::spawn_local` (requires only `Future: 'static`). -#[cfg(not(feature = "local"))] -fn spawn_service_task(future: F) -> tokio::task::JoinHandle -where - F: Future + Send + 'static, - F::Output: Send + 'static, -{ - tokio::spawn(future) -} - -#[cfg(feature = "local")] -fn spawn_service_task(future: F) -> tokio::task::JoinHandle -where - F: Future + 'static, - F::Output: 'static, -{ - tokio::task::spawn_local(future) -} - -#[instrument(skip_all)] -fn serve_inner( - service: S, - transport: T, - peer: Peer, - mut peer_rx: tokio::sync::mpsc::Receiver>, - ct: CancellationToken, -) -> RunningService -where - R: ServiceRole, - R::PeerNot: ProgressNotificationToken, - S: Service, - T: Transport + 'static, -{ - const SINK_PROXY_BUFFER_SIZE: usize = 64; - let (sink_proxy_tx, mut sink_proxy_rx) = - tokio::sync::mpsc::channel::>(SINK_PROXY_BUFFER_SIZE); - let peer_info = peer.peer_info(); - if R::IS_CLIENT { - tracing::info!(?peer_info, "Service initialized as client"); - } else { - tracing::info!(?peer_info, "Service initialized as server"); - } - - let mut local_responder_pool = - HashMap::>>::new(); - let mut local_ct_pool = HashMap::::new(); - let shared_service = Arc::new(service); - // for return - let service = shared_service.clone(); - - // let message_sink = tokio::sync:: - // let mut stream = std::pin::pin!(stream); - let serve_loop_ct = ct.child_token(); - let peer_return: Peer = peer.clone(); - let current_span = tracing::Span::current(); - let handle = spawn_service_task(async move { - let mut transport = transport.into_transport(); - let mut batch_messages = VecDeque::>::new(); - let mut send_task_set = tokio::task::JoinSet::::new(); - let mut response_send_tasks = tokio::task::JoinSet::<()>::new(); - #[derive(Debug)] - enum SendTaskResult { - Request { - id: RequestId, - result: Result<(), DynamicTransportError>, - }, - Notification { - responder: Responder>, - cancellation_param: Option, - result: Result<(), DynamicTransportError>, - }, - } - #[derive(Debug)] - enum Event { - ProxyMessage(PeerSinkMessage), - PeerMessage(RxJsonRpcMessage), - ToSink(TxJsonRpcMessage), - SendTaskResult(SendTaskResult), - ResponseSendTaskResult(Result<(), tokio::task::JoinError>), - } - - let quit_reason = loop { - let evt = if let Some(m) = batch_messages.pop_front() { - Event::PeerMessage(m) - } else { - tokio::select! { - m = sink_proxy_rx.recv(), if !sink_proxy_rx.is_closed() => { - if let Some(m) = m { - Event::ToSink(m) - } else { - continue - } - } - m = transport.receive() => { - if let Some(m) = m { - Event::PeerMessage(m) - } else { - // input stream closed - tracing::info!("input stream terminated"); - break QuitReason::Closed - } - } - m = peer_rx.recv(), if !peer_rx.is_closed() => { - if let Some(m) = m { - Event::ProxyMessage(m) - } else { - continue - } - } - m = send_task_set.join_next(), if !send_task_set.is_empty() => { - let Some(result) = m else { - continue - }; - match result { - Err(e) => { - // join error, which is serious, we should quit. - tracing::error!(%e, "send request task encounter a tokio join error"); - break QuitReason::JoinError(e) - } - Ok(result) => { - Event::SendTaskResult(result) - } - } - } - result = response_send_tasks.join_next(), if !response_send_tasks.is_empty() => { - Event::ResponseSendTaskResult( - result.expect("non-empty response send task set") - ) - } - _ = serve_loop_ct.cancelled() => { - tracing::info!("task cancelled"); - break QuitReason::Cancelled - } - } - }; - - tracing::trace!(?evt, "new event"); - match evt { - Event::SendTaskResult(SendTaskResult::Request { id, result }) => { - if let Err(e) = result - && let Some(responder) = local_responder_pool.remove(&id) { - let _ = responder.send(Err(ServiceError::TransportSend(e))); - } - } - Event::SendTaskResult(SendTaskResult::Notification { - responder, - result, - cancellation_param, - }) => { - let response = if let Err(e) = result { - Err(ServiceError::TransportSend(e)) - } else { - Ok(()) - }; - let _ = responder.send(response); - if let Some(param) = cancellation_param - && let Some(request_id) = ¶m.request_id - && let Some(responder) = local_responder_pool.remove(request_id) { - tracing::info!(id = %request_id, reason = param.reason, "cancelled"); - let _response_result = responder.send(Err(ServiceError::Cancelled { - reason: param.reason.clone(), - })); - } - } - Event::ResponseSendTaskResult(result) => { - if let Err(error) = result { - tracing::error!(%error, "response send task failed"); - } - } - // response and error - Event::ToSink(m) => { - if let Some(id) = match &m { - JsonRpcMessage::Response(response) => Some(&response.id), - JsonRpcMessage::Error(error) => error.id.as_ref(), - _ => None, - } { - let Some(ct) = local_ct_pool.remove(id) else { - tracing::debug!(%id, "dropping response for cancelled request"); - continue; - }; - ct.cancel(); - let send = transport.send(m); - let current_span = tracing::Span::current(); - response_send_tasks.spawn(async move { - let send_result = send.await; - if let Err(error) = send_result { - tracing::error!(%error, "fail to response message"); - } - }.instrument(current_span)); - } - } - Event::ProxyMessage(PeerSinkMessage::Request { - request, - id, - responder, - }) => { - local_responder_pool.insert(id.clone(), responder); - let send = transport.send(JsonRpcMessage::request(request, id.clone())); - { - let id = id.clone(); - let current_span = tracing::Span::current(); - send_task_set.spawn(send.map(move |r| SendTaskResult::Request { - id, - result: r.map_err(DynamicTransportError::new::), - }).instrument(current_span)); - } - } - Event::ProxyMessage(PeerSinkMessage::Notification { - notification, - responder, - }) => { - // catch cancellation notification - let mut cancellation_param = None; - let notification = match notification.try_into() { - Ok::(cancelled) => { - cancellation_param.replace(cancelled.params.clone()); - cancelled.into() - } - Err(notification) => notification, - }; - let send = transport.send(JsonRpcMessage::notification(notification)); - let current_span = tracing::Span::current(); - send_task_set.spawn(send.map(move |result| SendTaskResult::Notification { - responder, - cancellation_param, - result: result.map_err(DynamicTransportError::new::), - }).instrument(current_span)); - } - Event::PeerMessage(JsonRpcMessage::Request(JsonRpcRequest { - id, - mut request, - .. - })) => { - tracing::debug!(%id, ?request, "received request"); - if let Err(error) = R::enforce_peer_request_association( - &request, - peer.peer_info().as_deref(), - peer_request_association(&request, &local_responder_pool), - ) { - tracing::warn!(%id, message = %error.message, "rejected peer request"); - // send directly: the sink proxy path would drop the - // error since the request was never registered in - // local_ct_pool - let send = transport.send(JsonRpcMessage::error(error, Some(id))); - let current_span = tracing::Span::current(); - response_send_tasks.spawn(async move { - if let Err(error) = send.await { - tracing::error!(%error, "fail to send rejection error"); - } - }.instrument(current_span)); - continue; - } - { - let service = shared_service.clone(); - let sink = sink_proxy_tx.clone(); - let request_ct = serve_loop_ct.child_token(); - let context_ct = request_ct.child_token(); - local_ct_pool.insert(id.clone(), request_ct); - let mut extensions = Extensions::new(); - let mut meta = RequestMetaObject::new(); - // avoid clone - // swap meta firstly, otherwise progress token will be lost - std::mem::swap(&mut meta, request.get_meta_mut()); - std::mem::swap(&mut extensions, request.extensions_mut()); - let context = RequestContext { - ct: context_ct, - id: id.clone(), - peer: peer.clone(), - meta, - extensions, - }; - let current_span = tracing::Span::current(); - let handler_id = id.clone(); - spawn_service_task(async move { - let result = ORIGINATING_REQUEST - .scope(handler_id, service.handle_request(request, context)) - .await; - let response = match result { - Ok(result) => { - tracing::debug!(%id, ?result, "response message"); - JsonRpcMessage::response(result, id) - } - Err(error) => { - tracing::warn!(%id, ?error, "response error"); - JsonRpcMessage::error(error, Some(id)) - } - }; - let _send_result = sink.send(response).await; - }.instrument(current_span)); - } - } - Event::PeerMessage(JsonRpcMessage::Notification(JsonRpcNotification { - notification, - .. - })) => { - tracing::info!(?notification, "received notification"); - R::invalidate_response_cache(&peer, ¬ification).await; - let cancellation_request_id = - if let Some(cancelled) = R::peer_cancelled_params(¬ification) { - let request_id = cancelled.request_id.clone(); - if let Some(request_id) = request_id.as_ref() { - if R::IS_CLIENT { - if let Some(responder) = - local_responder_pool.remove(request_id) - { - let _ = responder.send(Err(ServiceError::Cancelled { - reason: cancelled.reason.clone(), - })); - } - } else if let Some(ct) = local_ct_pool.remove(request_id) { - tracing::info!(id = %request_id, reason = cancelled.reason, "cancelled"); - ct.cancel(); - } - } - request_id - } else { - None - }; - let subscription_id = notification - .get_meta() - .subscription_id() - .or(cancellation_request_id); - if let Some(subscription_id) = subscription_id - && let Some((sender, capacity)) = - peer.subscription_sender(&subscription_id) - { - match sender.try_send(notification) { - Ok(()) => {} - Err(mpsc::error::TrySendError::Full(_)) => { - tracing::warn!( - id = %subscription_id, - capacity, - "subscription notification buffer full" - ); - if R::IS_CLIENT - && let Some(responder) = - local_responder_pool.remove(&subscription_id) - { - let _ = responder - .send(Err(ServiceError::SubscriptionLagged { capacity })); - } - peer.unregister_subscription(&subscription_id); - peer.try_cancel_request( - subscription_id, - Some("subscription notification buffer full".to_owned()), - ); - } - Err(mpsc::error::TrySendError::Closed(_)) => { - peer.unregister_subscription(&subscription_id); - peer.try_cancel_request( - subscription_id, - Some("subscription notification receiver closed".to_owned()), - ); - } - } - continue; - } - let mut notification = notification; - if let Some(progress_token) = notification.progress_token() { - peer.notify_progress_timeout_watcher(progress_token).await; - } - { - let service = shared_service.clone(); - let mut extensions = Extensions::new(); - let mut meta = NotificationMetaObject::new(); - // avoid clone - std::mem::swap(&mut extensions, notification.extensions_mut()); - std::mem::swap(&mut meta, notification.get_meta_mut()); - let context = NotificationContext { - peer: peer.clone(), - meta, - extensions, - }; - let current_span = tracing::Span::current(); - spawn_service_task(async move { - let result = service.handle_notification(notification, context).await; - if let Err(error) = result { - tracing::warn!(%error, "Error sending notification"); - } - }.instrument(current_span)); - } - } - Event::PeerMessage(JsonRpcMessage::Response(JsonRpcResponse { - result, - id, - .. - })) => { - if let Some(responder) = - remove_pending_request(&mut local_responder_pool, &id) - { - let response_result = responder.send(Ok(result)); - if let Err(_error) = response_result { - tracing::warn!(%id, "Error sending response"); - } - } - } - Event::PeerMessage(JsonRpcMessage::Error(JsonRpcError { error, id, .. })) => { - let Some(id) = id else { - // MCP error responses without an id (e.g. Parse error / Invalid Request) - // can't be routed back to a pending request — log and drop. - tracing::debug!(?error, "received id-less peer error"); - continue; - }; - if let Some(responder) = - remove_pending_request(&mut local_responder_pool, &id) - { - let service_error = if error.is_transport_closed() { - ServiceError::TransportClosed - } else { - ServiceError::McpError(error) - }; - let _response_result = responder.send(Err(service_error)); - if let Err(_error) = _response_result { - tracing::warn!(%id, "Error sending response"); - } - } - } - } - }; - - // Drain in-flight handler responses before closing the transport. - // When stdin EOF or cancellation arrives, spawned handler tasks may still - // be finishing. We need to: - // 1. Wait for response sends that were already spawned in the main loop - // 2. Drain any remaining handler responses from the channel - let drain_timeout = match &quit_reason { - QuitReason::Closed => Some(Duration::from_secs(5)), - QuitReason::Cancelled => Some(Duration::from_secs(2)), - _ => None, - }; - if let Some(timeout_duration) = drain_timeout { - // Drop our sender so the channel closes once all handler task - // clones finish sending their responses (or are dropped). - drop(sink_proxy_tx); - let drain_result = tokio::time::timeout(timeout_duration, async { - // First, wait for any response sends already dispatched by the - // main loop (these hold transport write futures). - while let Some(result) = response_send_tasks.join_next().await { - if let Err(error) = result { - tracing::error!(%error, "response send task failed during drain"); - } - } - // Then drain any handler responses still in the channel - // (handlers that finished after the loop broke). - while let Some(m) = sink_proxy_rx.recv().await { - if let Err(error) = transport.send(m).await { - tracing::error!(%error, "failed to send pending response during drain"); - break; - } - } - }) - .await; - if drain_result.is_err() { - tracing::warn!("timed out draining in-flight responses"); - } - } - - let sink_close_result = transport.close().await; - if let Err(e) = sink_close_result { - tracing::error!(%e, "fail to close sink"); - } - tracing::info!(?quit_reason, "serve finished"); - quit_reason - }.instrument(current_span)); - RunningService { - service, - peer: peer_return, - handle: Some(handle), - cancellation_token: ct.clone(), - dg: ct.drop_guard(), - } -} - -#[cfg(all(test, feature = "server"))] -mod sep2260_marker_tests { - use std::sync::Arc; - - use super::*; - use crate::model::{PingRequest, RequestId, ServerRequest}; - - fn ping() -> ServerRequest { - ServerRequest::PingRequest(PingRequest { - method: Default::default(), - extensions: Default::default(), - }) - } - - async fn send_and_capture(scope: Option) -> ::Req { - // peer_info None keeps enforcement non-strict; only the sink message matters. - let (peer, mut rx) = - Peer::::new(Arc::new(AtomicU32RequestIdProvider::default()), None); - let send = peer.send_request_with_option(ping(), PeerRequestOptions::no_options()); - let _handle = match scope { - Some(id) => ORIGINATING_REQUEST.scope(id, send).await.unwrap(), - None => send.await.unwrap(), - }; - let PeerSinkMessage::Request { request, .. } = rx.recv().await.expect("sink message") - else { - panic!("expected a request sink message"); - }; - request - } - - #[tokio::test] - async fn outbound_request_carries_originating_id_when_in_scope() { - let request = send_and_capture(Some(RequestId::Number(7))).await; - let marker = request - .extensions() - .get::() - .expect("marker attached"); - assert_eq!(marker.0, RequestId::Number(7)); - } - - #[tokio::test] - async fn outbound_request_has_no_marker_outside_scope() { - let request = send_and_capture(None).await; - assert!(request.extensions().get::().is_none()); - } - - #[test] - #[expect( - deprecated, - reason = "Sampling is deprecated by SEP-2577 but remains the canonical restricted request" - )] - fn peer_request_association_maps_stream_origin() { - use std::collections::HashMap; - - use crate::model::{ - CreateMessageRequest, CreateMessageRequestParams, SamplingMessage, ServerRequest, - }; - - fn sampling(origin: Option) -> ServerRequest { - let mut request = CreateMessageRequest::new(CreateMessageRequestParams::new( - vec![SamplingMessage::user_text("hi")], - 16, - )); - if let Some(origin) = origin { - request.extensions.insert(origin); - } - ServerRequest::CreateMessageRequest(request) - } - - let empty: HashMap = HashMap::new(); - let in_flight: HashMap = HashMap::from([(RequestId::Number(7), ())]); - - // No marker (stdio): coarse signal. - assert_eq!( - peer_request_association(&sampling(None), &in_flight), - PeerRequestAssociation::Unknown { - has_pending_outbound_request: true - } - ); - assert_eq!( - peer_request_association(&sampling(None), &empty), - PeerRequestAssociation::Unknown { - has_pending_outbound_request: false - } - ); - // Standalone GET stream: unassociated even with requests in flight. - assert_eq!( - peer_request_association( - &sampling(Some(InboundStreamOrigin::Unassociated)), - &in_flight - ), - PeerRequestAssociation::Unassociated - ); - // Originating POST stream of an in-flight request: associated. - assert_eq!( - peer_request_association( - &sampling(Some(InboundStreamOrigin::OutboundRequest( - RequestId::Number(7) - ))), - &in_flight - ), - PeerRequestAssociation::Associated - ); - // Stream of a request that is no longer in flight: unassociated. - assert_eq!( - peer_request_association( - &sampling(Some(InboundStreamOrigin::OutboundRequest( - RequestId::Number(8) - ))), - &in_flight - ), - PeerRequestAssociation::Unassociated - ); - } -} diff --git a/vendor/rmcp/src/service/client.rs b/vendor/rmcp/src/service/client.rs deleted file mode 100644 index 2e25b13a3..000000000 --- a/vendor/rmcp/src/service/client.rs +++ /dev/null @@ -1,2302 +0,0 @@ -// Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected. -#![expect(deprecated)] -pub(super) mod cache; - -use std::{borrow::Cow, num::NonZeroUsize, sync::Arc, time::Duration}; - -use cache::CacheGeneration; -pub use cache::{ClientCacheConfig, MAX_CLIENT_CACHE_TTL}; -use thiserror::Error; - -use super::*; -use crate::{ - model::{ - ArgumentInfo, CacheScope, CallToolRequest, CallToolRequestParams, CallToolResponse, - CallToolResult, CancelTaskParams, CancelTaskRequest, CancelledNotification, - CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, ClientNotification, - ClientRequest, ClientResult, CompleteRequest, CompleteRequestParams, CompleteResult, - CompletionContext, CompletionInfo, DEFAULT_MRTR_MAX_ROUNDS, DiscoverRequest, - DiscoverRequestParams, DiscoverResult, ErrorData, GetExtensions, GetMeta, GetPromptRequest, - GetPromptRequestParams, GetPromptResponse, GetPromptResult, GetTaskParams, GetTaskRequest, - GetTaskResult, InitializeRequest, InitializedNotification, InputRequest, - InputRequiredResult, InputResponses, JsonRpcResponse, ListPromptsRequest, - ListPromptsResult, ListResourceTemplatesRequest, ListResourceTemplatesResult, - ListResourcesRequest, ListResourcesResult, ListToolsRequest, ListToolsResult, - NumberOrString, PaginatedRequestParams, ProgressNotification, ProgressNotificationParam, - ProtocolVersion, ReadResourceRequest, ReadResourceRequestParams, ReadResourceResponse, - ReadResourceResult, Reference, RequestId, RequestMetaObject, RootsListChangedNotification, - ServerJsonRpcMessage, ServerNotification, ServerPeerInfo, ServerRequest, ServerResult, - SetLevelRequest, SetLevelRequestParams, SubscribeRequest, SubscribeRequestParams, - SubscriptionFilter, SubscriptionsListenRequest, SubscriptionsListenRequestParams, - SubscriptionsListenResult, UnsubscribeRequest, UnsubscribeRequestParams, UpdateTaskParams, - UpdateTaskRequest, - }, - transport::DynamicTransportError, -}; - -/// It represents the error that may occur when serving the client. -/// -/// if you want to handle the error, you can use `serve_client_with_ct` or `serve_client` with `Result, ClientError>` -#[derive(Error, Debug)] -#[non_exhaustive] -pub enum ClientInitializeError { - #[error("expect initialized response, but received: {0:?}")] - ExpectedInitResponse(Option), - - #[error("expect initialized result, but received: {0:?}")] - ExpectedInitResult(Option), - - #[error("conflict initialized response id: expected {0}, got {1}")] - ConflictInitResponseId(RequestId, RequestId), - - #[error("connection closed: {0}")] - ConnectionClosed(String), - - #[error("Send message error {error}, when {context}")] - TransportError { - error: DynamicTransportError, - context: Cow<'static, str>, - }, - - #[error("JSON-RPC error: {0}")] - JsonRpcError(ErrorData), - - #[error( - "no compatible protocol version (client: {client_supported:?}, server: {server_supported:?})" - )] - NoCompatibleProtocolVersion { - client_supported: Vec, - server_supported: Vec, - }, - - #[error("discover startup requires at least one preferred protocol version")] - NoPreferredProtocolVersion, - - #[error("Cancelled")] - Cancelled, -} - -impl ClientInitializeError { - pub fn transport + 'static>( - error: T::Error, - context: impl Into>, - ) -> Self { - Self::TransportError { - error: DynamicTransportError::new::(error), - context: context.into(), - } - } - - /// The `WWW-Authenticate` challenge from the 401/403 the transport hit - /// during initialization, if that is why initialization failed. - /// - /// This is the trigger of the reactive OAuth flow: feed the challenge to - /// `AuthorizationRequest::with_challenge` to authorize, then reconnect. - #[cfg(feature = "transport-streamable-http-client")] - pub fn auth_challenge(&self) -> Option<&str> { - use crate::transport::streamable_http_client::{AuthRequiredError, InsufficientScopeError}; - - let Self::TransportError { error, .. } = self else { - return None; - }; - let mut source: Option<&(dyn std::error::Error + 'static)> = Some(error.error.as_ref()); - while let Some(current) = source { - if let Some(auth_required) = current.downcast_ref::() { - return Some(&auth_required.www_authenticate_header); - } - if let Some(insufficient_scope) = current.downcast_ref::() { - return Some(&insufficient_scope.www_authenticate_header); - } - source = current.source(); - } - None - } - - /// Returns whether client initialization failed because authorization is required. - /// - /// This covers both missing or expired local OAuth authorization and an HTTP - /// authorization challenge from the MCP server. - pub fn is_authorization_required(&self) -> bool { - matches!( - self, - Self::TransportError { error, .. } if error.is_authorization_required() - ) - } -} - -/// Helper function to get the next message from the stream -async fn expect_next_message( - transport: &mut T, - context: &str, -) -> Result -where - T: Transport, -{ - transport - .receive() - .await - .ok_or_else(|| ClientInitializeError::ConnectionClosed(context.to_string())) -} - -/// Helper function to expect a response from the stream -async fn expect_response( - transport: &mut T, - context: &str, - service: &S, - peer: Peer, -) -> Result<(ServerResult, RequestId), ClientInitializeError> -where - T: Transport, - S: Service, -{ - loop { - let message = expect_next_message(transport, context).await?; - match message { - // Expected message to complete the initialization - ServerJsonRpcMessage::Response(JsonRpcResponse { id, result, .. }) => { - break Ok((result, id)); - } - // Handle JSON-RPC error responses - ServerJsonRpcMessage::Error(error) => { - break Err(ClientInitializeError::JsonRpcError(error.error)); - } - // Server could send logging messages before handshake - ServerJsonRpcMessage::Notification(mut notification) => { - let ServerNotification::LoggingMessageNotification(logging) = - &mut notification.notification - else { - tracing::warn!(?notification, "Received unexpected message"); - continue; - }; - - let mut context = NotificationContext { - peer: peer.clone(), - meta: NotificationMetaObject::default(), - extensions: Extensions::default(), - }; - - if let Some(meta) = logging.extensions.get_mut::() { - std::mem::swap(&mut context.meta, meta); - } - std::mem::swap(&mut context.extensions, &mut logging.extensions); - - if let Err(error) = service - .handle_notification(notification.notification, context) - .await - { - tracing::warn!(?error, "Handle logging before handshake failed."); - } - } - // Server could send pings before handshake - ServerJsonRpcMessage::Request(ref request) - if matches!(request.request, ServerRequest::PingRequest(_)) => - { - tracing::trace!("Received ping request. Ignored.") - } - // Server SHOULD NOT send any other messages before handshake. We ignore them anyway - _ => tracing::warn!(?message, "Received unexpected message"), - } - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct RoleClient; - -/// Select the first client-preferred protocol version supported by the server. -/// -/// Returns `None` when no version is shared. -pub fn select_protocol_version( - client_preference: &[ProtocolVersion], - server_supported: &[ProtocolVersion], -) -> Option { - client_preference - .iter() - .find(|version| server_supported.contains(version)) - .cloned() -} - -impl ServiceRole for RoleClient { - type Req = ClientRequest; - type Resp = ClientResult; - type Not = ClientNotification; - type PeerReq = ServerRequest; - type PeerResp = ServerResult; - type PeerNot = ServerNotification; - type Info = ClientInfo; - type PeerInfo = ServerPeerInfo; - type InitializeError = ClientInitializeError; - const IS_CLIENT: bool = true; - - fn configure_direct_peer(peer: &Peer, info: &Self::Info) { - let Some(server_info) = peer.peer_info() else { - return; - }; - if server_info.protocol_version.as_str() < ProtocolVersion::V_2026_07_28.as_str() { - return; - } - peer.set_client_request_metadata(ClientRequestMetadata { - protocol_version: server_info.protocol_version.clone(), - client_info: info.client_info.clone(), - client_capabilities: info.capabilities.clone(), - }); - } - - fn peer_cancelled_params(notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> { - match notification { - ServerNotification::CancelledNotification(notification) => Some(¬ification.params), - _ => None, - } - } - - // SEP-2260: reject restricted server requests that arrived unassociated - // with any in-flight outbound request. Without stream separation - // (`Unknown`) the coarse in-flight check under-approximates the SHOULD. - fn enforce_peer_request_association( - peer_request: &Self::PeerReq, - peer_info: Option<&Self::PeerInfo>, - association: PeerRequestAssociation, - ) -> Result<(), ErrorData> { - let restricted = matches!( - peer_request, - ServerRequest::CreateMessageRequest(_) - | ServerRequest::ListRootsRequest(_) - | ServerRequest::ElicitRequest(_) - ); - if !restricted { - return Ok(()); - } - let strict = - peer_info.is_some_and(|info| info.protocol_version >= ProtocolVersion::V_2026_07_28); - if !strict { - return Ok(()); - } - let associated = match association { - PeerRequestAssociation::Associated => true, - PeerRequestAssociation::Unassociated => false, - PeerRequestAssociation::Unknown { - has_pending_outbound_request, - } => has_pending_outbound_request, - }; - if associated { - Ok(()) - } else { - Err(ErrorData::invalid_params( - "SEP-2260: server-to-client requests must be associated with an in-flight client request", - None, - )) - } - } - - async fn invalidate_response_cache(peer: &Peer, notification: &Self::PeerNot) { - match notification { - ServerNotification::ResourceUpdatedNotification(notification) => { - peer.invalidate_resource_read_cache(¬ification.params.uri) - .await; - } - ServerNotification::ResourceListChangedNotification(_) => { - peer.invalidate_resource_list_cache().await; - } - ServerNotification::ToolListChangedNotification(_) => { - peer.invalidate_tool_cache().await; - } - ServerNotification::PromptListChangedNotification(_) => { - peer.invalidate_prompt_cache().await; - } - _ => {} - } - } -} - -pub type ServerSink = Peer; - -/// Default number of notifications buffered for one subscription. -pub const DEFAULT_SUBSCRIPTION_CHANNEL_CAPACITY: usize = 64; - -/// How a client-side subscription stream ended. -#[derive(Debug, Clone, PartialEq)] -#[non_exhaustive] -pub enum SubscriptionEnd { - /// The server returned a final `SubscriptionsListenResult`. - Graceful(SubscriptionsListenResult), - /// The transport closed without a final result. Call `Peer::listen` again - /// after reconnecting; subscription streams are not resumable. - Abrupt, - /// The subscription was explicitly cancelled by either peer. - Cancelled, - /// The consumer did not drain notifications before the channel filled. - Lagged { capacity: usize }, -} - -/// Handle for one active `subscriptions/listen` request. -#[derive(Debug)] -pub struct Subscription { - id: RequestId, - acknowledged: SubscriptionFilter, - notifications: tokio::sync::mpsc::Receiver, - request: Option>, - end: Option, -} - -type SubscriptionResponse = - Result, tokio::sync::oneshot::error::RecvError>; - -struct PendingSubscriptionRequest { - handle: Option>, -} - -impl PendingSubscriptionRequest { - fn new(handle: RequestHandle) -> Self { - Self { - handle: Some(handle), - } - } - - async fn recv(&mut self) -> Option { - let handle = self.handle.as_mut()?; - Some((&mut handle.rx).await) - } - - fn take(&mut self) -> Option> { - self.handle.take() - } - - fn unregister(&self, id: &RequestId) { - if let Some(handle) = self.handle.as_ref() { - handle.peer.unregister_subscription(id); - } - } - - fn disarm(&mut self) { - self.handle.take(); - } - - async fn cancel(&mut self, reason: &'static str) { - if let Some(handle) = self.handle.take() { - let _ = handle.cancel(Some(reason.to_owned())).await; - } - } -} - -impl Drop for PendingSubscriptionRequest { - fn drop(&mut self) { - let Some(handle) = self.handle.take() else { - return; - }; - handle.peer.unregister_subscription(&handle.id); - handle.peer.try_cancel_request( - handle.id, - Some("subscription establishment cancelled".to_owned()), - ); - } -} - -impl Subscription { - /// Return the originating listen request ID. - pub fn id(&self) -> &RequestId { - &self.id - } - - /// Return the notification filter accepted by the server. - pub fn acknowledged(&self) -> &SubscriptionFilter { - &self.acknowledged - } - - /// Return the terminal state after this subscription has ended. - pub fn end(&self) -> Option<&SubscriptionEnd> { - self.end.as_ref() - } - - /// Receive the next notification, or `None` after the subscription ends. - /// - /// A graceful final result and an abrupt transport close are distinguished - /// through [`Self::end`]. - /// - /// # Errors - /// - /// Returns a service or protocol error when the stream carries an invalid - /// message, an unexpected final result, or another request failure. - pub async fn next(&mut self) -> Result, ServiceError> { - if self.end.is_some() { - return Ok(None); - } - let Some(request) = self.request.as_mut() else { - self.end = Some(SubscriptionEnd::Abrupt); - return Ok(None); - }; - - tokio::select! { - biased; - notification = self.notifications.recv() => { - let Some(notification) = notification else { - let response = (&mut request.rx).await; - return self.handle_response(response); - }; - if let ServerNotification::CancelledNotification(cancelled) = ¬ification { - if cancelled.params.request_id.as_ref() != Some(&self.id) { - self.cancel_as_abrupt("subscription cancellation ID mismatch") - .await; - return Err(ServiceError::UnexpectedResponse); - } - self.finish(SubscriptionEnd::Cancelled); - return Ok(None); - } - if notification.get_meta().subscription_id().as_ref() != Some(&self.id) { - self.cancel_as_abrupt("subscription notification ID mismatch") - .await; - return Err(ServiceError::UnexpectedResponse); - } - if !self.accepts(¬ification) { - self.cancel_as_abrupt( - "subscription notification was outside the acknowledged filter", - ) - .await; - return Err(ServiceError::UnexpectedResponse); - } - Ok(Some(notification)) - } - response = &mut request.rx => { - self.handle_response(response) - } - } - } - - /// Cancel this subscription. - /// - /// # Errors - /// - /// Returns a transport error when the cancellation signal cannot be sent. - pub async fn cancel(&mut self) -> Result<(), ServiceError> { - self.cancel_with_reason(None).await - } - - /// Cancel this subscription with a diagnostic reason. - /// - /// # Errors - /// - /// Returns a transport error when the cancellation signal cannot be sent. - pub async fn cancel_with_reason(&mut self, reason: Option) -> Result<(), ServiceError> { - let Some(request) = self.request.take() else { - return Ok(()); - }; - request.cancel(reason).await?; - self.end = Some(SubscriptionEnd::Cancelled); - Ok(()) - } - - fn finish(&mut self, end: SubscriptionEnd) { - if let Some(request) = self.request.take() { - request.peer.unregister_subscription(&self.id); - } - self.end = Some(end); - } - - async fn cancel_as_abrupt(&mut self, reason: &'static str) { - if let Some(request) = self.request.take() { - let _ = request.cancel(Some(reason.to_owned())).await; - } - self.end = Some(SubscriptionEnd::Abrupt); - } - - fn accepts(&self, notification: &ServerNotification) -> bool { - match notification { - ServerNotification::ToolListChangedNotification(_) => { - self.acknowledged.tools_list_changed == Some(true) - } - ServerNotification::PromptListChangedNotification(_) => { - self.acknowledged.prompts_list_changed == Some(true) - } - ServerNotification::ResourceListChangedNotification(_) => { - self.acknowledged.resources_list_changed == Some(true) - } - ServerNotification::ResourceUpdatedNotification(update) => self - .acknowledged - .resource_subscriptions - .as_ref() - .is_some_and(|uris| uris.contains(&update.params.uri)), - ServerNotification::SubscriptionsAcknowledgedNotification(_) - | ServerNotification::CancelledNotification(_) - | ServerNotification::ProgressNotification(_) - | ServerNotification::LoggingMessageNotification(_) - | ServerNotification::TaskStatusNotification(_) - | ServerNotification::CustomNotification(_) => false, - } - } - - fn handle_response( - &mut self, - response: SubscriptionResponse, - ) -> Result, ServiceError> { - let response = match response { - Ok(response) => response, - Err(_) => { - self.finish(SubscriptionEnd::Abrupt); - return Ok(None); - } - }; - let response = match response { - Ok(response) => response, - Err(ServiceError::TransportClosed) => { - self.finish(SubscriptionEnd::Abrupt); - return Ok(None); - } - Err(ServiceError::SubscriptionLagged { capacity }) => { - self.finish(SubscriptionEnd::Lagged { capacity }); - return Ok(None); - } - Err(error) => { - self.finish(SubscriptionEnd::Abrupt); - return Err(error); - } - }; - let ServerResult::SubscriptionsListenResult(result) = response else { - self.finish(SubscriptionEnd::Abrupt); - return Err(ServiceError::UnexpectedResponse); - }; - if !result.result_type.is_complete() - || result.meta.subscription_id().as_ref() != Some(&self.id) - { - self.finish(SubscriptionEnd::Abrupt); - return Err(ServiceError::UnexpectedResponse); - } - self.finish(SubscriptionEnd::Graceful(result)); - Ok(None) - } -} - -impl Drop for Subscription { - fn drop(&mut self) { - let Some(request) = self.request.take() else { - return; - }; - request.peer.unregister_subscription(&self.id); - request.peer.try_cancel_request( - self.id.clone(), - Some("subscription handle dropped".to_owned()), - ); - } -} - -/// Selects how a client establishes its MCP lifecycle. -/// -/// Existing [`ServiceExt::serve`] behavior remains legacy initialization. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum ClientLifecycleMode { - /// Use the legacy `initialize` / `notifications/initialized` handshake. - Initialize, - /// Use `server/discover` and send self-contained per-request metadata. - Discover { - preferred_versions: Vec, - }, - /// Probe with `server/discover`, falling back only when the peer proves it is legacy. - Auto { - preferred_versions: Vec, - legacy_version: Option, - }, -} - -/// Client-specific lifecycle entry points. -pub trait ClientServiceExt: Service + Sized { - fn serve_with_lifecycle( - self, - transport: T, - lifecycle: ClientLifecycleMode, - ) -> impl Future, ClientInitializeError>> - + MaybeSendFuture - where - T: IntoTransport, - E: std::error::Error + Send + Sync + 'static, - { - serve_client_with_lifecycle(self, transport, lifecycle) - } -} - -impl> ClientServiceExt for S {} - -impl> ServiceExt for S { - fn serve_with_ct( - self, - transport: T, - ct: CancellationToken, - ) -> impl Future, ClientInitializeError>> - + MaybeSendFuture - where - T: IntoTransport, - E: std::error::Error + Send + Sync + 'static, - Self: Sized, - { - serve_client_with_ct(self, transport, ct) - } -} - -pub async fn serve_client( - service: S, - transport: T, -) -> Result, ClientInitializeError> -where - S: Service, - T: IntoTransport, - E: std::error::Error + Send + Sync + 'static, -{ - serve_client_with_lifecycle_and_ct( - service, - transport, - ClientLifecycleMode::Initialize, - Default::default(), - ) - .await -} - -pub async fn serve_client_with_ct( - service: S, - transport: T, - ct: CancellationToken, -) -> Result, ClientInitializeError> -where - S: Service, - T: IntoTransport, - E: std::error::Error + Send + Sync + 'static, -{ - serve_client_with_lifecycle_and_ct(service, transport, ClientLifecycleMode::Initialize, ct) - .await -} - -pub async fn serve_client_with_lifecycle( - service: S, - transport: T, - lifecycle: ClientLifecycleMode, -) -> Result, ClientInitializeError> -where - S: Service, - T: IntoTransport, - E: std::error::Error + Send + Sync + 'static, -{ - serve_client_with_lifecycle_and_ct(service, transport, lifecycle, Default::default()).await -} - -pub async fn serve_client_with_lifecycle_and_ct( - service: S, - transport: T, - lifecycle: ClientLifecycleMode, - ct: CancellationToken, -) -> Result, ClientInitializeError> -where - S: Service, - T: IntoTransport, - E: std::error::Error + Send + Sync + 'static, -{ - tokio::select! { - result = serve_client_with_ct_inner(service, transport.into_transport(), lifecycle, ct.clone()) => { result } - _ = ct.cancelled() => { - Err(ClientInitializeError::Cancelled) - } - } -} - -async fn serve_client_with_ct_inner( - service: S, - transport: T, - lifecycle: ClientLifecycleMode, - ct: CancellationToken, -) -> Result, ClientInitializeError> -where - S: Service, - T: Transport + 'static, -{ - let mut transport = transport.into_transport(); - let id_provider = >::default(); - let (peer, peer_rx) = Peer::new(id_provider.clone(), None); - let client_info = service.get_info(); - - match lifecycle { - ClientLifecycleMode::Initialize => { - legacy_startup(&service, &mut transport, &id_provider, &peer, client_info).await?; - } - ClientLifecycleMode::Discover { preferred_versions } => { - discover_startup( - &service, - &mut transport, - &id_provider, - &peer, - &client_info, - preferred_versions, - ) - .await?; - } - ClientLifecycleMode::Auto { - preferred_versions, - legacy_version, - } => { - let discover_result = discover_startup( - &service, - &mut transport, - &id_provider, - &peer, - &client_info, - preferred_versions, - ) - .await; - match discover_result { - Ok(()) => {} - Err(ClientInitializeError::JsonRpcError(error)) - if error.code == crate::model::ErrorCode::METHOD_NOT_FOUND => - { - let mut legacy_info = client_info; - if let Some(version) = legacy_version { - legacy_info.protocol_version = version; - } - legacy_startup(&service, &mut transport, &id_provider, &peer, legacy_info) - .await?; - } - Err(error) => return Err(error), - } - } - } - Ok(serve_inner(service, transport, peer, peer_rx, ct)) -} - -async fn legacy_startup( - service: &S, - transport: &mut T, - id_provider: &Arc, - peer: &Peer, - client_info: ClientInfo, -) -> Result<(), ClientInitializeError> -where - S: Service, - T: Transport + 'static, -{ - let id = id_provider.next_request_id(); - let init_request = InitializeRequest { - method: Default::default(), - params: client_info, - extensions: Default::default(), - }; - transport - .send(ClientJsonRpcMessage::request( - ClientRequest::InitializeRequest(init_request), - id.clone(), - )) - .await - .map_err(|error| ClientInitializeError::TransportError { - error: DynamicTransportError::new::(error), - context: "send initialize request".into(), - })?; - - let (response, response_id) = - expect_response(transport, "initialize response", service, peer.clone()).await?; - - if !id.matches_response_id(&response_id) { - return Err(ClientInitializeError::ConflictInitResponseId( - id, - response_id, - )); - } - - let ServerResult::InitializeResult(initialize_result) = response else { - return Err(ClientInitializeError::ExpectedInitResult(Some(response))); - }; - peer.set_peer_info(initialize_result.into()); - - // send notification - let notification = ClientJsonRpcMessage::notification( - ClientNotification::InitializedNotification(InitializedNotification { - method: Default::default(), - extensions: Default::default(), - }), - ); - transport.send(notification).await.map_err(|error| { - ClientInitializeError::transport::(error, "send initialized notification") - })?; - Ok(()) -} - -async fn discover_startup( - service: &S, - transport: &mut T, - id_provider: &Arc, - peer: &Peer, - client_info: &ClientInfo, - preferred_versions: Vec, -) -> Result<(), ClientInitializeError> -where - S: Service, - T: Transport + 'static, -{ - if preferred_versions.is_empty() { - return Err(ClientInitializeError::NoPreferredProtocolVersion); - } - - let mut attempted = Vec::new(); - let mut candidate = preferred_versions[0].clone(); - loop { - attempted.push(candidate.clone()); - - let meta = RequestMetaObject::with_client_context( - candidate.clone(), - client_info.client_info.clone(), - client_info.capabilities.clone(), - ); - let mut discover = DiscoverRequest::new(DiscoverRequestParams {}); - discover.extensions.insert(meta); - let id = id_provider.next_request_id(); - transport - .send(ClientJsonRpcMessage::request( - ClientRequest::DiscoverRequest(discover), - id.clone(), - )) - .await - .map_err(|error| { - ClientInitializeError::transport::(error, "send discover request") - })?; - - match expect_response(transport, "discover response", service, peer.clone()).await { - Ok((ServerResult::DiscoverResult(result), response_id)) => { - if !id.matches_response_id(&response_id) { - return Err(ClientInitializeError::ConflictInitResponseId( - id, - response_id, - )); - } - let Some(selected) = - select_protocol_version(&preferred_versions, &result.supported_versions) - else { - return Err(ClientInitializeError::NoCompatibleProtocolVersion { - client_supported: preferred_versions, - server_supported: result.supported_versions, - }); - }; - peer.set_peer_info(ServerPeerInfo::from_discover_result( - selected.clone(), - result, - )); - peer.set_client_request_metadata(ClientRequestMetadata { - protocol_version: selected, - client_info: client_info.client_info.clone(), - client_capabilities: client_info.capabilities.clone(), - }); - return Ok(()); - } - Ok((response, _)) => { - return Err(ClientInitializeError::ExpectedInitResult(Some(response))); - } - Err(ClientInitializeError::JsonRpcError(error)) - if error.code == crate::model::ErrorCode::UNSUPPORTED_PROTOCOL_VERSION => - { - let supported = error - .data - .as_ref() - .and_then(|data| data.get("supported")) - .cloned() - .and_then(|value| serde_json::from_value::>(value).ok()) - .unwrap_or_default(); - let may_retry_current = attempted - .iter() - .filter(|version| *version == &candidate) - .count() - == 1; - let next = preferred_versions - .iter() - .find(|version| { - supported.contains(version) - && (!attempted.contains(version) - || (may_retry_current && *version == &candidate)) - }) - .cloned(); - let Some(next) = next else { - return Err(ClientInitializeError::NoCompatibleProtocolVersion { - client_supported: preferred_versions, - server_supported: supported, - }); - }; - candidate = next; - } - Err(error) => return Err(error), - } - } -} - -const DISCOVER_CACHE_PREFIX: &str = "server/discover:"; -const TOOL_LIST_CACHE_PREFIX: &str = "tools/list:"; -const PROMPT_LIST_CACHE_PREFIX: &str = "prompts/list:"; -const RESOURCE_LIST_CACHE_PREFIX: &str = "resources/list:"; -const RESOURCE_TEMPLATE_LIST_CACHE_PREFIX: &str = "resources/templates/list:"; -const RESOURCE_READ_CACHE_PREFIX: &str = "resources/read:"; - -// Cache keys are built only from the request method plus the parameters that -// affect the result (SEP-2549). Request `_meta` (progress tokens, trace -// context, etc.) does not affect the result, so it is deliberately excluded to -// avoid fragmenting the cache across otherwise-identical requests. -fn discover_cache_key() -> String { - // `server/discover` carries no result-affecting parameters. - DISCOVER_CACHE_PREFIX.to_string() -} - -fn list_response_cache_key(prefix: &str, params: &Option) -> String { - // Only the pagination cursor affects which page is returned. - let cursor = params.as_ref().and_then(|params| params.cursor.as_deref()); - let cursor = - serde_json::to_string(&cursor).expect("serializing a pagination cursor cannot fail"); - format!("{prefix}{cursor}") -} - -fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option { - // MRTR retries depend on inputs that are not part of the cache key and MUST - // NOT be cached. - if params.input_responses.is_some() || params.request_state.is_some() { - return None; - } - // Only the URI affects the result. - Some(resource_read_cache_prefix_for_uri(¶ms.uri)) -} - -fn resource_read_cache_prefix_for_uri(uri: &str) -> String { - let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail"); - format!("{RESOURCE_READ_CACHE_PREFIX}{uri}:") -} - -fn request_uses_cursor(params: &Option) -> bool { - params - .as_ref() - .and_then(|params| params.cursor.as_ref()) - .is_some() -} - -macro_rules! method { - ($(#[$meta:meta])* peer_req $method:ident $Req:ident() => $Resp: ident ) => { - $(#[$meta])* - pub async fn $method(&self) -> Result<$Resp, ServiceError> { - let result = self - .send_request(ClientRequest::$Req($Req { - method: Default::default(), - })) - .await?; - match result { - ServerResult::$Resp(result) => Ok(result), - _ => Err(ServiceError::UnexpectedResponse), - } - } - }; - ($(#[$meta:meta])* peer_req $method:ident $Req:ident($Param: ident) => $Resp: ident ) => { - $(#[$meta])* - pub async fn $method(&self, params: $Param) -> Result<$Resp, ServiceError> { - let result = self - .send_request(ClientRequest::$Req($Req { - method: Default::default(), - params, - extensions: Default::default(), - })) - .await?; - match result { - ServerResult::$Resp(result) => Ok(result), - _ => Err(ServiceError::UnexpectedResponse), - } - } - }; - ($(#[$meta:meta])* peer_req $method:ident $Req:ident($Param: ident)? => $Resp: ident ) => { - $(#[$meta])* - pub async fn $method(&self, params: Option<$Param>) -> Result<$Resp, ServiceError> { - let result = self - .send_request(ClientRequest::$Req($Req { - method: Default::default(), - params, - extensions: Default::default(), - })) - .await?; - match result { - ServerResult::$Resp(result) => Ok(result), - _ => Err(ServiceError::UnexpectedResponse), - } - } - }; - ($(#[$meta:meta])* peer_req $method:ident $Req:ident($Param: ident)) => { - $(#[$meta])* - pub async fn $method(&self, params: $Param) -> Result<(), ServiceError> { - let result = self - .send_request(ClientRequest::$Req($Req { - method: Default::default(), - params, - extensions: Default::default(), - })) - .await?; - match result { - ServerResult::EmptyResult(_) => Ok(()), - _ => Err(ServiceError::UnexpectedResponse), - } - } - }; - - ($(#[$meta:meta])* peer_not $method:ident $Not:ident($Param: ident)) => { - $(#[$meta])* - pub async fn $method(&self, params: $Param) -> Result<(), ServiceError> { - self.send_notification(ClientNotification::$Not($Not { - method: Default::default(), - params, - extensions: Default::default(), - })) - .await?; - Ok(()) - } - }; - ($(#[$meta:meta])* peer_not $method:ident $Not:ident) => { - $(#[$meta])* - pub async fn $method(&self) -> Result<(), ServiceError> { - self.send_notification(ClientNotification::$Not($Not { - method: Default::default(), - extensions: Default::default(), - })) - .await?; - Ok(()) - } - }; -} - -impl Peer { - /// Open a long-lived notification subscription and wait for its acknowledgment. - /// - /// Notifications routed to the returned [`Subscription`] are not also - /// delivered through [`ClientHandler`](crate::ClientHandler) callbacks. - /// - /// # Errors - /// - /// Returns a service, transport, or protocol error when the request cannot - /// be established or the acknowledgment is invalid. - pub async fn listen( - &self, - notifications: SubscriptionFilter, - ) -> Result { - self.listen_with_channel_capacity_inner( - notifications, - DEFAULT_SUBSCRIPTION_CHANNEL_CAPACITY, - ) - .await - } - - /// Open a subscription with an explicit notification buffer capacity. - /// - /// Notifications routed to the returned [`Subscription`] are not also - /// delivered through [`ClientHandler`](crate::ClientHandler) callbacks. - /// - /// # Errors - /// - /// Returns a service, transport, or protocol error when the request cannot - /// be established or the acknowledgment is invalid. - pub async fn listen_with_capacity( - &self, - notifications: SubscriptionFilter, - channel_capacity: NonZeroUsize, - ) -> Result { - self.listen_with_channel_capacity_inner(notifications, channel_capacity.get()) - .await - } - - async fn listen_with_channel_capacity_inner( - &self, - notifications: SubscriptionFilter, - channel_capacity: usize, - ) -> Result { - let request = ClientRequest::SubscriptionsListenRequest(SubscriptionsListenRequest::new( - SubscriptionsListenRequestParams::new(notifications.clone()), - )); - let (handle, mut subscription_notifications) = self - .send_subscription_request(request, PeerRequestOptions::no_options(), channel_capacity) - .await?; - let id = handle.id.clone(); - let mut pending = PendingSubscriptionRequest::new(handle); - - tokio::select! { - biased; - notification = subscription_notifications.recv() => { - let Some(notification) = notification else { - pending.cancel("subscription stream closed before acknowledgment").await; - return Err(ServiceError::TransportClosed); - }; - if notification.get_meta().subscription_id().as_ref() != Some(&id) { - pending.cancel("subscription acknowledgment ID mismatch").await; - return Err(ServiceError::UnexpectedResponse); - } - let ServerNotification::SubscriptionsAcknowledgedNotification( - acknowledgment, - ) = notification else { - pending.cancel("notification received before subscription acknowledgment").await; - return Err(ServiceError::UnexpectedResponse); - }; - let accepted = acknowledgment.params.notifications; - if !accepted.is_subset_of(¬ifications) { - pending.cancel("subscription acknowledged an unrequested filter").await; - return Err(ServiceError::UnexpectedResponse); - } - let Some(handle) = pending.take() else { - return Err(ServiceError::TransportClosed); - }; - Ok(Subscription { - id, - acknowledged: accepted, - notifications: subscription_notifications, - request: Some(handle), - end: None, - }) - } - response = pending.recv() => { - pending.unregister(&id); - pending.disarm(); - let Some(response) = response else { - return Err(ServiceError::TransportClosed); - }; - match response { - Ok(Err(error)) => Err(error), - Ok(Ok(_)) => Err(ServiceError::UnexpectedResponse), - Err(_) => Err(ServiceError::TransportClosed), - } - } - } - } - - /// Discover the server's supported protocol versions and capabilities. - /// - /// The high-level client currently exposes this peer only after initialization; - /// pre-initialization probing is planned as follow-up work. - pub async fn discover(&self, meta: RequestMetaObject) -> Result { - let cache_key = discover_cache_key(); - if let Some(ServerResult::DiscoverResult(result)) = self.cached_response(&cache_key).await { - return Ok(result); - } - let generation = self.capture_response_cache_generation().await; - let mut request = DiscoverRequest::new(DiscoverRequestParams {}); - request.extensions.insert(meta); - let result = self - .send_request(ClientRequest::DiscoverRequest(request)) - .await; - let result = match result { - Ok(result) => result, - Err(error) => { - if let Some(ServerResult::DiscoverResult(result)) = - self.stale_cached_response(&cache_key).await - { - return Ok(result); - } - return Err(error); - } - }; - match result { - ServerResult::DiscoverResult(result) => { - self.cache_result( - Some(cache_key), - Some(result.ttl_ms), - Some(result.cache_scope), - generation, - ServerResult::DiscoverResult(result.clone()), - ) - .await; - Ok(result) - } - _ => Err(ServiceError::UnexpectedResponse), - } - } - - async fn cache_result( - &self, - cache_key: Option, - ttl_ms: Option, - cache_scope: Option, - generation: CacheGeneration, - result: ServerResult, - ) { - let Some(cache_key) = cache_key else { - return; - }; - self.cache_response_with_generation(cache_key, result, ttl_ms, cache_scope, generation) - .await; - } - - pub(crate) async fn invalidate_tool_cache(&self) { - self.invalidate_cached_responses(TOOL_LIST_CACHE_PREFIX) - .await; - } - - pub(crate) async fn invalidate_prompt_cache(&self) { - self.invalidate_cached_responses(PROMPT_LIST_CACHE_PREFIX) - .await; - } - - pub(crate) async fn invalidate_resource_list_cache(&self) { - self.invalidate_cached_responses(RESOURCE_LIST_CACHE_PREFIX) - .await; - self.invalidate_cached_responses(RESOURCE_TEMPLATE_LIST_CACHE_PREFIX) - .await; - } - - pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) { - self.invalidate_cached_responses(&resource_read_cache_prefix_for_uri(uri)) - .await; - } - - /// Send one `tools/call` request and return either a final result or an MRTR - /// `InputRequiredResult` without driving any follow-up rounds. - pub async fn call_tool_once( - &self, - params: CallToolRequestParams, - ) -> Result { - let result = self - .send_request(ClientRequest::CallToolRequest(CallToolRequest { - method: Default::default(), - params, - extensions: Default::default(), - })) - .await?; - match result { - ServerResult::CallToolResult(result) => Ok(CallToolResponse::Complete(result)), - ServerResult::InputRequiredResult(result) => { - Ok(CallToolResponse::InputRequired(result)) - } - // SEP-2663 Tasks extension: the server materialized a task. - ServerResult::CreateTaskResult(result) => Ok(CallToolResponse::Task(result)), - _ => Err(ServiceError::UnexpectedResponse), - } - } - - /// SEP-2663 `tasks/get`: poll the current state of a task. - pub async fn get_task(&self, params: GetTaskParams) -> Result { - let result = self - .send_request(ClientRequest::GetTaskRequest(GetTaskRequest::new(params))) - .await?; - match result { - ServerResult::GetTaskResult(result) => Ok(result), - _ => Err(ServiceError::UnexpectedResponse), - } - } - - /// SEP-2663 `tasks/update`: deliver responses to outstanding in-task - /// input requests. The acknowledgement is eventually consistent. - pub async fn update_task(&self, params: UpdateTaskParams) -> Result<(), ServiceError> { - let result = self - .send_request(ClientRequest::UpdateTaskRequest(UpdateTaskRequest::new( - params, - ))) - .await?; - match result { - ServerResult::TaskAckResult(_) | ServerResult::EmptyResult(_) => Ok(()), - _ => Err(ServiceError::UnexpectedResponse), - } - } - - /// SEP-2663 `tasks/cancel`: signal intent to cancel a task. Cancellation - /// is cooperative; the ack does not guarantee the task stops. - pub async fn cancel_task(&self, params: CancelTaskParams) -> Result<(), ServiceError> { - let result = self - .send_request(ClientRequest::CancelTaskRequest(CancelTaskRequest::new( - params, - ))) - .await?; - match result { - ServerResult::TaskAckResult(_) | ServerResult::EmptyResult(_) => Ok(()), - _ => Err(ServiceError::UnexpectedResponse), - } - } - - /// Send one `prompts/get` request and return either a final result or an MRTR - /// `InputRequiredResult` without driving any follow-up rounds. - pub async fn get_prompt_once( - &self, - params: GetPromptRequestParams, - ) -> Result { - let result = self - .send_request(ClientRequest::GetPromptRequest(GetPromptRequest { - method: Default::default(), - params, - extensions: Default::default(), - })) - .await?; - match result { - ServerResult::GetPromptResult(result) => Ok(GetPromptResponse::Complete(result)), - ServerResult::InputRequiredResult(result) => { - Ok(GetPromptResponse::InputRequired(result)) - } - _ => Err(ServiceError::UnexpectedResponse), - } - } - - /// Send one `resources/read` request and return either a final result or an - /// MRTR `InputRequiredResult` without driving any follow-up rounds. - pub async fn read_resource_once( - &self, - params: ReadResourceRequestParams, - ) -> Result { - let cache_key = resource_read_cache_key(¶ms); - if let Some(key) = cache_key.as_deref() - && let Some(ServerResult::ReadResourceResult(result)) = self.cached_response(key).await - { - return Ok(ReadResourceResponse::Complete(result)); - } - - let generation = self.capture_response_cache_generation().await; - let result = self - .send_request(ClientRequest::ReadResourceRequest(ReadResourceRequest { - method: Default::default(), - params, - extensions: Default::default(), - })) - .await; - let result = match result { - Ok(result) => result, - Err(error) => { - if let Some(key) = cache_key.as_deref() - && let Some(ServerResult::ReadResourceResult(result)) = - self.stale_cached_response(key).await - { - return Ok(ReadResourceResponse::Complete(result)); - } - return Err(error); - } - }; - match result { - ServerResult::ReadResourceResult(result) => { - self.cache_result( - cache_key, - result.ttl_ms, - result.cache_scope, - generation, - ServerResult::ReadResourceResult(result.clone()), - ) - .await; - Ok(ReadResourceResponse::Complete(result)) - } - ServerResult::InputRequiredResult(result) => { - Ok(ReadResourceResponse::InputRequired(result)) - } - _ => Err(ServiceError::UnexpectedResponse), - } - } - - method!(peer_req complete CompleteRequest(CompleteRequestParams) => CompleteResult); - method!( - #[deprecated( - since = "1.8.0", - note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" - )] - peer_req set_level SetLevelRequest(SetLevelRequestParams) - ); - method!(peer_req get_prompt GetPromptRequest(GetPromptRequestParams) => GetPromptResult); - method!( - #[deprecated( - note = "resources/subscribe is legacy-only; use Peer::listen for protocol version 2026-07-28" - )] - peer_req subscribe SubscribeRequest(SubscribeRequestParams) - ); - method!( - #[deprecated( - note = "resources/unsubscribe is legacy-only; cancel the Subscription handle instead" - )] - peer_req unsubscribe UnsubscribeRequest(UnsubscribeRequestParams) - ); - method!(peer_req call_tool CallToolRequest(CallToolRequestParams) => CallToolResult); - - pub async fn list_prompts( - &self, - params: Option, - ) -> Result { - let cache_key = list_response_cache_key(PROMPT_LIST_CACHE_PREFIX, ¶ms); - if let Some(ServerResult::ListPromptsResult(result)) = - self.cached_response(&cache_key).await - { - return Ok(result); - } - let generation = self.capture_response_cache_generation().await; - let uses_cursor = request_uses_cursor(¶ms); - let result = self - .send_request(ClientRequest::ListPromptsRequest(ListPromptsRequest { - method: Default::default(), - params, - extensions: Default::default(), - })) - .await; - let result = match result { - Ok(result) => result, - Err(error) => { - if uses_cursor { - self.invalidate_prompt_cache().await; - return Err(error); - } - if let Some(ServerResult::ListPromptsResult(result)) = - self.stale_cached_response(&cache_key).await - { - return Ok(result); - } - return Err(error); - } - }; - match result { - ServerResult::ListPromptsResult(result) => { - self.cache_result( - Some(cache_key), - result.ttl_ms, - result.cache_scope, - generation, - ServerResult::ListPromptsResult(result.clone()), - ) - .await; - Ok(result) - } - _ => Err(ServiceError::UnexpectedResponse), - } - } - - pub async fn list_resources( - &self, - params: Option, - ) -> Result { - let cache_key = list_response_cache_key(RESOURCE_LIST_CACHE_PREFIX, ¶ms); - if let Some(ServerResult::ListResourcesResult(result)) = - self.cached_response(&cache_key).await - { - return Ok(result); - } - let generation = self.capture_response_cache_generation().await; - let uses_cursor = request_uses_cursor(¶ms); - let result = self - .send_request(ClientRequest::ListResourcesRequest(ListResourcesRequest { - method: Default::default(), - params, - extensions: Default::default(), - })) - .await; - let result = match result { - Ok(result) => result, - Err(error) => { - if uses_cursor { - self.invalidate_cached_responses(RESOURCE_LIST_CACHE_PREFIX) - .await; - return Err(error); - } - if let Some(ServerResult::ListResourcesResult(result)) = - self.stale_cached_response(&cache_key).await - { - return Ok(result); - } - return Err(error); - } - }; - match result { - ServerResult::ListResourcesResult(result) => { - self.cache_result( - Some(cache_key), - result.ttl_ms, - result.cache_scope, - generation, - ServerResult::ListResourcesResult(result.clone()), - ) - .await; - Ok(result) - } - _ => Err(ServiceError::UnexpectedResponse), - } - } - - pub async fn list_resource_templates( - &self, - params: Option, - ) -> Result { - let cache_key = list_response_cache_key(RESOURCE_TEMPLATE_LIST_CACHE_PREFIX, ¶ms); - if let Some(ServerResult::ListResourceTemplatesResult(result)) = - self.cached_response(&cache_key).await - { - return Ok(result); - } - let generation = self.capture_response_cache_generation().await; - let uses_cursor = request_uses_cursor(¶ms); - let result = self - .send_request(ClientRequest::ListResourceTemplatesRequest( - ListResourceTemplatesRequest { - method: Default::default(), - params, - extensions: Default::default(), - }, - )) - .await; - let result = match result { - Ok(result) => result, - Err(error) => { - if uses_cursor { - self.invalidate_cached_responses(RESOURCE_TEMPLATE_LIST_CACHE_PREFIX) - .await; - return Err(error); - } - if let Some(ServerResult::ListResourceTemplatesResult(result)) = - self.stale_cached_response(&cache_key).await - { - return Ok(result); - } - return Err(error); - } - }; - match result { - ServerResult::ListResourceTemplatesResult(result) => { - self.cache_result( - Some(cache_key), - result.ttl_ms, - result.cache_scope, - generation, - ServerResult::ListResourceTemplatesResult(result.clone()), - ) - .await; - Ok(result) - } - _ => Err(ServiceError::UnexpectedResponse), - } - } - - pub async fn read_resource( - &self, - params: ReadResourceRequestParams, - ) -> Result { - match self.read_resource_once(params).await? { - ReadResourceResponse::Complete(result) => Ok(result), - ReadResourceResponse::InputRequired(_) => Err(ServiceError::UnexpectedResponse), - } - } - - pub async fn list_tools( - &self, - params: Option, - ) -> Result { - let cache_key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); - if let Some(ServerResult::ListToolsResult(result)) = self.cached_response(&cache_key).await - { - return Ok(result); - } - let generation = self.capture_response_cache_generation().await; - let uses_cursor = request_uses_cursor(¶ms); - let result = self - .send_request(ClientRequest::ListToolsRequest(ListToolsRequest { - method: Default::default(), - params, - extensions: Default::default(), - })) - .await; - let result = match result { - Ok(result) => result, - Err(error) => { - if uses_cursor { - self.invalidate_tool_cache().await; - return Err(error); - } - if let Some(ServerResult::ListToolsResult(result)) = - self.stale_cached_response(&cache_key).await - { - return Ok(result); - } - return Err(error); - } - }; - match result { - ServerResult::ListToolsResult(result) => { - self.cache_result( - Some(cache_key), - result.ttl_ms, - result.cache_scope, - generation, - ServerResult::ListToolsResult(result.clone()), - ) - .await; - Ok(result) - } - _ => Err(ServiceError::UnexpectedResponse), - } - } - - method!(peer_not notify_cancelled CancelledNotification(CancelledNotificationParam)); - method!(peer_not notify_progress ProgressNotification(ProgressNotificationParam)); - method!(peer_not notify_initialized InitializedNotification); - method!(peer_not notify_roots_list_changed RootsListChangedNotification); -} - -impl Peer { - /// A wrapper method for [`Peer::list_tools`]. - /// - /// This function will call [`Peer::list_tools`] multiple times until all tools are listed. - pub async fn list_all_tools(&self) -> Result, ServiceError> { - let mut tools = Vec::new(); - let mut cursor = None; - loop { - let result = self - .list_tools(Some(PaginatedRequestParams { meta: None, cursor })) - .await?; - tools.extend(result.tools); - cursor = result.next_cursor; - if cursor.is_none() { - break; - } - } - Ok(tools) - } - - /// A wrapper method for [`Peer::list_prompts`]. - /// - /// This function will call [`Peer::list_prompts`] multiple times until all prompts are listed. - pub async fn list_all_prompts(&self) -> Result, ServiceError> { - let mut prompts = Vec::new(); - let mut cursor = None; - loop { - let result = self - .list_prompts(Some(PaginatedRequestParams { meta: None, cursor })) - .await?; - prompts.extend(result.prompts); - cursor = result.next_cursor; - if cursor.is_none() { - break; - } - } - Ok(prompts) - } - - /// A wrapper method for [`Peer::list_resources`]. - /// - /// This function will call [`Peer::list_resources`] multiple times until all resources are listed. - pub async fn list_all_resources(&self) -> Result, ServiceError> { - let mut resources = Vec::new(); - let mut cursor = None; - loop { - let result = self - .list_resources(Some(PaginatedRequestParams { meta: None, cursor })) - .await?; - resources.extend(result.resources); - cursor = result.next_cursor; - if cursor.is_none() { - break; - } - } - Ok(resources) - } - - /// A wrapper method for [`Peer::list_resource_templates`]. - /// - /// This function will call [`Peer::list_resource_templates`] multiple times until all resource templates are listed. - pub async fn list_all_resource_templates( - &self, - ) -> Result, ServiceError> { - let mut resource_templates = Vec::new(); - let mut cursor = None; - loop { - let result = self - .list_resource_templates(Some(PaginatedRequestParams { meta: None, cursor })) - .await?; - resource_templates.extend(result.resource_templates); - cursor = result.next_cursor; - if cursor.is_none() { - break; - } - } - Ok(resource_templates) - } - - /// Convenient method to get completion suggestions for a prompt argument - /// - /// # Arguments - /// * `prompt_name` - Name of the prompt being completed - /// * `argument_name` - Name of the argument being completed - /// * `current_value` - Current partial value of the argument - /// * `context` - Optional context with previously resolved arguments - /// - /// # Returns - /// CompletionInfo with suggestions for the specified prompt argument - pub async fn complete_prompt_argument( - &self, - prompt_name: impl Into, - argument_name: impl Into, - current_value: impl Into, - context: Option, - ) -> Result { - let request = CompleteRequestParams { - meta: None, - r#ref: Reference::for_prompt(prompt_name), - argument: ArgumentInfo { - name: argument_name.into(), - value: current_value.into(), - }, - context, - }; - - let result = self.complete(request).await?; - Ok(result.completion) - } - - /// Convenient method to get completion suggestions for a resource URI argument - /// - /// # Arguments - /// * `uri_template` - URI template pattern being completed - /// * `argument_name` - Name of the URI parameter being completed - /// * `current_value` - Current partial value of the parameter - /// * `context` - Optional context with previously resolved arguments - /// - /// # Returns - /// CompletionInfo with suggestions for the specified resource URI argument - pub async fn complete_resource_argument( - &self, - uri_template: impl Into, - argument_name: impl Into, - current_value: impl Into, - context: Option, - ) -> Result { - let request = CompleteRequestParams { - meta: None, - r#ref: Reference::for_resource(uri_template), - argument: ArgumentInfo { - name: argument_name.into(), - value: current_value.into(), - }, - context, - }; - - let result = self.complete(request).await?; - Ok(result.completion) - } - - /// Simple completion for a prompt argument without context - /// - /// This is a convenience wrapper around `complete_prompt_argument` for - /// simple completion scenarios that don't require context awareness. - pub async fn complete_prompt_simple( - &self, - prompt_name: impl Into, - argument_name: impl Into, - current_value: impl Into, - ) -> Result, ServiceError> { - let completion = self - .complete_prompt_argument(prompt_name, argument_name, current_value, None) - .await?; - Ok(completion.values) - } - - /// Simple completion for a resource URI argument without context - /// - /// This is a convenience wrapper around `complete_resource_argument` for - /// simple completion scenarios that don't require context awareness. - pub async fn complete_resource_simple( - &self, - uri_template: impl Into, - argument_name: impl Into, - current_value: impl Into, - ) -> Result, ServiceError> { - let completion = self - .complete_resource_argument(uri_template, argument_name, current_value, None) - .await?; - Ok(completion.values) - } -} - -impl RunningService -where - S: Service, -{ - /// Send one `tools/call` request without driving MRTR follow-up rounds. - pub async fn call_tool_once( - &self, - params: CallToolRequestParams, - ) -> Result { - self.peer.call_tool_once(params).await - } - - /// Send one `prompts/get` request without driving MRTR follow-up rounds. - pub async fn get_prompt_once( - &self, - params: GetPromptRequestParams, - ) -> Result { - self.peer.get_prompt_once(params).await - } - - /// Send one `resources/read` request without driving MRTR follow-up rounds. - pub async fn read_resource_once( - &self, - params: ReadResourceRequestParams, - ) -> Result { - self.peer.read_resource_once(params).await - } - - /// High-level `tools/call` helper that automatically fulfils SEP-2322 - /// `input_required` rounds through the local [`ClientHandler`](crate::ClientHandler) service. - /// - /// # Errors - /// - /// Returns [`ServiceError::InputRequiredRoundsExceeded`] if the peer does - /// not produce a final [`CallToolResult`] within the default MRTR round cap. - /// Other transport, protocol, and local input-handler errors are propagated. - pub async fn call_tool( - &self, - params: CallToolRequestParams, - ) -> Result { - self.call_tool_with_mrtr_max_rounds(params, DEFAULT_MRTR_MAX_ROUNDS) - .await - } - - /// Same as [`Self::call_tool`], with an explicit MRTR round cap. - /// - /// # Errors - /// - /// Returns [`ServiceError::InputRequiredRoundsExceeded`] once `max_rounds` - /// `input_required` responses have been driven without receiving a final - /// [`CallToolResult`]. Other transport, protocol, and local input-handler - /// errors are propagated. - pub async fn call_tool_with_mrtr_max_rounds( - &self, - mut params: CallToolRequestParams, - max_rounds: usize, - ) -> Result { - let mut state_only_rounds = 0usize; - for _round in 0..max_rounds { - match self.peer.call_tool_once(params.clone()).await? { - CallToolResponse::Complete(result) => return Ok(result), - CallToolResponse::InputRequired(result) => { - let (input_responses, request_state) = self - .prepare_input_required_retry(result, &mut state_only_rounds) - .await?; - params.input_responses = input_responses; - params.request_state = request_state; - } - // SEP-2663: this helper does not drive the task polling - // lifecycle. Callers that declare the tasks extension - // capability should use `call_tool_once` and poll `tasks/get`. - CallToolResponse::Task(_) => return Err(ServiceError::UnexpectedResponse), - } - } - Err(ServiceError::InputRequiredRoundsExceeded { max_rounds }) - } - - /// High-level `prompts/get` helper that automatically fulfils SEP-2322 - /// `input_required` rounds through the local [`ClientHandler`](crate::ClientHandler) service. - /// - /// # Errors - /// - /// Returns [`ServiceError::InputRequiredRoundsExceeded`] if the peer does - /// not produce a final [`GetPromptResult`] within the default MRTR round cap. - /// Other transport, protocol, and local input-handler errors are propagated. - pub async fn get_prompt( - &self, - params: GetPromptRequestParams, - ) -> Result { - self.get_prompt_with_mrtr_max_rounds(params, DEFAULT_MRTR_MAX_ROUNDS) - .await - } - - /// Same as [`Self::get_prompt`], with an explicit MRTR round cap. - /// - /// # Errors - /// - /// Returns [`ServiceError::InputRequiredRoundsExceeded`] once `max_rounds` - /// `input_required` responses have been driven without receiving a final - /// [`GetPromptResult`]. Other transport, protocol, and local input-handler - /// errors are propagated. - pub async fn get_prompt_with_mrtr_max_rounds( - &self, - mut params: GetPromptRequestParams, - max_rounds: usize, - ) -> Result { - let mut state_only_rounds = 0usize; - for _round in 0..max_rounds { - match self.peer.get_prompt_once(params.clone()).await? { - GetPromptResponse::Complete(result) => return Ok(result), - GetPromptResponse::InputRequired(result) => { - let (input_responses, request_state) = self - .prepare_input_required_retry(result, &mut state_only_rounds) - .await?; - params.input_responses = input_responses; - params.request_state = request_state; - } - } - } - Err(ServiceError::InputRequiredRoundsExceeded { max_rounds }) - } - - /// High-level `resources/read` helper that automatically fulfils SEP-2322 - /// `input_required` rounds through the local [`ClientHandler`](crate::ClientHandler) service. - /// - /// # Errors - /// - /// Returns [`ServiceError::InputRequiredRoundsExceeded`] if the peer does - /// not produce a final [`ReadResourceResult`] within the default MRTR round - /// cap. Other transport, protocol, and local input-handler errors are - /// propagated. - pub async fn read_resource( - &self, - params: ReadResourceRequestParams, - ) -> Result { - self.read_resource_with_mrtr_max_rounds(params, DEFAULT_MRTR_MAX_ROUNDS) - .await - } - - /// Same as [`Self::read_resource`], with an explicit MRTR round cap. - /// - /// # Errors - /// - /// Returns [`ServiceError::InputRequiredRoundsExceeded`] once `max_rounds` - /// `input_required` responses have been driven without receiving a final - /// [`ReadResourceResult`]. Other transport, protocol, and local input-handler - /// errors are propagated. - pub async fn read_resource_with_mrtr_max_rounds( - &self, - mut params: ReadResourceRequestParams, - max_rounds: usize, - ) -> Result { - let mut state_only_rounds = 0usize; - for _round in 0..max_rounds { - match self.peer.read_resource_once(params.clone()).await? { - ReadResourceResponse::Complete(result) => return Ok(result), - ReadResourceResponse::InputRequired(result) => { - let (input_responses, request_state) = self - .prepare_input_required_retry(result, &mut state_only_rounds) - .await?; - params.input_responses = input_responses; - params.request_state = request_state; - } - } - } - Err(ServiceError::InputRequiredRoundsExceeded { max_rounds }) - } - - async fn prepare_input_required_retry( - &self, - result: InputRequiredResult, - state_only_rounds: &mut usize, - ) -> Result<(Option, Option), ServiceError> { - let had_input_requests = result - .input_requests - .as_ref() - .is_some_and(|requests| !requests.is_empty()); - if !had_input_requests && result.request_state.is_none() { - return Err(ServiceError::UnexpectedResponse); - } - - let responses = self - .fulfill_input_requests(result.input_requests.unwrap_or_default()) - .await?; - if had_input_requests { - *state_only_rounds = 0; - } else { - Self::sleep_state_only_round(*state_only_rounds).await; - *state_only_rounds += 1; - } - - Ok(( - (!responses.is_empty()).then_some(responses), - result.request_state, - )) - } - - async fn fulfill_input_requests( - &self, - requests: crate::model::InputRequests, - ) -> Result { - let responses = futures::future::try_join_all( - requests - .into_iter() - .map(|(key, request)| self.fulfill_input_request(key, request)), - ) - .await?; - Ok(responses.into_iter().collect()) - } - - async fn fulfill_input_request( - &self, - key: String, - request: InputRequest, - ) -> Result<(String, serde_json::Value), ServiceError> { - let response = match request { - InputRequest::CreateMessage(request) => { - let mut request = ServerRequest::CreateMessageRequest(request); - let context = self.input_request_context(&key, &mut request); - match self - .service - .handle_request(request, context) - .await - .map_err(ServiceError::McpError)? - { - ClientResult::CreateMessageResult(result) => { - serde_json::to_value(result).map_err(Self::serde_to_service_error)? - } - _ => return Err(ServiceError::UnexpectedResponse), - } - } - InputRequest::Elicitation(request) => { - let mut request = ServerRequest::ElicitRequest(request); - let context = self.input_request_context(&key, &mut request); - match self - .service - .handle_request(request, context) - .await - .map_err(ServiceError::McpError)? - { - ClientResult::ElicitResult(result) => { - serde_json::to_value(result).map_err(Self::serde_to_service_error)? - } - _ => return Err(ServiceError::UnexpectedResponse), - } - } - InputRequest::ListRoots(request) => { - let mut request = ServerRequest::ListRootsRequest(request); - let context = self.input_request_context(&key, &mut request); - match self - .service - .handle_request(request, context) - .await - .map_err(ServiceError::McpError)? - { - ClientResult::ListRootsResult(result) => { - serde_json::to_value(result).map_err(Self::serde_to_service_error)? - } - _ => return Err(ServiceError::UnexpectedResponse), - } - } - }; - Ok((key, response)) - } - - fn input_request_context(&self, key: &str, request: &mut T) -> RequestContext - where - T: GetMeta + GetExtensions, - { - let mut meta = Default::default(); - let mut extensions = Default::default(); - std::mem::swap(&mut meta, request.get_meta_mut()); - std::mem::swap(&mut extensions, request.extensions_mut()); - RequestContext { - ct: tokio_util::sync::CancellationToken::new(), - id: NumberOrString::String(Arc::from(key)), - peer: self.peer.clone(), - meta, - extensions, - } - } - - async fn sleep_state_only_round(state_only_rounds: usize) { - let millis = (50u64.saturating_mul(1_u64 << state_only_rounds.min(3))).min(250); - tokio::time::sleep(Duration::from_millis(millis)).await; - } - - fn serde_to_service_error(error: serde_json::Error) -> ServiceError { - ServiceError::McpError(ErrorData::internal_error( - format!("failed to serialize MRTR input response: {error}"), - None, - )) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn disconnected_peer() -> Peer { - let (peer, receiver) = - Peer::::new(Arc::new(AtomicU32RequestIdProvider::default()), None); - drop(receiver); - peer - } - - fn tools_result(ttl_ms: Option, cache_scope: Option) -> ListToolsResult { - let mut result = ListToolsResult::with_all_items(Vec::new()); - result.ttl_ms = ttl_ms; - result.cache_scope = cache_scope; - result - } - - #[tokio::test] - async fn fresh_cached_page_is_served_without_transport_io() { - let peer = disconnected_peer(); - let params = None::; - let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); - let expected = tools_result(Some(5_000), Some(CacheScope::Public)); - peer.cache_response( - key, - ServerResult::ListToolsResult(expected.clone()), - expected.ttl_ms, - expected.cache_scope, - ) - .await; - - assert_eq!(peer.list_tools(params).await.unwrap(), expected); - } - - #[tokio::test] - async fn expired_entry_falls_through_to_the_transport() { - let peer = disconnected_peer(); - peer.set_response_cache_config( - ClientCacheConfig::default().with_serve_stale_on_error(false), - ) - .await; - let params = None::; - let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); - peer.cache_response( - key, - ServerResult::ListToolsResult(tools_result(Some(1), Some(CacheScope::Public))), - Some(1), - Some(CacheScope::Public), - ) - .await; - tokio::time::sleep(Duration::from_millis(5)).await; - - assert!(matches!( - peer.list_tools(params).await, - Err(ServiceError::TransportClosed) - )); - } - - #[tokio::test] - async fn private_entries_are_isolated_between_authorization_partitions() { - let peer = disconnected_peer(); - let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &None); - - peer.set_response_cache_config( - ClientCacheConfig::default().with_private_partition("auth-a"), - ) - .await; - peer.cache_response( - key.clone(), - ServerResult::ListToolsResult(tools_result(Some(5_000), Some(CacheScope::Private))), - Some(5_000), - Some(CacheScope::Private), - ) - .await; - assert!(peer.cached_response(&key).await.is_some()); - - // Switching to a different authorization context must not expose the - // first partition's private entry. - peer.set_response_cache_config( - ClientCacheConfig::default().with_private_partition("auth-b"), - ) - .await; - assert!(peer.cached_response(&key).await.is_none()); - } - - #[tokio::test] - async fn list_change_notification_discards_every_cached_page() { - let peer = disconnected_peer(); - for cursor in [None, Some("page-a".into()), Some("page-b".into())] { - let params = - cursor.map(|cursor| PaginatedRequestParams::default().with_cursor(Some(cursor))); - let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); - peer.cache_response( - key, - ServerResult::ListToolsResult(tools_result(Some(5_000), Some(CacheScope::Public))), - Some(5_000), - Some(CacheScope::Public), - ) - .await; - } - - peer.invalidate_tool_cache().await; - - for cursor in [None, Some("page-a".into()), Some("page-b".into())] { - let params = - cursor.map(|cursor| PaginatedRequestParams::default().with_cursor(Some(cursor))); - let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); - assert!(peer.cached_response(&key).await.is_none()); - } - } - - #[tokio::test] - async fn expired_entry_is_served_when_refetch_fails() { - let peer = disconnected_peer(); - let params = None::; - let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); - let expected = tools_result(Some(1), Some(CacheScope::Public)); - peer.cache_response( - key, - ServerResult::ListToolsResult(expected.clone()), - Some(1), - Some(CacheScope::Public), - ) - .await; - tokio::time::sleep(Duration::from_millis(5)).await; - - assert_eq!(peer.list_tools(params).await.unwrap(), expected); - } - - #[tokio::test] - async fn discover_serves_a_fresh_cached_response_without_transport_io() { - let peer = disconnected_peer(); - let meta = RequestMetaObject::default(); - let key = discover_cache_key(); - let expected = DiscoverResult::new(vec![ProtocolVersion::default()], Default::default()) - .with_server_info(crate::model::Implementation::from_build_env()) - .with_ttl_ms(5_000) - .with_cache_scope(CacheScope::Public); - peer.cache_response( - key, - ServerResult::DiscoverResult(expected.clone()), - Some(5_000), - Some(CacheScope::Public), - ) - .await; - - assert_eq!(peer.discover(meta).await.unwrap(), expected); - } -} - -#[cfg(test)] -mod sep2260_association_tests { - use super::*; - use crate::{ - model::{ - CreateMessageRequest, CreateMessageRequestParams, SamplingMessage, ServerCapabilities, - }, - service::PeerRequestAssociation, - }; - - fn sampling_request() -> ServerRequest { - ServerRequest::CreateMessageRequest(CreateMessageRequest::new( - CreateMessageRequestParams::new(vec![SamplingMessage::user_text("hi")], 16), - )) - } - - fn server_info(version: ProtocolVersion) -> ServerPeerInfo { - ServerPeerInfo::new(version, ServerCapabilities::default()) - } - - fn enforce( - info: &ServerPeerInfo, - association: PeerRequestAssociation, - ) -> Result<(), ErrorData> { - RoleClient::enforce_peer_request_association(&sampling_request(), Some(info), association) - } - - #[test] - fn strict_rejects_unassociated() { - let info = server_info(ProtocolVersion::V_2026_07_28); - let err = enforce(&info, PeerRequestAssociation::Unassociated).unwrap_err(); - assert_eq!(err.code, crate::model::ErrorCode::INVALID_PARAMS); - } - - #[test] - fn strict_accepts_associated() { - let info = server_info(ProtocolVersion::V_2026_07_28); - assert!(enforce(&info, PeerRequestAssociation::Associated).is_ok()); - } - - #[test] - fn strict_unknown_falls_back_to_coarse_check() { - let info = server_info(ProtocolVersion::V_2026_07_28); - assert!( - enforce( - &info, - PeerRequestAssociation::Unknown { - has_pending_outbound_request: true - } - ) - .is_ok() - ); - assert!( - enforce( - &info, - PeerRequestAssociation::Unknown { - has_pending_outbound_request: false - } - ) - .is_err() - ); - } - - #[test] - fn legacy_protocol_accepts_even_unassociated() { - let info = server_info(ProtocolVersion::V_2025_11_25); - assert!(enforce(&info, PeerRequestAssociation::Unassociated).is_ok()); - } -} diff --git a/vendor/rmcp/src/service/client/cache.rs b/vendor/rmcp/src/service/client/cache.rs deleted file mode 100644 index f7137ccd6..000000000 --- a/vendor/rmcp/src/service/client/cache.rs +++ /dev/null @@ -1,401 +0,0 @@ -use std::{ - collections::HashMap, - sync::Arc, - time::{Duration, Instant}, -}; - -use super::RoleClient; -use crate::{ - model::CacheScope, - service::{Peer, ServiceRole}, -}; - -/// Maximum server-provided cache TTL honoured by the client response cache. -pub const MAX_CLIENT_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60); - -/// Configuration for the built-in MCP client response cache. -/// -/// A cache is allocated per client [`Peer`]. Public responses may be reused -/// throughout that client connection. Private responses are additionally -/// partitioned by `private_partition`; changing the partition drops every -/// private entry while preserving public entries. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub struct ClientCacheConfig { - /// Enables cache reads and writes. - pub enabled: bool, - /// TTL used when a backwards-compatible server omits `ttlMs`. - /// - /// The default is zero, which leaves such responses immediately stale. - pub default_ttl: Duration, - /// Upper bound applied to both server-provided and default TTLs. - pub max_ttl: Duration, - /// Stable opaque identity for the current authorization context. - /// - /// A single-principal client may leave this unset because each client owns - /// its own in-memory store. Gateways or clients that change principals on an - /// existing connection should set this value and update it whenever the - /// authorization context changes. - pub private_partition: Option, - /// Maximum number of responses retained by the in-memory cache. - /// - /// A value of zero disables the size limit. - pub max_entries: usize, - /// Serves an expired cached response when a re-fetch fails. - /// - /// SEP-2549 permits clients to serve stale responses if errors occur while - /// re-fetching (for example, network issues or server downtime). When this - /// is enabled the client retains expired entries so it can fall back to the - /// last known response instead of surfacing the transport or server error. - /// A successful re-fetch always overwrites the stale entry. - pub serve_stale_on_error: bool, -} - -impl Default for ClientCacheConfig { - fn default() -> Self { - Self { - enabled: true, - default_ttl: Duration::ZERO, - max_ttl: MAX_CLIENT_CACHE_TTL, - private_partition: None, - max_entries: 512, - serve_stale_on_error: true, - } - } -} - -impl ClientCacheConfig { - /// Returns a configuration that disables all cache reads and writes. - pub fn disabled() -> Self { - Self { - enabled: false, - ..Self::default() - } - } - - /// Sets the TTL used when a response omits `ttlMs`. - pub fn with_default_ttl(mut self, default_ttl: Duration) -> Self { - self.default_ttl = default_ttl; - self - } - - /// Sets the maximum TTL the client will honour. - pub fn with_max_ttl(mut self, max_ttl: Duration) -> Self { - self.max_ttl = max_ttl; - self - } - - /// Sets the stable partition for private responses. - pub fn with_private_partition(mut self, partition: impl Into) -> Self { - self.private_partition = Some(partition.into()); - self - } - - /// Sets the maximum number of retained responses. - pub fn with_max_entries(mut self, max_entries: usize) -> Self { - self.max_entries = max_entries; - self - } - - /// Controls whether an expired response may be served when a re-fetch fails. - pub fn with_serve_stale_on_error(mut self, serve_stale_on_error: bool) -> Self { - self.serve_stale_on_error = serve_stale_on_error; - self - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -enum CachePartition { - Public, - Private(Arc), -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct CacheKey { - logical_key: String, - partition: CachePartition, -} - -#[derive(Debug, Clone)] -struct CachedPeerResponse { - value: T, - expires_at: Instant, - inserted_at: Instant, - scope: CacheScope, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct CacheGeneration(u64); - -#[derive(Debug)] -pub(crate) struct PeerResponseCacheState { - entries: HashMap>, - config: ClientCacheConfig, - generation: u64, -} - -impl Default for PeerResponseCacheState { - fn default() -> Self { - Self { - entries: HashMap::new(), - config: ClientCacheConfig::default(), - generation: 0, - } - } -} - -impl PeerResponseCacheState { - fn trim_to_limit(&mut self) { - while self.config.max_entries > 0 && self.entries.len() > self.config.max_entries { - let Some(oldest_key) = self - .entries - .iter() - .min_by_key(|(_, entry)| entry.inserted_at) - .map(|(key, _)| key.clone()) - else { - break; - }; - self.entries.remove(&oldest_key); - } - } -} - -pub(crate) type PeerResponseCache = Arc>>; - -impl Peer { - fn private_partition(config: &ClientCacheConfig) -> Arc { - Arc::from(config.private_partition.as_deref().unwrap_or("connection")) - } - - fn cache_key(logical_key: &str, partition: CachePartition) -> CacheKey { - CacheKey { - logical_key: logical_key.to_owned(), - partition, - } - } - - fn scoped_cache_key( - logical_key: &str, - scope: CacheScope, - config: &ClientCacheConfig, - ) -> CacheKey { - let partition = match scope { - CacheScope::Public => CachePartition::Public, - CacheScope::Private => CachePartition::Private(Self::private_partition(config)), - }; - Self::cache_key(logical_key, partition) - } - - /// Captures the cache generation before a request crosses the transport. - /// - /// Any configuration change, explicit clear, or notification invalidation - /// advances the generation. A response from an older generation is not - /// written back, preventing an in-flight stale response from undoing an - /// invalidation. - pub(crate) async fn capture_response_cache_generation(&self) -> CacheGeneration { - CacheGeneration(self.response_cache.read().await.generation) - } - - /// Returns a fresh cached response, preferring the current private partition - /// before the public partition. - /// - /// Expired entries are removed on access unless `serve_stale_on_error` is - /// enabled, in which case they are retained so a later re-fetch failure can - /// fall back to them via [`Peer::stale_cached_response`]. - pub(crate) async fn cached_response(&self, logical_key: &str) -> Option { - let now = Instant::now(); - let mut cache = self.response_cache.write().await; - if !cache.config.enabled { - return None; - } - let keep_stale = cache.config.serve_stale_on_error; - - let private_key = Self::cache_key( - logical_key, - CachePartition::Private(Self::private_partition(&cache.config)), - ); - let private_fresh = cache.entries.get(&private_key).and_then(|entry| { - (entry.expires_at > now && entry.scope == CacheScope::Private) - .then(|| entry.value.clone()) - }); - if let Some(value) = private_fresh { - return Some(value); - } - if !keep_stale { - cache.entries.remove(&private_key); - } - - let public_key = Self::cache_key(logical_key, CachePartition::Public); - let public_fresh = cache.entries.get(&public_key).and_then(|entry| { - (entry.expires_at > now && entry.scope == CacheScope::Public) - .then(|| entry.value.clone()) - }); - if let Some(value) = public_fresh { - return Some(value); - } - if !keep_stale { - cache.entries.remove(&public_key); - } - None - } - - /// Returns a cached response ignoring its TTL, for use as a fallback when a - /// re-fetch fails (SEP-2549 permits serving stale responses on error). - /// - /// Returns `None` when the cache is disabled or `serve_stale_on_error` is - /// turned off. The private partition is preferred over the public one. The - /// entry is left in place so repeated failures keep serving it until a - /// successful re-fetch overwrites it or a notification invalidates it. - pub(crate) async fn stale_cached_response(&self, logical_key: &str) -> Option { - let cache = self.response_cache.read().await; - if !cache.config.enabled || !cache.config.serve_stale_on_error { - return None; - } - - let private_key = Self::cache_key( - logical_key, - CachePartition::Private(Self::private_partition(&cache.config)), - ); - if let Some(entry) = cache.entries.get(&private_key) - && entry.scope == CacheScope::Private - { - return Some(entry.value.clone()); - } - - let public_key = Self::cache_key(logical_key, CachePartition::Public); - if let Some(entry) = cache.entries.get(&public_key) - && entry.scope == CacheScope::Public - { - return Some(entry.value.clone()); - } - None - } - - /// Stores a response when the configured effective TTL is positive. - /// - /// Missing `cacheScope` is treated as private. This is deliberately more - /// conservative than the model's backwards-compatible wire default and - /// prevents an older or malformed server response from becoming shareable. - pub(crate) async fn cache_response_with_generation( - &self, - logical_key: String, - value: R::PeerResp, - ttl_ms: Option, - cache_scope: Option, - generation: CacheGeneration, - ) { - let now = Instant::now(); - let mut cache = self.response_cache.write().await; - if !cache.config.enabled || generation.0 != cache.generation { - return; - } - - let requested_ttl = ttl_ms - .map(Duration::from_millis) - .unwrap_or(cache.config.default_ttl); - let ttl = requested_ttl.min(cache.config.max_ttl); - if ttl.is_zero() { - return; - } - let Some(expires_at) = now.checked_add(ttl) else { - return; - }; - let scope = cache_scope.unwrap_or(CacheScope::Private); - let target_key = Self::scoped_cache_key(&logical_key, scope, &cache.config); - let opposite_key = match scope { - CacheScope::Public => Self::cache_key( - &logical_key, - CachePartition::Private(Self::private_partition(&cache.config)), - ), - CacheScope::Private => Self::cache_key(&logical_key, CachePartition::Public), - }; - - if !cache.config.serve_stale_on_error { - cache.entries.retain(|_, entry| entry.expires_at > now); - } - cache.entries.remove(&opposite_key); - - if cache.config.max_entries > 0 - && !cache.entries.contains_key(&target_key) - && cache.entries.len() >= cache.config.max_entries - && let Some(oldest_key) = cache - .entries - .iter() - .min_by_key(|(_, entry)| entry.inserted_at) - .map(|(key, _)| key.clone()) - { - cache.entries.remove(&oldest_key); - } - - cache.entries.insert( - target_key, - CachedPeerResponse { - value, - expires_at, - inserted_at: now, - scope, - }, - ); - } - - #[cfg(test)] - pub(crate) async fn cache_response( - &self, - logical_key: String, - value: R::PeerResp, - ttl_ms: Option, - cache_scope: Option, - ) { - let generation = self.capture_response_cache_generation().await; - self.cache_response_with_generation(logical_key, value, ttl_ms, cache_scope, generation) - .await; - } - - pub(crate) async fn invalidate_cached_responses(&self, prefix: &str) { - let mut cache = self.response_cache.write().await; - cache.generation = cache.generation.wrapping_add(1); - cache - .entries - .retain(|key, _| !key.logical_key.starts_with(prefix)); - } -} - -impl Peer { - /// Replaces the response-cache configuration. - /// - /// Changing the private partition invalidates private entries from the old - /// authorization context. Disabling the cache clears every entry. Any - /// configuration change also suppresses writes from requests that were - /// already in flight under the previous configuration. - pub async fn set_response_cache_config(&self, config: ClientCacheConfig) { - let mut cache = self.response_cache.write().await; - let config_changed = cache.config != config; - let partition_changed = cache.config.private_partition != config.private_partition; - let ttl_policy_changed = cache.config.default_ttl != config.default_ttl - || cache.config.max_ttl != config.max_ttl; - cache.config = config; - if config_changed { - cache.generation = cache.generation.wrapping_add(1); - } - if !cache.config.enabled || ttl_policy_changed { - cache.entries.clear(); - } else if partition_changed { - cache - .entries - .retain(|_, entry| entry.scope == CacheScope::Public); - } - cache.trim_to_limit(); - } - - /// Returns a snapshot of the active response-cache configuration. - pub async fn response_cache_config(&self) -> ClientCacheConfig { - self.response_cache.read().await.config.clone() - } - - /// Clears every cached client response without changing the configuration. - pub async fn clear_response_cache(&self) { - let mut cache = self.response_cache.write().await; - cache.generation = cache.generation.wrapping_add(1); - cache.entries.clear(); - } -} diff --git a/vendor/rmcp/src/service/server.rs b/vendor/rmcp/src/service/server.rs deleted file mode 100644 index 8efbd34be..000000000 --- a/vendor/rmcp/src/service/server.rs +++ /dev/null @@ -1,1314 +0,0 @@ -// Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected. -#![expect(deprecated)] -#[cfg(feature = "elicitation")] -use std::collections::HashSet; -use std::{borrow::Cow, sync::Arc}; - -use thiserror::Error; -#[cfg(feature = "elicitation")] -use url::Url; - -use super::*; -#[cfg(feature = "elicitation")] -use crate::model::{ElicitRequest, ElicitRequestParams, ElicitResult, ElicitationAction}; -use crate::{ - model::{ - CancelledNotification, CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, - ClientNotification, ClientRequest, ClientResult, CreateMessageRequest, - CreateMessageRequestParams, CreateMessageResult, EmptyResult, ErrorData, ListRootsRequest, - ListRootsResult, LoggingMessageNotification, LoggingMessageNotificationParam, - ProgressNotification, ProgressNotificationParam, PromptListChangedNotification, - ProtocolVersion, ResourceListChangedNotification, ResourceUpdatedNotification, - ResourceUpdatedNotificationParam, ServerInfo, ServerNotification, ServerRequest, - ServerResult, SubscriptionFilter, SubscriptionsAcknowledgedNotification, - SubscriptionsAcknowledgedNotificationParams, ToolListChangedNotification, - }, - transport::DynamicTransportError, -}; - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct RoleServer; - -impl ServiceRole for RoleServer { - type Req = ServerRequest; - type Resp = ServerResult; - type Not = ServerNotification; - type PeerReq = ClientRequest; - type PeerResp = ClientResult; - type PeerNot = ClientNotification; - type Info = ServerInfo; - type PeerInfo = ClientInfo; - - type InitializeError = ServerInitializeError; - const IS_CLIENT: bool = false; - - fn peer_cancelled_params(notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> { - match notification { - ClientNotification::CancelledNotification(notification) => Some(¬ification.params), - _ => None, - } - } - - fn enforce_request_association( - request: &Self::Req, - peer_info: Option<&Self::PeerInfo>, - in_request_handler_scope: bool, - ) -> Result<(), ServiceError> { - let restricted = matches!( - request, - ServerRequest::CreateMessageRequest(_) - | ServerRequest::ListRootsRequest(_) - | ServerRequest::ElicitRequest(_) - ); - if !restricted { - return Ok(()); - } - let strict = - peer_info.is_some_and(|info| info.protocol_version >= ProtocolVersion::V_2026_07_28); - if strict && !in_request_handler_scope { - return Err(ServiceError::McpError(ErrorData::invalid_request( - "SEP-2260: server-to-client requests must be associated with an originating client request", - None, - ))); - } - Ok(()) - } -} - -/// It represents the error that may occur when serving the server. -/// -/// if you want to handle the error, you can use `serve_server_with_ct` or `serve_server` with `Result, ServerError>` -#[derive(Error, Debug)] -#[non_exhaustive] -pub enum ServerInitializeError { - #[error("expect initialized request, but received: {0:?}")] - ExpectedInitializeRequest(Option), - - #[error("connection closed: {0}")] - ConnectionClosed(String), - - #[error("unexpected initialize result: {0:?}")] - UnexpectedInitializeResponse(ServerResult), - - #[error("initialize failed: {0}")] - InitializeFailed(ErrorData), - - #[error("Send message error {error}, when {context}")] - TransportError { - error: DynamicTransportError, - context: Cow<'static, str>, - }, - - #[error("Cancelled")] - Cancelled, -} - -impl ServerInitializeError { - pub fn transport + 'static>( - error: T::Error, - context: impl Into>, - ) -> Self { - Self::TransportError { - error: DynamicTransportError::new::(error), - context: context.into(), - } - } -} -pub type ClientSink = Peer; - -/// Failure to send a notification through a [`SubscriptionSink`]. -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum SubscriptionSendError { - #[error("subscription is no longer active")] - SubscriptionClosed, - #[error("notification is not allowed on a subscription stream: {0}")] - UnsupportedNotification(&'static str), - #[error("notification was not accepted for this subscription: {0}")] - NotificationNotAccepted(&'static str), - #[error(transparent)] - Service(#[from] ServiceError), -} - -/// A server-side notification sink scoped to one `subscriptions/listen` request. -/// -/// The sink applies the accepted filter and adds the subscription request ID to -/// every notification it sends. -#[derive(Debug, Clone)] -pub struct SubscriptionSink { - peer: Peer, - id: RequestId, - accepted: Arc, - active: CancellationToken, -} - -impl SubscriptionSink { - fn new( - peer: Peer, - id: RequestId, - accepted: Arc, - active: CancellationToken, - ) -> Self { - Self { - peer, - id, - accepted, - active, - } - } - - /// Return the JSON-RPC ID of the originating listen request. - pub fn id(&self) -> &RequestId { - &self.id - } - - /// Return the filter accepted for this subscription. - pub fn accepted(&self) -> &SubscriptionFilter { - self.accepted.as_ref() - } - - /// Send an allowed change notification with subscription metadata attached. - /// - /// # Errors - /// - /// Returns [`SubscriptionSendError::SubscriptionClosed`] after the request - /// ends, a filter error for disallowed notifications, or a transport error. - pub async fn send( - &self, - mut notification: ServerNotification, - ) -> Result<(), SubscriptionSendError> { - if self.active.is_cancelled() { - return Err(SubscriptionSendError::SubscriptionClosed); - } - match ¬ification { - ServerNotification::ToolListChangedNotification(_) => { - if self.accepted.tools_list_changed != Some(true) { - return Err(SubscriptionSendError::NotificationNotAccepted( - "notifications/tools/list_changed", - )); - } - } - ServerNotification::PromptListChangedNotification(_) => { - if self.accepted.prompts_list_changed != Some(true) { - return Err(SubscriptionSendError::NotificationNotAccepted( - "notifications/prompts/list_changed", - )); - } - } - ServerNotification::ResourceListChangedNotification(_) => { - if self.accepted.resources_list_changed != Some(true) { - return Err(SubscriptionSendError::NotificationNotAccepted( - "notifications/resources/list_changed", - )); - } - } - ServerNotification::ResourceUpdatedNotification(update) => { - let accepted = self - .accepted - .resource_subscriptions - .as_ref() - .is_some_and(|uris| uris.contains(&update.params.uri)); - if !accepted { - return Err(SubscriptionSendError::NotificationNotAccepted( - "notifications/resources/updated", - )); - } - } - ServerNotification::SubscriptionsAcknowledgedNotification(_) => { - return Err(SubscriptionSendError::UnsupportedNotification( - "notifications/subscriptions/acknowledged", - )); - } - ServerNotification::CancelledNotification(_) => { - return Err(SubscriptionSendError::UnsupportedNotification( - "notifications/cancelled", - )); - } - ServerNotification::ProgressNotification(_) => { - return Err(SubscriptionSendError::UnsupportedNotification( - "notifications/progress", - )); - } - ServerNotification::LoggingMessageNotification(_) => { - return Err(SubscriptionSendError::UnsupportedNotification( - "notifications/message", - )); - } - // SEP-2663 task status notifications are not yet routable through - // `subscriptions/listen`: `SubscriptionFilter` has no `taskIds` - // field yet (the upstream conformance check for this flow is also - // still skipped, pending the subscriptions/listen rewrite). - // Clients currently observe task state by polling `tasks/get`. - ServerNotification::TaskStatusNotification(_) => { - return Err(SubscriptionSendError::UnsupportedNotification( - "notifications/tasks", - )); - } - ServerNotification::CustomNotification(_) => { - return Err(SubscriptionSendError::UnsupportedNotification( - "custom notification", - )); - } - } - - notification - .get_meta_mut() - .set_subscription_id(self.id.clone()); - self.peer.send_notification(notification).await?; - Ok(()) - } - - /// Send `notifications/tools/list_changed`. - /// - /// # Errors - /// - /// See [`Self::send`]. - pub async fn notify_tool_list_changed(&self) -> Result<(), SubscriptionSendError> { - self.send(ServerNotification::ToolListChangedNotification( - ToolListChangedNotification { - method: Default::default(), - extensions: Default::default(), - }, - )) - .await - } - - /// Send `notifications/prompts/list_changed`. - /// - /// # Errors - /// - /// See [`Self::send`]. - pub async fn notify_prompt_list_changed(&self) -> Result<(), SubscriptionSendError> { - self.send(ServerNotification::PromptListChangedNotification( - PromptListChangedNotification { - method: Default::default(), - extensions: Default::default(), - }, - )) - .await - } - - /// Send `notifications/resources/list_changed`. - /// - /// # Errors - /// - /// See [`Self::send`]. - pub async fn notify_resource_list_changed(&self) -> Result<(), SubscriptionSendError> { - self.send(ServerNotification::ResourceListChangedNotification( - ResourceListChangedNotification { - method: Default::default(), - extensions: Default::default(), - }, - )) - .await - } - - /// Send `notifications/resources/updated` for an accepted URI. - /// - /// # Errors - /// - /// See [`Self::send`]. - pub async fn notify_resource_updated( - &self, - uri: impl Into, - ) -> Result<(), SubscriptionSendError> { - self.send(ServerNotification::ResourceUpdatedNotification( - ResourceUpdatedNotification::new(ResourceUpdatedNotificationParam::new(uri)), - )) - .await - } -} - -/// Context for one established server-side notification subscription. -/// -/// The acknowledgment has already been sent before this context is handed to -/// [`ServerHandler::listen`](crate::ServerHandler::listen). -#[derive(Debug)] -pub struct SubscriptionContext { - request: RequestContext, - requested: SubscriptionFilter, - accepted: Arc, - sink: SubscriptionSink, - _active_guard: DropGuard, -} - -impl SubscriptionContext { - pub(crate) async fn establish( - request: RequestContext, - requested: SubscriptionFilter, - accepted: SubscriptionFilter, - ) -> Result { - let active = request.ct.child_token(); - let accepted = Arc::new(accepted); - let sink = SubscriptionSink::new( - request.peer.clone(), - request.id.clone(), - accepted.clone(), - active.clone(), - ); - let mut acknowledgment = SubscriptionsAcknowledgedNotification::new( - SubscriptionsAcknowledgedNotificationParams::new(accepted.as_ref().clone()), - ); - let mut meta = NotificationMetaObject::new(); - meta.set_subscription_id(request.id.clone()); - acknowledgment.extensions.insert(meta); - request - .peer - .send_notification(ServerNotification::SubscriptionsAcknowledgedNotification( - acknowledgment, - )) - .await - .map_err(|error| { - ErrorData::internal_error( - format!("failed to acknowledge subscription: {error}"), - None, - ) - })?; - Ok(Self { - request, - requested, - accepted, - sink, - _active_guard: active.drop_guard(), - }) - } - - /// Return the filter requested by the client. - pub fn requested(&self) -> &SubscriptionFilter { - &self.requested - } - - /// Return the subset accepted by the server. - pub fn accepted(&self) -> &SubscriptionFilter { - self.accepted.as_ref() - } - - /// Return a cloneable, filter-enforcing notification sink. - pub fn sink(&self) -> &SubscriptionSink { - &self.sink - } - - /// Wait until the subscription request is cancelled. - pub async fn cancelled(&self) { - self.request.ct.cancelled().await; - } - - /// Access the underlying request context. - pub fn request_context(&self) -> &RequestContext { - &self.request - } -} - -impl> ServiceExt for S { - fn serve_with_ct( - self, - transport: T, - ct: CancellationToken, - ) -> impl Future, ServerInitializeError>> - + MaybeSendFuture - where - T: IntoTransport, - E: std::error::Error + Send + Sync + 'static, - Self: Sized, - { - serve_server_with_ct(self, transport, ct) - } -} - -pub async fn serve_server( - service: S, - transport: T, -) -> Result, ServerInitializeError> -where - S: Service, - T: IntoTransport, - E: std::error::Error + Send + Sync + 'static, -{ - serve_server_with_ct(service, transport, CancellationToken::new()).await -} - -/// Helper function to get the next message from the stream -async fn expect_next_message( - transport: &mut T, - context: &str, -) -> Result -where - T: Transport, -{ - transport - .receive() - .await - .ok_or_else(|| ServerInitializeError::ConnectionClosed(context.to_string())) -} - -pub async fn serve_server_with_ct( - service: S, - transport: T, - ct: CancellationToken, -) -> Result, ServerInitializeError> -where - S: Service, - T: IntoTransport, - E: std::error::Error + Send + Sync + 'static, -{ - tokio::select! { - result = serve_server_with_ct_inner(service, transport.into_transport(), ct.clone()) => { result } - _ = ct.cancelled() => { - Err(ServerInitializeError::Cancelled) - } - } -} - -/// Echoes the client-requested version if the server supports it; otherwise -/// returns `server_fallback`. -/// -/// `server_supported` comes from [`Service::supported_protocol_versions`], so a -/// server that narrows that list is never made to answer `initialize` with a -/// version it cannot serve. -pub(crate) fn negotiate_protocol_version( - client_requested: &ProtocolVersion, - server_fallback: ProtocolVersion, - server_supported: &[ProtocolVersion], -) -> ProtocolVersion { - if server_supported.contains(client_requested) { - client_requested.clone() - } else { - tracing::warn!( - client_requested = %client_requested, - server_fallback = %server_fallback, - "client requested unsupported protocol version; falling back to server default" - ); - server_fallback - } -} - -async fn serve_server_with_ct_inner( - service: S, - transport: T, - ct: CancellationToken, -) -> Result, ServerInitializeError> -where - S: Service, - T: Transport + 'static, -{ - let mut transport = transport.into_transport(); - let id_provider = >::default(); - - // Get initialize request; the MCP spec permits ping before initialize. - // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization - let (request, id) = loop { - let msg = expect_next_message(&mut transport, "initialize request").await?; - match msg { - ClientJsonRpcMessage::Request(req) - if matches!(req.request, ClientRequest::PingRequest(_)) => - { - transport - .send(ServerJsonRpcMessage::response( - ServerResult::EmptyResult(EmptyResult {}), - req.id, - )) - .await - .map_err(|error| { - ServerInitializeError::transport::( - error, - "sending pre-init ping response", - ) - })?; - } - ClientJsonRpcMessage::Request(req) => break (req.request, req.id), - other => { - return Err(ServerInitializeError::ExpectedInitializeRequest(Some( - other, - ))); - } - } - }; - - let initialize_request = match request { - ClientRequest::InitializeRequest(request) => request, - mut request => { - if !request - .get_meta() - .missing_required_keys(&ProtocolVersion::V_2026_07_28) - .is_empty() - { - return Err(ServerInitializeError::ExpectedInitializeRequest(Some( - ClientJsonRpcMessage::request(request, id), - ))); - } - let (peer, peer_rx) = Peer::new(id_provider, None); - peer.require_request_metadata(); - let context = RequestContext { - ct: ct.child_token(), - id: id.clone(), - meta: std::mem::take(request.get_meta_mut()), - extensions: std::mem::take(request.extensions_mut()), - peer: peer.clone(), - }; - let response = match service.handle_request(request, context).await { - Ok(result) => ServerJsonRpcMessage::response(result, id), - Err(error) => ServerJsonRpcMessage::error(error, Some(id)), - }; - transport.send(response).await.map_err(|error| { - ServerInitializeError::transport::(error, "sending negotiated request response") - })?; - return Ok(serve_inner(service, transport, peer, peer_rx, ct)); - } - }; - let requested_protocol_version = initialize_request.params.protocol_version.clone(); - let mut negotiated_peer_info = initialize_request.params.clone(); - let (peer, peer_rx) = Peer::new(id_provider, Some(negotiated_peer_info.clone())); - let request = ClientRequest::InitializeRequest(initialize_request); - let context = RequestContext { - ct: ct.child_token(), - id: id.clone(), - meta: request.get_meta().clone(), - extensions: request.extensions().clone(), - peer: peer.clone(), - }; - // Send initialize response - let init_response = service.handle_request(request, context).await; - let mut init_response = match init_response { - Ok(ServerResult::InitializeResult(init_response)) => init_response, - Ok(result) => { - return Err(ServerInitializeError::UnexpectedInitializeResponse(result)); - } - Err(e) => { - transport - .send(ServerJsonRpcMessage::error(e.clone(), Some(id))) - .await - .map_err(|error| { - ServerInitializeError::transport::(error, "sending error response") - })?; - return Err(ServerInitializeError::InitializeFailed(e)); - } - }; - init_response.protocol_version = negotiate_protocol_version( - &requested_protocol_version, - init_response.protocol_version, - &service.supported_protocol_versions(), - ); - // Update peer_info so context.protocol_version() reflects the negotiated - // version in all subsequent request handlers. - negotiated_peer_info.protocol_version = init_response.protocol_version.clone(); - peer.set_peer_info(negotiated_peer_info); - transport - .send(ServerJsonRpcMessage::response( - ServerResult::InitializeResult(init_response), - id, - )) - .await - .map_err(|error| { - ServerInitializeError::transport::(error, "sending initialize response") - })?; - - // Enter the main service loop immediately after sending InitializeResult. - // The initialized notification will be handled as a regular notification by serve_inner. - // This matches the TypeScript SDK behavior: no init gate, no waiting for initialized. - // Streamable HTTP has no ordering guarantee between POSTs, and the MCP spec uses - // SHOULD NOT (not MUST NOT) for pre-initialized messages, so any request arriving - // before initialized is processed normally. - Ok(serve_inner(service, transport, peer, peer_rx, ct)) -} - -macro_rules! method { - ($(#[$meta:meta])* peer_req $method:ident $Req:ident() => $Resp: ident ) => { - $(#[$meta])* - pub async fn $method(&self) -> Result<$Resp, ServiceError> { - let result = self - .send_request(ServerRequest::$Req($Req { - method: Default::default(), - extensions: Default::default(), - })) - .await?; - match result { - ClientResult::$Resp(result) => Ok(result), - _ => Err(ServiceError::UnexpectedResponse), - } - } - }; - ($(#[$meta:meta])* peer_req $method:ident $Req:ident($Param: ident) => $Resp: ident ) => { - $(#[$meta])* - pub async fn $method(&self, params: $Param) -> Result<$Resp, ServiceError> { - let result = self - .send_request(ServerRequest::$Req($Req { - method: Default::default(), - params, - extensions: Default::default(), - })) - .await?; - match result { - ClientResult::$Resp(result) => Ok(result), - _ => Err(ServiceError::UnexpectedResponse), - } - } - }; - ($(#[$meta:meta])* peer_req $method:ident $Req:ident($Param: ident)) => { - $(#[$meta])* - pub fn $method( - &self, - params: $Param, - ) -> impl Future> + Send + '_ { - async move { - let result = self - .send_request(ServerRequest::$Req($Req { - method: Default::default(), - params, - })) - .await?; - match result { - ClientResult::EmptyResult(_) => Ok(()), - _ => Err(ServiceError::UnexpectedResponse), - } - } - } - }; - - ($(#[$meta:meta])* peer_not $method:ident $Not:ident($Param: ident)) => { - $(#[$meta])* - pub async fn $method(&self, params: $Param) -> Result<(), ServiceError> { - self.send_notification(ServerNotification::$Not($Not { - method: Default::default(), - params, - extensions: Default::default(), - })) - .await?; - Ok(()) - } - }; - ($(#[$meta:meta])* peer_not $method:ident $Not:ident) => { - $(#[$meta])* - pub async fn $method(&self) -> Result<(), ServiceError> { - self.send_notification(ServerNotification::$Not($Not { - method: Default::default(), - extensions: Default::default(), - })) - .await?; - Ok(()) - } - }; - - // Timeout-only variants (base method should be created separately with peer_req) - ($(#[$meta:meta])* peer_req_with_timeout $method_with_timeout:ident $Req:ident() => $Resp: ident) => { - $(#[$meta])* - pub async fn $method_with_timeout( - &self, - timeout: Option, - ) -> Result<$Resp, ServiceError> { - let request = ServerRequest::$Req($Req { - method: Default::default(), - extensions: Default::default(), - }); - let options = crate::service::PeerRequestOptions { - timeout, - meta: None, - reset_timeout_on_progress: false, - max_total_timeout: None, - }; - let result = self - .send_request_with_option(request, options) - .await? - .await_response() - .await?; - match result { - ClientResult::$Resp(result) => Ok(result), - _ => Err(ServiceError::UnexpectedResponse), - } - } - }; - - ($(#[$meta:meta])* peer_req_with_timeout $method_with_timeout:ident $Req:ident($Param: ident) => $Resp: ident) => { - $(#[$meta])* - pub async fn $method_with_timeout( - &self, - params: $Param, - timeout: Option, - ) -> Result<$Resp, ServiceError> { - let request = ServerRequest::$Req($Req { - method: Default::default(), - params, - extensions: Default::default(), - }); - let options = crate::service::PeerRequestOptions { - timeout, - meta: None, - reset_timeout_on_progress: false, - max_total_timeout: None, - }; - let result = self - .send_request_with_option(request, options) - .await? - .await_response() - .await?; - match result { - ClientResult::$Resp(result) => Ok(result), - _ => Err(ServiceError::UnexpectedResponse), - } - } - }; -} - -impl Peer { - /// Check if the client supports sampling tools capability. - pub fn supports_sampling_tools(&self) -> bool { - if let Some(client_info) = self.peer_info() { - client_info - .capabilities - .sampling - .as_ref() - .and_then(|s| s.tools.as_ref()) - .is_some() - } else { - false - } - } - - /// # SEP-2260: request association - /// - /// From protocol version `2026-07-28` this must be issued while handling a - /// client request; see [`OriginatingRequestId`]. - #[deprecated( - since = "1.8.0", - note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" - )] - pub async fn create_message( - &self, - params: CreateMessageRequestParams, - ) -> Result { - // MUST throw error when tools/toolChoice provided without capability - if (params.tools.is_some() || params.tool_choice.is_some()) - && !self.supports_sampling_tools() - { - return Err(ServiceError::McpError(ErrorData::invalid_params( - "tools or toolChoice provided but client does not support sampling tools capability", - None, - ))); - } - // Validate message structure - params - .validate() - .map_err(|e| ServiceError::McpError(ErrorData::invalid_params(e, None)))?; - let result = self - .send_request(ServerRequest::CreateMessageRequest(CreateMessageRequest { - method: Default::default(), - params, - extensions: Default::default(), - })) - .await?; - match result { - ClientResult::CreateMessageResult(result) => Ok(*result), - _ => Err(ServiceError::UnexpectedResponse), - } - } - method!( - /// # SEP-2260: request association - /// - /// From protocol version `2026-07-28` this must be issued while handling a - /// client request; see [`OriginatingRequestId`]. - #[deprecated( - since = "1.8.0", - note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" - )] - peer_req list_roots ListRootsRequest() => ListRootsResult - ); - #[cfg(feature = "elicitation")] - method!( - /// # SEP-2260: request association - /// - /// From protocol version `2026-07-28` this must be issued while handling a - /// client request; see [`OriginatingRequestId`]. - peer_req create_elicitation ElicitRequest(ElicitRequestParams) => ElicitResult - ); - #[cfg(feature = "elicitation")] - method!( - /// # SEP-2260: request association - /// - /// From protocol version `2026-07-28` this must be issued while handling a - /// client request; see [`OriginatingRequestId`]. - peer_req_with_timeout create_elicitation_with_timeout ElicitRequest(ElicitRequestParams) => ElicitResult - ); - - method!(peer_not notify_cancelled CancelledNotification(CancelledNotificationParam)); - method!(peer_not notify_progress ProgressNotification(ProgressNotificationParam)); - method!( - #[deprecated( - since = "1.8.0", - note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" - )] - peer_not notify_logging_message LoggingMessageNotification(LoggingMessageNotificationParam) - ); - method!(peer_not notify_resource_updated ResourceUpdatedNotification(ResourceUpdatedNotificationParam)); - method!(peer_not notify_resource_list_changed ResourceListChangedNotification); - method!(peer_not notify_tool_list_changed ToolListChangedNotification); - method!(peer_not notify_prompt_list_changed PromptListChangedNotification); -} - -// ============================================================================= -// ELICITATION CONVENIENCE METHODS -// These methods are specific to server role and provide typed elicitation functionality -// ============================================================================= - -/// Errors that can occur during typed elicitation operations -#[cfg(feature = "elicitation")] -#[derive(Error, Debug)] -#[non_exhaustive] -pub enum ElicitationError { - /// The elicitation request failed at the service level - #[error("Service error: {0}")] - Service(#[from] ServiceError), - - /// User explicitly declined to provide the requested information - /// This indicates a conscious decision by the user to reject the request - /// (e.g., clicked "Reject", "Decline", "No", etc.) - #[error("User explicitly declined the request")] - UserDeclined, - - /// User dismissed the request without making an explicit choice - /// This indicates the user cancelled without explicitly declining - /// (e.g., closed dialog, clicked outside, pressed Escape, etc.) - #[error("User cancelled/dismissed the request")] - UserCancelled, - - /// The response data could not be parsed into the requested type - #[error("Failed to parse response data: {error}\nReceived data: {data}")] - ParseError { - error: serde_json::Error, - data: serde_json::Value, - }, - - /// No response content was provided by the user - #[error("No response content provided")] - NoContent, - - /// Client does not support elicitation capability - #[error("Client does not support elicitation - capability not declared during initialization")] - CapabilityNotSupported, -} - -/// Marker trait to ensure that elicitation types generate object-type JSON schemas. -/// -/// This trait provides compile-time safety to ensure that types used with -/// `elicit()` methods will generate JSON schemas of type "object", which -/// aligns with MCP client expectations for structured data input. -/// -/// # Type Safety Rationale -/// -/// MCP clients typically expect JSON objects for elicitation schemas to -/// provide structured forms and validation. This trait prevents common -/// mistakes like: -/// -/// ```compile_fail -/// // These would not compile due to missing ElicitationSafe bound: -/// let name: String = server.elicit("Enter name").await?; // Primitive -/// let items: Vec = server.elicit("Enter items").await?; // Array -/// ``` -#[cfg(feature = "elicitation")] -pub trait ElicitationSafe: schemars::JsonSchema {} - -/// Macro to mark types as safe for elicitation by verifying they generate object schemas. -/// -/// This macro automatically implements the `ElicitationSafe` trait for struct types -/// that should be used with `elicit()` methods. -/// -/// # Example -/// -/// ```rust -/// use rmcp::elicit_safe; -/// use schemars::JsonSchema; -/// use serde::{Deserialize, Serialize}; -/// -/// #[derive(Serialize, Deserialize, JsonSchema)] -/// struct UserProfile { -/// name: String, -/// email: String, -/// } -/// -/// elicit_safe!(UserProfile); -/// -/// // Now safe to use in async context: -/// // let profile: UserProfile = server.elicit("Enter profile").await?; -/// ``` -#[cfg(feature = "elicitation")] -#[macro_export] -macro_rules! elicit_safe { - ($($t:ty),* $(,)?) => { - $( - impl $crate::service::ElicitationSafe for $t {} - )* - }; -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[non_exhaustive] -pub enum ElicitationMode { - Form, - Url, -} - -#[cfg(feature = "elicitation")] -impl Peer { - /// Check if the client supports elicitation capability - /// - /// Returns true if the client declared elicitation capability during initialization, - /// false otherwise. According to MCP 2025-06-18 specification, clients that support - /// elicitation MUST declare the capability during initialization. - pub fn supported_elicitation_modes(&self) -> HashSet { - if let Some(client_info) = self.peer_info() { - if let Some(elicit_capability) = &client_info.capabilities.elicitation { - let mut modes = HashSet::new(); - // Backward compatibility: if neither form nor url is specified, assume form - if elicit_capability.form.is_none() && elicit_capability.url.is_none() { - modes.insert(ElicitationMode::Form); - } else { - if elicit_capability.form.is_some() { - modes.insert(ElicitationMode::Form); - } - if elicit_capability.url.is_some() { - modes.insert(ElicitationMode::Url); - } - } - modes - } else { - HashSet::new() - } - } else { - HashSet::new() - } - } - - /// Request typed data from the user with automatic schema generation. - /// - /// This method automatically generates the JSON schema from the Rust type using `schemars`, - /// eliminating the need to manually create schemas. The response is automatically parsed - /// into the requested type. - /// - /// **Requires the `elicitation` feature to be enabled.** - /// - /// # Type Requirements - /// The type `T` must implement: - /// - `schemars::JsonSchema` - for automatic schema generation - /// - `serde::Deserialize` - for parsing the response - /// - /// # Arguments - /// * `message` - The prompt message for the user - /// - /// # Returns - /// * `Ok(Some(data))` if user provided valid data that matches type T - /// * `Err(ElicitationError::UserDeclined)` if user explicitly declined the request - /// * `Err(ElicitationError::UserCancelled)` if user cancelled/dismissed the request - /// * `Err(ElicitationError::ParseError { .. })` if response data couldn't be parsed into type T - /// * `Err(ElicitationError::NoContent)` if no response content was provided - /// * `Err(ElicitationError::Service(_))` if the underlying service call failed - /// - /// # Example - /// - /// Add to your `Cargo.toml`: - /// ```toml - /// [dependencies] - /// rmcp = { version = "0.3", features = ["elicitation"] } - /// serde = { version = "1.0", features = ["derive"] } - /// schemars = "1.0" - /// ``` - /// - /// ```rust,no_run - /// # use rmcp::*; - /// # use rmcp::service::ElicitationError; - /// # use serde::{Deserialize, Serialize}; - /// # use schemars::JsonSchema; - /// # - /// #[derive(Debug, Serialize, Deserialize, JsonSchema)] - /// struct UserProfile { - /// #[schemars(description = "Full name")] - /// name: String, - /// #[schemars(description = "Email address")] - /// email: String, - /// #[schemars(description = "Age")] - /// age: u8, - /// } - /// - /// // Mark as safe for elicitation (generates object schema) - /// rmcp::elicit_safe!(UserProfile); - /// - /// # async fn example(peer: Peer) -> Result<(), Box> { - /// match peer.elicit::("Please enter your profile information").await { - /// Ok(Some(profile)) => { - /// println!("Name: {}, Email: {}, Age: {}", profile.name, profile.email, profile.age); - /// } - /// Ok(None) => { - /// println!("User provided no content"); - /// } - /// Err(ElicitationError::UserDeclined) => { - /// println!("User explicitly declined to provide information"); - /// // Handle explicit decline - perhaps offer alternatives - /// } - /// Err(ElicitationError::UserCancelled) => { - /// println!("User cancelled the request"); - /// // Handle cancellation - perhaps prompt again later - /// } - /// Err(ElicitationError::ParseError { error, data }) => { - /// println!("Failed to parse response: {}\nData: {}", error, data); - /// } - /// Err(e) => return Err(e.into()), - /// } - /// # Ok(()) - /// # } - /// ``` - /// - /// # SEP-2260: request association - /// - /// From protocol version `2026-07-28` this must be issued while handling a - /// client request; see [`OriginatingRequestId`]. - #[cfg(all(feature = "schemars", feature = "elicitation"))] - pub async fn elicit(&self, message: impl Into) -> Result, ElicitationError> - where - T: ElicitationSafe + for<'de> serde::Deserialize<'de>, - { - self.elicit_with_timeout(message, None).await - } - - /// Request typed data from the user with custom timeout. - /// - /// Same as `elicit()` but allows specifying a custom timeout for the request. - /// If the user doesn't respond within the timeout, the request will be cancelled. - /// - /// # Arguments - /// * `message` - The prompt message for the user - /// * `timeout` - Optional timeout duration. If None, uses default timeout behavior - /// - /// # Returns - /// Same as `elicit()` but may also return `ServiceError::Timeout` if timeout expires - /// - /// # Example - /// ```rust,no_run - /// # use rmcp::*; - /// # use rmcp::service::ElicitationError; - /// # use serde::{Deserialize, Serialize}; - /// # use schemars::JsonSchema; - /// # use std::time::Duration; - /// # - /// #[derive(Debug, Serialize, Deserialize, JsonSchema)] - /// struct QuickResponse { - /// answer: String, - /// } - /// - /// // Mark as safe for elicitation - /// rmcp::elicit_safe!(QuickResponse); - /// - /// # async fn example(peer: Peer) -> Result<(), Box> { - /// // Give user 30 seconds to respond - /// let timeout = Some(Duration::from_secs(30)); - /// match peer.elicit_with_timeout::( - /// "Quick question - what's your answer?", - /// timeout - /// ).await { - /// Ok(Some(response)) => println!("Got answer: {}", response.answer), - /// Ok(None) => println!("User provided no content"), - /// Err(ElicitationError::UserDeclined) => { - /// println!("User explicitly declined"); - /// // Handle explicit decline - /// } - /// Err(ElicitationError::UserCancelled) => { - /// println!("User cancelled/dismissed"); - /// // Handle cancellation - /// } - /// Err(ElicitationError::Service(ServiceError::Timeout { .. })) => { - /// println!("User didn't respond in time"); - /// } - /// Err(e) => return Err(e.into()), - /// } - /// # Ok(()) - /// # } - /// ``` - /// - /// # SEP-2260: request association - /// - /// From protocol version `2026-07-28` this must be issued while handling a - /// client request; see [`OriginatingRequestId`]. - #[cfg(all(feature = "schemars", feature = "elicitation"))] - pub async fn elicit_with_timeout( - &self, - message: impl Into, - timeout: Option, - ) -> Result, ElicitationError> - where - T: ElicitationSafe + for<'de> serde::Deserialize<'de>, - { - // Check if client supports form elicitation capability - if !self - .supported_elicitation_modes() - .contains(&ElicitationMode::Form) - { - return Err(ElicitationError::CapabilityNotSupported); - } - - // Generate schema automatically from type - let schema = crate::model::ElicitationSchema::from_type::().map_err(|e| { - ElicitationError::Service(ServiceError::McpError(crate::ErrorData::invalid_params( - format!( - "Invalid schema for type {}: {}", - std::any::type_name::(), - e - ), - None, - ))) - })?; - - let response = self - .create_elicitation_with_timeout( - ElicitRequestParams::FormElicitationParams { - meta: None, - message: message.into(), - requested_schema: schema, - }, - timeout, - ) - .await?; - - match response.action { - crate::model::ElicitationAction::Accept => { - if let Some(value) = response.content { - match serde_json::from_value::(value.clone()) { - Ok(parsed) => Ok(Some(parsed)), - Err(error) => Err(ElicitationError::ParseError { error, data: value }), - } - } else { - Err(ElicitationError::NoContent) - } - } - crate::model::ElicitationAction::Decline => Err(ElicitationError::UserDeclined), - crate::model::ElicitationAction::Cancel => Err(ElicitationError::UserCancelled), - } - } - - /// Request the user to visit a URL and confirm completion. - /// - /// This method sends a URL elicitation request to the client, prompting the user - /// to visit the specified URL and confirm completion. It returns the user's action - /// (accept/decline/cancel) without any additional data. - /// **Requires the `elicitation` feature to be enabled.** - /// - /// # Arguments - /// * `message` - The prompt message for the user - /// * `url` - The URL the user is requested to visit - /// * `elicitation_id` - A unique identifier for this elicitation request - /// # Returns - /// * `Ok(action)` indicating the user's response action - /// * `Err(ElicitationError::CapabilityNotSupported)` if client does not support elicitation via URL - /// * `Err(ElicitationError::Service(_))` if the underlying service call failed - /// # Example - /// ```rust,no_run - /// # use rmcp::*; - /// # use rmcp::model::ElicitationAction; - /// # use url::Url; - /// - /// async fn example(peer: Peer) -> Result<(), Box> { - /// let elicit_result = peer.elicit_url("Please visit the following URL to complete the action", - /// Url::parse("https://example.com/complete_action")?, "elicit_123").await?; - /// match elicit_result { - /// ElicitationAction::Accept => { - /// println!("User accepted and confirmed completion"); - /// } - /// ElicitationAction::Decline => { - /// println!("User declined the request"); - /// } - /// ElicitationAction::Cancel => { - /// println!("User cancelled/dismissed the request"); - /// } - /// _ => {} - /// } - /// Ok(()) - /// } - /// ``` - /// - /// # SEP-2260: request association - /// - /// From protocol version `2026-07-28` this must be issued while handling a - /// client request; see [`OriginatingRequestId`]. - #[cfg(feature = "elicitation")] - pub async fn elicit_url( - &self, - message: impl Into, - url: impl Into, - elicitation_id: impl Into, - ) -> Result { - self.elicit_url_with_timeout(message, url, elicitation_id, None) - .await - } - - /// Request the user to visit a URL and confirm completion. - /// - /// Same as `elicit_url()` but allows specifying a custom timeout for the request. - /// - /// # Arguments - /// * `message` - The prompt message for the user - /// * `url` - The URL the user is requested to visit - /// * `elicitation_id` - A unique identifier for this elicitation request - /// * `timeout` - Optional timeout duration. If None, uses default timeout behavior - /// # Returns - /// * `Ok(action)` indicating the user's response action - /// * `Err(ElicitationError::CapabilityNotSupported)` if client does not support elicitation via URL - /// * `Err(ElicitationError::Service(_))` if the underlying service call failed - /// # Example - /// ```rust,no_run - /// # use std::time::Duration; - /// use rmcp::*; - /// # use rmcp::model::ElicitationAction; - /// # use url::Url; - /// - /// async fn example(peer: Peer) -> Result<(), Box> { - /// let elicit_result = peer.elicit_url_with_timeout("Please visit the following URL to complete the action", - /// Url::parse("https://example.com/complete_action")?, - /// "elicit_123", - /// Some(Duration::from_secs(30))).await?; - /// match elicit_result { - /// ElicitationAction::Accept => { - /// println!("User accepted and confirmed completion"); - /// } - /// ElicitationAction::Decline => { - /// println!("User declined the request"); - /// } - /// ElicitationAction::Cancel => { - /// println!("User cancelled/dismissed the request"); - /// } - /// _ => {} - /// } - /// Ok(()) - /// } - /// ``` - /// - /// # SEP-2260: request association - /// - /// From protocol version `2026-07-28` this must be issued while handling a - /// client request; see [`OriginatingRequestId`]. - #[cfg(feature = "elicitation")] - pub async fn elicit_url_with_timeout( - &self, - message: impl Into, - url: impl Into, - elicitation_id: impl Into, - timeout: Option, - ) -> Result { - // Check if client supports url elicitation - if !self - .supported_elicitation_modes() - .contains(&ElicitationMode::Url) - { - return Err(ElicitationError::CapabilityNotSupported); - } - - let action = self - .create_elicitation_with_timeout( - ElicitRequestParams::UrlElicitationParams { - meta: None, - message: message.into(), - url: url.into().to_string(), - elicitation_id: elicitation_id.into(), - }, - timeout, - ) - .await? - .action; - Ok(action) - } -} diff --git a/vendor/rmcp/src/service/tower.rs b/vendor/rmcp/src/service/tower.rs deleted file mode 100644 index 867d3e7fa..000000000 --- a/vendor/rmcp/src/service/tower.rs +++ /dev/null @@ -1,54 +0,0 @@ -use std::{future::poll_fn, marker::PhantomData}; - -use tower_service::Service as TowerService; - -use super::NotificationContext; -use crate::service::{MaybeSendFuture, RequestContext, Service, ServiceRole}; - -pub struct TowerHandler { - pub service: S, - pub info: R::Info, - role: PhantomData, -} - -impl TowerHandler { - pub fn new(service: S, info: R::Info) -> Self { - Self { - service, - role: PhantomData, - info, - } - } -} - -impl Service for TowerHandler -where - S: TowerService + Sync + Send + Clone + 'static, - S::Error: Into, - S::Future: Send, -{ - async fn handle_request( - &self, - request: R::PeerReq, - _context: RequestContext, - ) -> Result { - let mut service = self.service.clone(); - poll_fn(|cx| service.poll_ready(cx)) - .await - .map_err(Into::into)?; - let resp = service.call(request).await.map_err(Into::into)?; - Ok(resp) - } - - fn handle_notification( - &self, - _notification: R::PeerNot, - _context: NotificationContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Ok(())) - } - - fn get_info(&self) -> R::Info { - self.info.clone() - } -} diff --git a/vendor/rmcp/src/task_manager.rs b/vendor/rmcp/src/task_manager.rs deleted file mode 100644 index 0e8339c0a..000000000 --- a/vendor/rmcp/src/task_manager.rs +++ /dev/null @@ -1,998 +0,0 @@ -//! Server-side runtime for the MCP Tasks extension (SEP-2663, -//! `io.modelcontextprotocol/tasks`). -//! -//! [`TaskManager`] owns the durable state for tasks a server has materialized -//! in response to task-eligible requests (currently `tools/call`). It: -//! -//! - spawns the underlying operation and tracks its lifecycle as a -//! [`DetailedTask`] (`working` → terminal, optionally via `input_required`), -//! - answers `tasks/get` with the current state (including in-flight -//! `inputRequests` and terminal `result`/`error` payloads), -//! - accepts `tasks/update` `inputResponses` and routes them to the running -//! operation (ignoring unknown or already-answered keys per spec), -//! - handles cooperative `tasks/cancel`, -//! - enforces TTL-based expiry (`ttl_ms`), marking overdue tasks `failed`. -//! -//! Tasks are only durably observable once [`TaskManager::spawn`] returns, -//! satisfying the spec requirement that a server not return `CreateTaskResult` -//! before `tasks/get` for that id would resolve. - -use std::{ - collections::HashMap, - pin::Pin, - sync::{Arc, Mutex}, - time::Instant, -}; - -use futures::Future; -use tokio::sync::oneshot; - -use crate::{ - error::ErrorData as McpError, - model::{ - CallToolResult, DetailedTask, InputRequest, InputRequests, JsonObject, Task, TaskPayload, - TaskStatus, - }, -}; - -/// Default TTL (5 minutes, in milliseconds) applied when none is specified. -pub const DEFAULT_TASK_TTL_MS: u64 = 300_000; - -/// Default suggested polling interval, in milliseconds. -pub const DEFAULT_POLL_INTERVAL_MS: u64 = 1_000; - -/// Helper to generate an ISO 8601 timestamp for task metadata. -pub fn current_timestamp() -> String { - chrono::Utc::now().to_rfc3339() -} - -/// Handle passed to a running task operation, allowing it to surface -/// server-to-client requests (elicitation, sampling, roots) mid-task and -/// await the client's `tasks/update` response. -#[derive(Clone)] -pub struct TaskContext { - task_id: String, - inner: Arc>, -} - -impl TaskContext { - /// The id of the task this context belongs to. - pub fn task_id(&self) -> &str { - &self.task_id - } - - /// Surface a server-to-client request under `key` and wait for the - /// client's response delivered via `tasks/update`. - /// - /// While at least one request is outstanding the task reports - /// `input_required` from `tasks/get`, with all outstanding requests in - /// `inputRequests`. Keys must be unique over the lifetime of the task; - /// reusing a key returns an error. - pub async fn request_input( - &self, - key: impl Into, - request: InputRequest, - ) -> Result { - let key = key.into(); - let (tx, rx) = oneshot::channel(); - { - let mut inner = self.inner.lock().expect("task manager lock poisoned"); - let entry = inner.tasks.get_mut(&self.task_id).ok_or_else(|| { - TaskExit::Error(McpError::internal_error( - "task no longer exists".to_string(), - None, - )) - })?; - if !entry.used_input_keys.insert(key.clone()) { - return Err(TaskExit::Error(McpError::internal_error( - format!("inputRequests key {key:?} was already used for this task"), - None, - ))); - } - entry.pending_inputs.insert(key.clone(), (request, tx)); - entry.touch(); - } - // The sender is dropped when `tasks/cancel` clears pending inputs. - rx.await.map_err(|_| TaskExit::Cancelled) - } - - /// Update the task's human-readable status message. - pub fn set_status_message(&self, message: impl Into) { - let mut inner = self.inner.lock().expect("task manager lock poisoned"); - if let Some(entry) = inner.tasks.get_mut(&self.task_id) { - entry.task.status_message = Some(message.into()); - entry.touch(); - } - } - - /// Returns `true` if `tasks/cancel` has been received for this task. - /// Cooperative: operations should check this and stop when set. - pub fn is_cancel_requested(&self) -> bool { - let inner = self.inner.lock().expect("task manager lock poisoned"); - inner - .tasks - .get(&self.task_id) - .is_some_and(|e| e.cancel_requested) - } - - /// Resolves once `tasks/cancel` has been received for this task (or - /// immediately, if it already has). Cooperative: pair with - /// `tokio::select!` around long-running work to implement a cancellation - /// exit path. - /// - /// An operation that stops in response should return - /// [`TaskExit::Cancelled`] so the task settles as `cancelled`. Returning - /// [`TaskExit::Error`] settles as `failed`, and finishing the work - /// anyway settles as `completed` — per SEP-2663 cancellation is - /// cooperative and a task may reach a non-`cancelled` terminal status. - pub async fn cancelled(&self) { - let mut rx = { - let inner = self.inner.lock().expect("task manager lock poisoned"); - let Some(entry) = inner.tasks.get(&self.task_id) else { - return; - }; - if entry.cancel_requested { - return; - } - entry.cancel_signal.subscribe() - }; - // Wait until the watch flips to true; a closed channel means the - // manager dropped the entry, which also unblocks the operation. - while !*rx.borrow_and_update() { - if rx.changed().await.is_err() { - return; - } - } - } -} - -/// How a task operation finished without producing a result. -#[expect( - clippy::exhaustive_enums, - reason = "error variant for task exit may only be due to error or cancellation" -)] -#[derive(Debug)] -pub enum TaskExit { - /// The operation is exiting in response to a cancellation request; - /// the task settles as terminal `cancelled`. - Cancelled, - /// A real failure; the task settles as terminal `failed` with the - /// error inlined, even after `tasks/cancel` was received. - Error(McpError), -} - -impl From for TaskExit { - fn from(error: McpError) -> Self { - TaskExit::Error(error) - } -} - -/// Boxed future representing the async operation backing a task. -pub type TaskFuture = Pin> + Send>>; - -struct TaskEntry { - task: Task, - /// Terminal payload, if the task has finished. - terminal: Option, - /// When the task reached its terminal state; drives retention eviction. - terminal_at: Option, - /// Outstanding input requests keyed by their unique identifier. - pending_inputs: HashMap)>, - /// Every key ever used, to enforce uniqueness across the task lifetime. - used_input_keys: std::collections::HashSet, - cancel_requested: bool, - /// Signals the running operation that cancellation was requested - /// (`true` once `tasks/cancel` arrives). Cooperative: the operation - /// decides whether and how to stop. - cancel_signal: tokio::sync::watch::Sender, - created: Instant, - join_handle: Option>, -} - -impl TaskEntry { - fn touch(&mut self) { - self.task.last_updated_at = current_timestamp(); - } - - fn current_status(&self) -> TaskStatus { - match &self.terminal { - Some(payload) => payload.status(), - None if !self.pending_inputs.is_empty() => TaskStatus::InputRequired, - None => TaskStatus::Working, - } - } - - fn detailed(&self) -> DetailedTask { - let payload = match &self.terminal { - Some(p) => p.clone(), - None if !self.pending_inputs.is_empty() => TaskPayload::InputRequired { - input_requests: self - .pending_inputs - .iter() - .map(|(k, (req, _))| (k.clone(), req.clone())) - .collect::(), - }, - None => TaskPayload::Working, - }; - DetailedTask::new(self.task.clone(), payload) - } -} - -#[derive(Default)] -struct TaskManagerInner { - tasks: HashMap, -} - -/// Options controlling a spawned task. -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct TaskOptions { - /// TTL in milliseconds; `None` means unlimited retention. - pub ttl_ms: Option, - /// Suggested polling interval in milliseconds. - pub poll_interval_ms: Option, - /// Initial status message. - pub status_message: Option, -} - -impl Default for TaskOptions { - fn default() -> Self { - Self { - ttl_ms: Some(DEFAULT_TASK_TTL_MS), - poll_interval_ms: Some(DEFAULT_POLL_INTERVAL_MS), - status_message: None, - } - } -} - -impl TaskOptions { - pub fn new() -> Self { - Self::default() - } - - /// Set the TTL in milliseconds. `None` means unlimited retention. - pub fn with_ttl_ms(mut self, ttl_ms: impl Into>) -> Self { - self.ttl_ms = ttl_ms.into(); - self - } - - /// Set the suggested polling interval in milliseconds. - pub fn with_poll_interval_ms(mut self, poll_interval_ms: u64) -> Self { - self.poll_interval_ms = Some(poll_interval_ms); - self - } - - /// Set the initial status message. - pub fn with_status_message(mut self, message: impl Into) -> Self { - self.status_message = Some(message.into()); - self - } -} - -/// Server-side task store and executor for the SEP-2663 Tasks extension. -/// -/// Cheaply cloneable; all clones share the same state. -/// -/// # Retention -/// -/// Entries are swept opportunistically on every `spawn` / `get_task` / -/// `update_task` / `cancel_task` / `running_task_count` call: non-terminal -/// tasks whose `ttl_ms` has elapsed are marked `failed` (their operation is -/// aborted), and terminal tasks are evicted after being retained for one -/// further `ttl_ms` window past their terminal transition so pollers can -/// observe the final state. -/// -/// Note that the retention window intentionally extends past the -/// creation-based lifetime that `ttl_ms` advertises on the wire: a task that -/// runs to its TTL deadline is marked `failed` around `created + ttl_ms` and -/// stays observable until roughly `created + 2 × ttl_ms`. This is compliant — -/// SEP-2663 lets servers delete expired tasks *at any time* after the TTL, -/// so retaining them longer as an observation grace period is a server-side -/// policy choice, not a wire-contract change. Clients may treat the task as -/// unusable after `createdAt + ttlMs` regardless. -/// -/// Tasks with `ttl_ms: None` are retained for the lifetime of the manager -/// (spec: unlimited retention) — bound task creation or call -/// [`Self::shutdown`] yourself if you spawn such tasks in a long-lived -/// server. There is no background sweeper; an idle manager holds its entries -/// until the next call. -#[derive(Clone, Default)] -pub struct TaskManager { - inner: Arc>, -} - -impl TaskManager { - pub fn new() -> Self { - Self::default() - } - - /// Spawn an operation as a task and return its seed [`Task`] state for a - /// `CreateTaskResult`. The task is durably observable via - /// [`Self::get_task`] before this method returns. - /// - /// `make_future` receives a [`TaskContext`] for mid-task input requests, - /// status messages, and cooperative cancellation checks. - pub fn spawn(&self, options: TaskOptions, make_future: F) -> Task - where - F: FnOnce(TaskContext) -> TaskFuture, - { - let task_id = uuid::Uuid::new_v4().to_string(); - let now = current_timestamp(); - let mut task = Task::new(task_id.clone(), TaskStatus::Working, now.clone(), now); - task.ttl_ms = options.ttl_ms; - task.poll_interval_ms = options.poll_interval_ms; - task.status_message = options.status_message; - - let entry = TaskEntry { - task: task.clone(), - terminal: None, - terminal_at: None, - pending_inputs: HashMap::new(), - used_input_keys: std::collections::HashSet::new(), - cancel_requested: false, - cancel_signal: tokio::sync::watch::channel(false).0, - created: Instant::now(), - join_handle: None, - }; - { - let mut inner = self.inner.lock().expect("task manager lock poisoned"); - // Opportunistic TTL sweep on every task creation, so terminal - // entries are evicted even if clients never poll again. - Self::sweep_expired(&mut inner); - inner.tasks.insert(task_id.clone(), entry); - } - - let context = TaskContext { - task_id: task_id.clone(), - inner: self.inner.clone(), - }; - let future = make_future(context); - let inner = self.inner.clone(); - let id_for_task = task_id.clone(); - let originating_request = crate::service::ORIGINATING_REQUEST - .try_with(|id| id.clone()) - .ok(); - let handle = tokio::spawn(async move { - let result = run_task_operation(originating_request, future).await; - let mut inner = inner.lock().expect("task manager lock poisoned"); - if let Some(entry) = inner.tasks.get_mut(&id_for_task) { - if entry.terminal.is_none() { - entry.terminal = Some(match result { - Ok(result) => TaskPayload::Completed { - result: result_to_object(&result), - }, - Err(TaskExit::Cancelled) => TaskPayload::Cancelled, - Err(TaskExit::Error(error)) => TaskPayload::Failed { - error: error_to_object(&error), - }, - }); - entry.terminal_at = Some(Instant::now()); - entry.pending_inputs.clear(); - entry.touch(); - entry.task.status = entry.current_status(); - } - // The operation has finished; drop the JoinHandle so it is - // not retained for the rest of the retention window. - entry.join_handle = None; - } - }); - match self - .inner - .lock() - .expect("task manager lock poisoned") - .tasks - .get_mut(&task_id) - { - // Only store the handle while the operation is still running: if - // it already settled, the completion path above ran first and a - // stored handle would never be cleared. - Some(entry) => { - if entry.terminal.is_none() { - entry.join_handle = Some(handle); - } - } - // The entry is gone: shutdown() drained the map between the - // insert and here. Abort rather than leak a detached operation. - None => handle.abort(), - } - task - } - - /// Handle `tasks/get`: return the current [`DetailedTask`] state. - pub fn get_task(&self, task_id: &str) -> Result { - let mut inner = self.inner.lock().expect("task manager lock poisoned"); - Self::sweep_expired(&mut inner); - let entry = inner - .tasks - .get_mut(task_id) - .ok_or_else(|| unknown_task(task_id))?; - entry.task.status = entry.current_status(); - Ok(entry.detailed()) - } - - /// Handle `tasks/update`: deliver `inputResponses` to the running - /// operation. Unknown, already-answered, or superseded keys are ignored - /// per spec; a partial set of responses is accepted. - pub fn update_task( - &self, - task_id: &str, - input_responses: impl IntoIterator, - ) -> Result<(), McpError> { - let mut inner = self.inner.lock().expect("task manager lock poisoned"); - Self::sweep_expired(&mut inner); - let entry = inner - .tasks - .get_mut(task_id) - .ok_or_else(|| unknown_task(task_id))?; - for (key, value) in input_responses { - if let Some((_, tx)) = entry.pending_inputs.remove(&key) { - // Receiver dropped means the operation moved on; ignore. - let _ = tx.send(value); - } - } - entry.touch(); - entry.task.status = entry.current_status(); - Ok(()) - } - - /// Handle `tasks/cancel`: cooperative cancellation (SEP-2663). - /// - /// Records the cancellation *intent* and acknowledges immediately, but - /// does **not** abort the underlying future or force a terminal state. - /// The operation observes cancellation via - /// [`TaskContext::is_cancel_requested`] / [`TaskContext::cancelled`], or - /// via the error returned from a pending [`TaskContext::request_input`] - /// call (whose response channel is dropped here), and decides its own - /// terminal status: - /// - /// - stops with [`TaskExit::Cancelled`] → recorded as `cancelled`, - /// - stops with [`TaskExit::Error`] → recorded as `failed` with the - /// error inlined (a real failure after a cancel request is not masked), - /// - finishes its work anyway → recorded as `completed` — per the spec, - /// "the task may still reach a non-`cancelled` terminal status". - pub fn cancel_task(&self, task_id: &str) -> Result<(), McpError> { - let mut inner = self.inner.lock().expect("task manager lock poisoned"); - Self::sweep_expired(&mut inner); - let entry = inner - .tasks - .get_mut(task_id) - .ok_or_else(|| unknown_task(task_id))?; - entry.cancel_requested = true; - let _ = entry.cancel_signal.send(true); - if entry.terminal.is_none() { - // Wake any operation parked on `request_input`: dropping the - // response senders resolves those awaits with an error, giving - // parked operations a cooperative exit path. The task leaves - // `input_required` and reports `working` until it settles. - entry.pending_inputs.clear(); - entry.touch(); - entry.task.status = entry.current_status(); - } - Ok(()) - } - - /// Number of tasks currently in a non-terminal state. - pub fn running_task_count(&self) -> usize { - let mut inner = self.inner.lock().expect("task manager lock poisoned"); - Self::sweep_expired(&mut inner); - inner - .tasks - .values() - .filter(|e| e.terminal.is_none()) - .count() - } - - /// Abort all running tasks and clear all task state. - pub fn shutdown(&self) { - let mut inner = self.inner.lock().expect("task manager lock poisoned"); - for (_, mut entry) in inner.tasks.drain() { - if let Some(handle) = entry.join_handle.take() { - handle.abort(); - } - } - } - - /// TTL sweep, run from every `TaskManager` entry point (SEP-2663: servers - /// MAY mark a task `failed` any time after its TTL elapses, and - /// subsequently delete it at any time; `ttl_ms: None` means unlimited - /// retention). - /// - /// Two phases per entry: - /// 1. A non-terminal task whose TTL has elapsed is marked `failed` (its - /// operation is aborted — the TTL is the SDK's hard-stop safety valve, - /// unlike cooperative `tasks/cancel`). - /// 2. A *terminal* task is evicted once it has been retained for a full - /// TTL window after reaching its terminal state, so well-behaved - /// pollers get a chance to observe the final status before late - /// `tasks/get` calls start returning `-32602`. - fn sweep_expired(inner: &mut TaskManagerInner) { - // Phase 1: fail overdue non-terminal tasks. - for entry in inner.tasks.values_mut() { - if entry.terminal.is_none() - && let Some(ttl_ms) = entry.task.ttl_ms - && entry.created.elapsed().as_millis() >= u128::from(ttl_ms) - { - if let Some(handle) = entry.join_handle.take() { - handle.abort(); - } - entry.terminal = Some(TaskPayload::Failed { - error: error_to_object(&McpError::internal_error( - "task expired: TTL elapsed before completion".to_string(), - None, - )), - }); - entry.terminal_at = Some(Instant::now()); - entry.pending_inputs.clear(); - entry.touch(); - entry.task.status = TaskStatus::Failed; - } - } - // Phase 2: evict terminal tasks whose retention window has passed. - inner.tasks.retain(|_, entry| { - let (Some(ttl_ms), Some(terminal_at)) = (entry.task.ttl_ms, entry.terminal_at) else { - return true; - }; - terminal_at.elapsed().as_millis() < u128::from(ttl_ms) - }); - } -} - -fn unknown_task(task_id: &str) -> McpError { - McpError::invalid_params(format!("unknown task: {task_id}"), None) -} - -async fn run_task_operation( - originating_request: Option, - future: TaskFuture, -) -> Result { - match originating_request { - Some(id) => crate::service::ORIGINATING_REQUEST.scope(id, future).await, - None => future.await, - } -} - -fn result_to_object(result: &CallToolResult) -> JsonObject { - match serde_json::to_value(result) { - Ok(serde_json::Value::Object(map)) => map, - _ => JsonObject::new(), - } -} - -fn error_to_object(error: &McpError) -> JsonObject { - match serde_json::to_value(error) { - Ok(serde_json::Value::Object(map)) => map, - _ => JsonObject::new(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::model::ContentBlock; - - fn ok_result(text: &str) -> CallToolResult { - CallToolResult::success(vec![ContentBlock::text(text.to_string())]) - } - - #[tokio::test] - async fn task_completes_and_result_is_inlined() { - let manager = TaskManager::new(); - let task = manager.spawn(TaskOptions::default(), |_ctx| { - Box::pin(async { Ok(ok_result("42")) }) - }); - assert_eq!(task.status, TaskStatus::Working); - - // Durable immediately. - manager.get_task(&task.task_id).unwrap(); - - // Wait for completion. - for _ in 0..100 { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - let detailed = manager.get_task(&task.task_id).unwrap(); - if detailed.status() == TaskStatus::Completed { - match detailed.payload { - TaskPayload::Completed { result } => { - assert!(result.contains_key("content")); - return; - } - other => panic!("unexpected payload: {other:?}"), - } - } - } - panic!("task did not complete"); - } - - #[tokio::test] - async fn cancel_settles_to_cancelled_when_operation_honors_it() { - let manager = TaskManager::new(); - let task = manager.spawn(TaskOptions::default(), |ctx| { - Box::pin(async move { - tokio::select! { - _ = ctx.cancelled() => Err(TaskExit::Cancelled), - _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => { - Ok(ok_result("never")) - } - } - }) - }); - manager.cancel_task(&task.task_id).unwrap(); - - // The ack is immediate but the terminal state is set by the - // operation; poll until it settles. - for _ in 0..100 { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - let detailed = manager.get_task(&task.task_id).unwrap(); - if detailed.status().is_terminal() { - assert_eq!(detailed.status(), TaskStatus::Cancelled); - return; - } - } - panic!("task did not settle after cancel"); - } - - #[tokio::test] - async fn post_cancel_unrelated_error_settles_as_failed() { - let manager = TaskManager::new(); - let task = manager.spawn(TaskOptions::default(), |ctx| { - Box::pin(async move { - // Fail for an unrelated reason after observing the cancel: - // must be recorded as `failed`, not masked as `cancelled`. - ctx.cancelled().await; - Err(TaskExit::Error(McpError::internal_error( - "database write failed", - None, - ))) - }) - }); - manager.cancel_task(&task.task_id).unwrap(); - - for _ in 0..100 { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - let detailed = manager.get_task(&task.task_id).unwrap(); - if detailed.status().is_terminal() { - assert_eq!(detailed.status(), TaskStatus::Failed); - match detailed.payload { - TaskPayload::Failed { error } => { - assert!( - error.get("message").is_some_and(|m| m - .as_str() - .is_some_and(|s| s.contains("database write failed"))), - "error payload should be preserved: {error:?}" - ); - } - other => panic!("unexpected payload: {other:?}"), - } - return; - } - } - panic!("task did not settle after cancel"); - } - - #[tokio::test] - async fn cancel_is_cooperative_and_lets_the_operation_clean_up() { - let manager = TaskManager::new(); - let (cleanup_tx, cleanup_rx) = oneshot::channel::<&'static str>(); - let task = manager.spawn(TaskOptions::default(), |ctx| { - Box::pin(async move { - // Wait for cancellation, then run cleanup and finish the - // work anyway (spec: a task may still reach a - // non-`cancelled` terminal status). - ctx.cancelled().await; - let _ = cleanup_tx.send("cleaned up"); - Ok(ok_result("finished despite cancel")) - }) - }); - - manager.cancel_task(&task.task_id).unwrap(); - - // The ack is immediate and does not force a terminal state. - let detailed = manager.get_task(&task.task_id).unwrap(); - assert!( - !detailed.status().is_terminal(), - "cancel must not force terminal state" - ); - - // The operation observes the cancel and performs cleanup. - let cleanup = tokio::time::timeout(std::time::Duration::from_secs(5), cleanup_rx) - .await - .expect("cleanup should not time out") - .expect("cleanup channel should not be dropped"); - assert_eq!(cleanup, "cleaned up"); - - // The operation chose to complete: the task settles as `completed`. - for _ in 0..100 { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - let detailed = manager.get_task(&task.task_id).unwrap(); - if detailed.status().is_terminal() { - assert_eq!(detailed.status(), TaskStatus::Completed); - return; - } - } - panic!("task did not settle after cancel"); - } - - #[tokio::test] - async fn cancel_wakes_parked_input_requests() { - let manager = TaskManager::new(); - let (exit_tx, exit_rx) = oneshot::channel::<&'static str>(); - let task = manager.spawn(TaskOptions::default(), |ctx| { - Box::pin(async move { - let request: InputRequest = serde_json::from_value(serde_json::json!({ - "method": "elicitation/create", - "params": { - "message": "Waiting forever", - "requestedSchema": {"type": "object", "properties": {}} - } - })) - .map_err(|e| McpError::internal_error(e.to_string(), None))?; - // Parked on input; cancel must wake this await with an error. - let err = ctx.request_input("k1", request).await.unwrap_err(); - let _ = exit_tx.send("woken"); - Err(err) - }) - }); - - // Wait until the task is parked on the input request. - for _ in 0..100 { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - if manager.get_task(&task.task_id).unwrap().status() == TaskStatus::InputRequired { - break; - } - } - - manager.cancel_task(&task.task_id).unwrap(); - let woken = tokio::time::timeout(std::time::Duration::from_secs(5), exit_rx) - .await - .expect("parked operation should be woken by cancel") - .expect("exit channel should not be dropped"); - assert_eq!(woken, "woken"); - assert_eq!( - manager.get_task(&task.task_id).unwrap().status(), - TaskStatus::Cancelled - ); - } - - #[tokio::test] - async fn unknown_task_is_invalid_params() { - // SEP-2663 §Protocol Errors: invalid or nonexistent taskId is -32602 - // (Invalid params) — MUST for tasks/get, SHOULD for update/cancel. - let manager = TaskManager::new(); - for err in [ - manager.get_task("nope").unwrap_err(), - manager.cancel_task("nope").unwrap_err(), - manager.update_task("nope", []).unwrap_err(), - ] { - assert_eq!(err.code, crate::model::ErrorCode::INVALID_PARAMS); - } - } - - #[tokio::test] - async fn terminal_tasks_are_evicted_after_retention_window() { - let manager = TaskManager::new(); - let task = manager.spawn(TaskOptions::new().with_ttl_ms(50), |_ctx| { - Box::pin(async { Ok(ok_result("fast")) }) - }); - - // Wait for completion; the terminal state stays observable during - // the retention window. - let mut completed = false; - for _ in 0..100 { - tokio::time::sleep(std::time::Duration::from_millis(5)).await; - if manager.get_task(&task.task_id).unwrap().status() == TaskStatus::Completed { - completed = true; - break; - } - } - assert!(completed, "task should have completed"); - - // After a full TTL window past terminal, the entry is evicted and - // late polls get -32602. - tokio::time::sleep(std::time::Duration::from_millis(120)).await; - let err = manager.get_task(&task.task_id).unwrap_err(); - assert_eq!(err.code, crate::model::ErrorCode::INVALID_PARAMS); - assert_eq!(manager.running_task_count(), 0); - } - - #[tokio::test] - async fn abandoned_tasks_are_swept_by_other_entry_points() { - // A task nobody ever polls again must still be failed + evicted; the - // sweep runs from spawn() too, so activity on *other* tasks is enough. - let manager = TaskManager::new(); - let abandoned = manager.spawn(TaskOptions::new().with_ttl_ms(10), |_ctx| { - Box::pin(async { - tokio::time::sleep(std::time::Duration::from_secs(60)).await; - Ok(ok_result("never")) - }) - }); - - // Let TTL elapse (fails the task), then a second full window - // (evicts it), without ever calling get_task on the abandoned id. - tokio::time::sleep(std::time::Duration::from_millis(40)).await; - let _ = manager.spawn(TaskOptions::default(), |_ctx| { - Box::pin(async { Ok(ok_result("other")) }) - }); - tokio::time::sleep(std::time::Duration::from_millis(40)).await; - let _ = manager.spawn(TaskOptions::default(), |_ctx| { - Box::pin(async { Ok(ok_result("other2")) }) - }); - - let err = manager.get_task(&abandoned.task_id).unwrap_err(); - assert_eq!(err.code, crate::model::ErrorCode::INVALID_PARAMS); - } - - #[tokio::test] - async fn running_task_count_sweeps_expired_tasks() { - let manager = TaskManager::new(); - let _task = manager.spawn(TaskOptions::new().with_ttl_ms(10), |_ctx| { - Box::pin(async { - tokio::time::sleep(std::time::Duration::from_secs(60)).await; - Ok(ok_result("never")) - }) - }); - assert_eq!(manager.running_task_count(), 1); - tokio::time::sleep(std::time::Duration::from_millis(30)).await; - // The count itself must sweep: the overdue task is failed, not - // reported as running. - assert_eq!(manager.running_task_count(), 0); - } - - #[tokio::test] - async fn unlimited_ttl_tasks_are_retained() { - let manager = TaskManager::new(); - let task = manager.spawn(TaskOptions::new().with_ttl_ms(None), |_ctx| { - Box::pin(async { Ok(ok_result("kept")) }) - }); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - // Sweeps triggered by other entry points must not evict it. - let _ = manager.spawn(TaskOptions::default(), |_ctx| { - Box::pin(async { Ok(ok_result("other")) }) - }); - assert_eq!( - manager.get_task(&task.task_id).unwrap().status(), - TaskStatus::Completed - ); - } - - #[tokio::test] - async fn ttl_expiry_fails_task() { - let manager = TaskManager::new(); - let task = manager.spawn( - TaskOptions { - ttl_ms: Some(10), - ..Default::default() - }, - |_ctx| { - Box::pin(async { - tokio::time::sleep(std::time::Duration::from_secs(60)).await; - Ok(ok_result("never")) - }) - }, - ); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - let detailed = manager.get_task(&task.task_id).unwrap(); - assert_eq!(detailed.status(), TaskStatus::Failed); - } - - #[tokio::test] - async fn input_required_roundtrip() { - let manager = TaskManager::new(); - let task = manager.spawn(TaskOptions::default(), |ctx| { - Box::pin(async move { - let request: InputRequest = serde_json::from_value(serde_json::json!({ - "method": "elicitation/create", - "params": { - "message": "What is your name?", - "requestedSchema": {"type": "object", "properties": {}} - } - })) - .map_err(|e| McpError::internal_error(e.to_string(), None))?; - let response = ctx.request_input("name-1", request).await?; - let name = response - .get("content") - .and_then(|c| c.get("name")) - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(); - Ok(ok_result(&format!("hello {name}"))) - }) - }); - - // Wait for the task to surface the input request. - let mut saw_input_required = false; - for _ in 0..100 { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - let detailed = manager.get_task(&task.task_id).unwrap(); - if let TaskPayload::InputRequired { input_requests } = &detailed.payload { - assert!(input_requests.contains_key("name-1")); - saw_input_required = true; - break; - } - } - assert!(saw_input_required, "task never reached input_required"); - - // Respond via tasks/update. - manager - .update_task( - &task.task_id, - [( - "name-1".to_string(), - serde_json::json!({"action": "accept", "content": {"name": "Ada"}}), - )], - ) - .unwrap(); - - for _ in 0..100 { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - let detailed = manager.get_task(&task.task_id).unwrap(); - if detailed.status() == TaskStatus::Completed { - return; - } - } - panic!("task did not complete after input response"); - } - - #[tokio::test] - async fn task_operation_reestablishes_request_association_scope() { - use crate::{ - model::RequestId, - service::{ORIGINATING_REQUEST, in_request_handler_scope}, - }; - - let manager = TaskManager::new(); - let observed = Arc::new(Mutex::new(None::)); - let observed_in_task = observed.clone(); - - ORIGINATING_REQUEST - .scope(RequestId::Number(7), async { - manager.spawn(TaskOptions::default(), move |_ctx| { - let observed_in_task = observed_in_task.clone(); - Box::pin(async move { - *observed_in_task.lock().unwrap() = Some(in_request_handler_scope()); - Ok(ok_result("done")) - }) - }) - }) - .await; - - for _ in 0..100 { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - if let Some(scoped) = *observed.lock().unwrap() { - assert!( - scoped, - "task operation must run inside the originating request's association scope" - ); - return; - } - } - panic!("task operation did not run"); - } - - #[tokio::test] - async fn task_operation_without_originating_request_is_unscoped() { - use crate::service::in_request_handler_scope; - - let manager = TaskManager::new(); - let observed = Arc::new(Mutex::new(None::)); - let observed_in_task = observed.clone(); - - manager.spawn(TaskOptions::default(), move |_ctx| { - let observed_in_task = observed_in_task.clone(); - Box::pin(async move { - *observed_in_task.lock().unwrap() = Some(in_request_handler_scope()); - Ok(ok_result("done")) - }) - }); - - for _ in 0..100 { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - if let Some(scoped) = *observed.lock().unwrap() { - assert!( - !scoped, - "task operation started without an originating request must remain unscoped" - ); - return; - } - } - panic!("task operation did not run"); - } -} diff --git a/vendor/rmcp/src/transport.rs b/vendor/rmcp/src/transport.rs deleted file mode 100644 index 06fde8e51..000000000 --- a/vendor/rmcp/src/transport.rs +++ /dev/null @@ -1,310 +0,0 @@ -//! # Transport -//! The transport type must implemented [`Transport`] trait, which allow it send message concurrently and receive message sequentially. -//! -//! ## Standard Transport Types -//! There are 2 pairs of standard transport types: -//! -//! | transport | client | server | -//! |:-: |:-: |:-: | -//! | std IO | [`child_process::TokioChildProcess`] | [`io::stdio`] | -//! | streamable http | [`streamable_http_client::StreamableHttpClientTransport`] | `streamable_http_server::StreamableHttpService` | -//! -//!## Helper Transport Types -//! Thers are several helper transport types that can help you to create transport quickly. -//! -//! ### [Worker Transport](`worker::WorkerTransport`) -//! Which allows you to run a worker and process messages in another tokio task. -//! -//! ### [Async Read/Write Transport](`async_rw::AsyncRwTransport`) -//! You need to enable `transport-async-rw` feature to use this transport. -//! -//! This transport is used to create a transport from a byte stream which implemented [`tokio::io::AsyncRead`] and [`tokio::io::AsyncWrite`]. -//! -//! This could be very helpful when you want to create a transport from a byte stream, such as a file or a tcp connection. -//! -//! ### [Sink/Stream Transport](`sink_stream::SinkStreamTransport`) -//! This transport is used to create a transport from a sink and a stream. -//! -//! This could be very helpful when you want to create a transport from a duplex object stream, such as a websocket connection. -//! -//! ## [IntoTransport](`IntoTransport`) trait -//! [`IntoTransport`] is a helper trait that implicitly convert a type into a transport type. -//! -//! ### These types is automatically implemented [`IntoTransport`] trait -//! 1. A type that already implement both [`futures::Sink`] and [`futures::Stream`] trait, or a tuple `(Tx, Rx)` where `Tx` is [`futures::Sink`] and `Rx` is [`futures::Stream`]. -//! 2. A type that implement both [`tokio::io::AsyncRead`] and [`tokio::io::AsyncWrite`] trait. or a tuple `(R, W)` where `R` is [`tokio::io::AsyncRead`] and `W` is [`tokio::io::AsyncWrite`]. -//! 3. A type that implement [Worker](`worker::Worker`) trait. -//! 4. A type that implement [`Transport`] trait. -//! -//! ## Examples -//! -//! ```rust -//! # use rmcp::{ -//! # ServiceExt, serve_server, -//! # }; -//! #[cfg(feature = "client")] -//! # use rmcp::serve_client; -//! -//! // create transport from tcp stream -//! #[cfg(feature = "client")] -//! async fn client() -> Result<(), Box> { -//! let stream = tokio::net::TcpSocket::new_v4()? -//! .connect("127.0.0.1:8001".parse()?) -//! .await?; -//! let client = ().serve(stream).await?; -//! let tools = client.peer().list_tools(Default::default()).await?; -//! println!("{:?}", tools); -//! Ok(()) -//! } -//! -//! // create transport from std io -//! #[cfg(feature = "client")] -//! async fn io() -> Result<(), Box> { -//! let client = ().serve((tokio::io::stdin(), tokio::io::stdout())).await?; -//! let tools = client.peer().list_tools(Default::default()).await?; -//! println!("{:?}", tools); -//! Ok(()) -//! } -//! ``` - -use std::{borrow::Cow, sync::Arc}; - -use crate::service::{RxJsonRpcMessage, ServiceRole, TxJsonRpcMessage}; - -pub mod sink_stream; - -#[cfg(feature = "transport-async-rw")] -pub mod async_rw; - -#[cfg(feature = "transport-worker")] -pub mod worker; -#[cfg(feature = "transport-worker")] -pub use worker::WorkerTransport; - -#[cfg(feature = "transport-child-process")] -pub mod child_process; -#[cfg(feature = "which-command")] -pub use child_process::which_command; -#[cfg(feature = "transport-child-process")] -pub use child_process::{ConfigureCommandExt, TokioChildProcess}; - -#[cfg(feature = "transport-io")] -pub mod io; -#[cfg(feature = "transport-io")] -pub use io::stdio; - -#[cfg(feature = "auth")] -pub mod auth; -#[cfg(feature = "auth-client-credentials-jwt")] -pub use auth::JwtSigningAlgorithm; -#[cfg(feature = "auth")] -pub use auth::{ - AuthClient, AuthError, AuthorizationManager, AuthorizationRequest, AuthorizationSession, - AuthorizedHttpClient, ClientCredentialsConfig, CredentialStore, - EXTENSION_OAUTH_CLIENT_CREDENTIALS, InMemoryCredentialStore, InMemoryStateStore, - OAuthHttpClient, OAuthHttpClientError, OAuthHttpClientFuture, OAuthHttpRedirectPolicy, - OAuthHttpRequest, ScopeUpgradeConfig, StateStore, StoredAuthorizationState, StoredCredentials, - WWWAuthenticateParams, -}; - -// #[cfg(feature = "transport-ws")] -// pub mod ws; -#[cfg(feature = "transport-streamable-http-server-session")] -pub mod streamable_http_server; -#[cfg(all(feature = "transport-streamable-http-server", not(feature = "local")))] -pub use streamable_http_server::tower::{StreamableHttpServerConfig, StreamableHttpService}; - -#[cfg(feature = "transport-streamable-http-client")] -pub mod streamable_http_client; -#[cfg(all(unix, feature = "transport-streamable-http-client-unix-socket"))] -pub use common::unix_socket::UnixSocketHttpClient; -#[cfg(feature = "transport-streamable-http-client")] -pub use streamable_http_client::StreamableHttpClientTransport; - -/// Common use codes -pub mod common; - -pub trait Transport: Send -where - R: ServiceRole, -{ - type Error: std::error::Error + Send + Sync + 'static; - fn name() -> Cow<'static, str> { - std::any::type_name::().into() - } - /// Send a message to the transport - /// - /// Notice that the future returned by this function should be `Send` and `'static`. - /// It's because the sending message could be executed concurrently. - /// - fn send( - &mut self, - item: TxJsonRpcMessage, - ) -> impl Future> + Send + 'static; - - /// Receive a message from the transport, this operation is sequential. - fn receive(&mut self) -> impl Future>> + Send; - - /// Close the transport - fn close(&mut self) -> impl Future> + Send; -} - -pub trait IntoTransport: Send + 'static -where - R: ServiceRole, - E: std::error::Error + Send + 'static, -{ - fn into_transport(self) -> impl Transport + 'static; -} - -#[non_exhaustive] -pub enum TransportAdapterIdentity {} -impl IntoTransport for T -where - T: Transport + Send + 'static, - R: ServiceRole, - E: std::error::Error + Send + Sync + 'static, -{ - fn into_transport(self) -> impl Transport + 'static { - self - } -} - -/// A transport that can send a single message and then close itself -pub struct OneshotTransport -where - R: ServiceRole, -{ - message: Option>, - sender: tokio::sync::mpsc::Sender>, - termination: Arc, -} - -impl OneshotTransport -where - R: ServiceRole, -{ - pub fn new( - message: RxJsonRpcMessage, - ) -> (Self, tokio::sync::mpsc::Receiver>) { - let (sender, receiver) = tokio::sync::mpsc::channel(16); - ( - Self { - message: Some(message), - sender, - termination: Arc::new(tokio::sync::Semaphore::new(0)), - }, - receiver, - ) - } -} - -impl Transport for OneshotTransport -where - R: ServiceRole, -{ - type Error = tokio::sync::mpsc::error::SendError>; - - fn send( - &mut self, - item: TxJsonRpcMessage, - ) -> impl Future> + Send + 'static { - let sender = self.sender.clone(); - let terminate = matches!(item, TxJsonRpcMessage::::Response(_)) - || matches!(item, TxJsonRpcMessage::::Error(_)); - let termination = self.termination.clone(); - async move { - sender.send(item).await?; - if terminate { - termination.add_permits(1); - } - Ok(()) - } - } - - async fn receive(&mut self) -> Option> { - if let Some(msg) = self.message.take() { - return Some(msg); - } - let _ = self.termination.acquire().await; - None - } - - fn close(&mut self) -> impl Future> + Send { - self.message.take(); - std::future::ready(Ok(())) - } -} - -#[derive(Debug, thiserror::Error)] -#[error("Transport [{transport_name}] error: {error}")] -#[non_exhaustive] -pub struct DynamicTransportError { - pub transport_name: Cow<'static, str>, - pub transport_type_id: std::any::TypeId, - #[source] - pub error: Box, -} - -impl DynamicTransportError { - pub fn new + 'static, R: ServiceRole>(e: T::Error) -> Self { - Self { - transport_name: T::name(), - transport_type_id: std::any::TypeId::of::(), - error: Box::new(e), - } - } - - /// Create a `DynamicTransportError` from raw parts. - /// - /// Unlike [`new`](Self::new), this does not require a concrete [`Transport`] type, - /// making it usable in test fixtures and other contexts where a real transport - /// implementation is not available. - pub fn from_parts( - transport_name: impl Into>, - transport_type_id: std::any::TypeId, - error: Box, - ) -> Self { - Self { - transport_name: transport_name.into(), - transport_type_id, - error, - } - } - - pub(crate) fn is_authorization_required(&self) -> bool { - let mut error = Some(self.error.as_ref() as &(dyn std::error::Error + 'static)); - while let Some(current) = error { - #[cfg(feature = "auth")] - if matches!( - current.downcast_ref::(), - Some(auth::AuthError::AuthorizationRequired) - ) { - return true; - } - - #[cfg(feature = "transport-streamable-http-client")] - if current.is::() { - return true; - } - - error = current.source(); - } - false - } - - pub fn downcast + 'static, R: ServiceRole>(self) -> Result { - if !self.is::() { - Err(self) - } else { - Ok(self - .error - .downcast::() - .map(|e| *e) - .expect("type is checked")) - } - } - pub fn is + 'static, R: ServiceRole>(&self) -> bool { - self.error.is::() && self.transport_type_id == std::any::TypeId::of::() - } -} diff --git a/vendor/rmcp/src/transport/async_rw.rs b/vendor/rmcp/src/transport/async_rw.rs deleted file mode 100644 index e18fd4fce..000000000 --- a/vendor/rmcp/src/transport/async_rw.rs +++ /dev/null @@ -1,779 +0,0 @@ -use std::{marker::PhantomData, sync::Arc}; - -use futures::SinkExt; -use serde::{Serialize, de::DeserializeOwned}; -use thiserror::Error; -use tokio::{ - io::{AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader}, - sync::Mutex, -}; -use tokio_util::{ - bytes::{Buf, BufMut, BytesMut}, - codec::{Decoder, Encoder, FramedWrite}, -}; - -use super::{IntoTransport, Transport}; -use crate::{ - model::ErrorData, - service::{RxJsonRpcMessage, ServiceRole, TxJsonRpcMessage}, -}; - -#[non_exhaustive] -pub enum TransportAdapterAsyncRW {} - -impl IntoTransport for (R, W) -where - Role: ServiceRole, - R: AsyncRead + Send + 'static + Unpin, - W: AsyncWrite + Send + 'static + Unpin, -{ - fn into_transport(self) -> impl Transport + 'static { - AsyncRwTransport::new(self.0, self.1) - } -} - -#[non_exhaustive] -pub enum TransportAdapterAsyncCombinedRW {} -impl IntoTransport for S -where - Role: ServiceRole, - S: AsyncRead + AsyncWrite + Send + 'static, -{ - fn into_transport(self) -> impl Transport + 'static { - IntoTransport::::into_transport( - tokio::io::split(self), - ) - } -} - -pub type TransportWriter = FramedWrite>>; - -pub struct AsyncRwTransport { - read: BufReader, - line_buf: Vec, - write: Arc>>>, - _role: PhantomData Role>, -} - -impl AsyncRwTransport -where - R: Send + AsyncRead + Unpin, - W: Send + AsyncWrite + Unpin + 'static, -{ - pub fn new(read: R, write: W) -> Self { - let read = BufReader::new(read); - let write = Arc::new(Mutex::new(Some(FramedWrite::new( - write, - JsonRpcMessageCodec::>::default(), - )))); - Self { - read, - line_buf: Vec::new(), - write, - _role: PhantomData, - } - } -} - -#[cfg(feature = "client")] -impl AsyncRwTransport -where - R: Send + AsyncRead + Unpin, - W: Send + AsyncWrite + Unpin + 'static, -{ - pub fn new_client(read: R, write: W) -> Self { - Self::new(read, write) - } -} - -#[cfg(feature = "server")] -impl AsyncRwTransport -where - R: Send + AsyncRead + Unpin, - W: Send + AsyncWrite + Unpin + 'static, -{ - pub fn new_server(read: R, write: W) -> Self { - Self::new(read, write) - } -} - -impl Transport for AsyncRwTransport -where - R: Send + AsyncRead + Unpin, - W: Send + AsyncWrite + Unpin + 'static, -{ - type Error = std::io::Error; - - fn send( - &mut self, - item: TxJsonRpcMessage, - ) -> impl Future> + Send + 'static { - let lock = self.write.clone(); - async move { - let mut write = lock.lock().await; - if let Some(ref mut write) = *write { - write.send(item).await.map_err(Into::into) - } else { - Err(std::io::Error::new( - std::io::ErrorKind::NotConnected, - "Transport is closed", - )) - } - } - } - - async fn receive(&mut self) -> Option> { - loop { - // `read_until` is not cancellation-safe on its own, and `receive` is - // polled inside a `select!` in the service loop: an in-progress line - // read is dropped whenever another branch (e.g. an outgoing response) - // becomes ready. We rely on `read_until` appending into `self.line_buf` - // and only returning at a delimiter or EOF, so a cancelled read leaves - // its partial bytes in `self.line_buf`. Keeping that buffer across - // calls lets the next read resume the same line; it is cleared only - // after a whole line has been consumed. Clearing at the top of the - // loop (the previous behaviour) discarded the partial read and so - // dropped incoming requests under concurrent response load. - match self.read.read_until(b'\n', &mut self.line_buf).await { - // EOF. Any bytes still in `line_buf` are an incomplete trailing - // message with no delimiter, so there is nothing to deliver. - Ok(0) => return None, - Ok(_) => {} - Err(e) => { - tracing::error!("Error reading from stream: {}", e); - return None; - } - } - // A returned `read_until` means a full line is buffered. Parse it - // (borrowing `line_buf`), then clear the buffer — retaining its - // capacity for the next read — before handling the parse result. - let parsed = { - let line = without_carriage_return( - self.line_buf.strip_suffix(b"\n").unwrap_or(&self.line_buf), - ); - if line.is_empty() { - self.line_buf.clear(); - continue; - } - try_parse_with_compatibility::>(line, "receive") - }; - self.line_buf.clear(); - match parsed { - Ok(Some(msg)) => return Some(msg), - Ok(None) => continue, - Err(JsonRpcMessageCodecError::Serde(e)) => { - match e.classify() { - serde_json::error::Category::Syntax | serde_json::error::Category::Eof => { - // The input isn't valid JSON, so there's no message id to correlate a - // response to, and replying to invalid data can trigger an error storm - // if the peer echoes the response back as more invalid data. This - // matches the other official MCP SDKs, which ignore unparsable input. - // See https://github.com/modelcontextprotocol/rust-sdk/issues/938 - tracing::debug!("Ignoring unparsable incoming message: {e}"); - } - serde_json::error::Category::Data | serde_json::error::Category::Io => { - // Well-formed JSON that doesn't match the expected message shape is a - // real protocol error rather than unparsable input, so surface it with - // an Invalid Request response instead of silently dropping it. - tracing::debug!("Protocol error on incoming message: {e}"); - let mut write = self.write.lock().await; - let framed = write.as_mut()?; - let response = TxJsonRpcMessage::::error( - ErrorData::invalid_request("Invalid request", None), - None, - ); - if framed.send(response).await.is_err() { - return None; - } - } - } - } - Err(e) => { - tracing::error!("Error reading from stream: {}", e); - return None; - } - } - } - } - - async fn close(&mut self) -> Result<(), Self::Error> { - let mut write = self.write.lock().await; - drop(write.take()); - Ok(()) - } -} - -#[derive(Debug, Clone)] -pub struct JsonRpcMessageCodec { - _marker: PhantomData T>, - next_index: usize, - max_length: usize, - is_discarding: bool, -} - -impl Default for JsonRpcMessageCodec { - fn default() -> Self { - Self::new() - } -} - -impl JsonRpcMessageCodec { - pub fn new() -> Self { - Self { - _marker: PhantomData, - next_index: 0, - max_length: usize::MAX, - is_discarding: false, - } - } - - pub fn new_with_max_length(max_length: usize) -> Self { - Self { - max_length, - ..Self::new() - } - } - - pub fn max_length(&self) -> usize { - self.max_length - } -} - -fn without_carriage_return(s: &[u8]) -> &[u8] { - s.strip_suffix(b"\r").unwrap_or(s) -} - -/// UTF-8 byte order mark. RFC 8259 §8.1 allows JSON parsers to ignore a leading BOM. -const UTF8_BOM: &[u8; 3] = b"\xEF\xBB\xBF"; - -/// Check if a method is a standard MCP method (request, response, or notification). -/// This includes both requests and notifications defined in the MCP specification. -/// -/// Based on MCP specification 2026-07-28: https://modelcontextprotocol.io/specification/2026-07-28 -fn is_standard_method(method: &str) -> bool { - matches!( - method, - "initialize" - | "ping" - | "server/discover" - | "prompts/get" - | "prompts/list" - | "resources/list" - | "resources/read" - | "resources/subscribe" - | "resources/unsubscribe" - | "resources/templates/list" - | "subscriptions/listen" - | "tools/call" - | "tools/list" - | "completion/complete" - | "elicitation/create" - | "logging/setLevel" - | "roots/list" - | "sampling/createMessage" - ) || is_standard_notification(method) -} - -fn is_standard_notification(method: &str) -> bool { - matches!( - method, - "notifications/cancelled" - | "notifications/initialized" - | "notifications/message" - | "notifications/progress" - | "notifications/prompts/list_changed" - | "notifications/resources/list_changed" - | "notifications/resources/updated" - | "notifications/roots/list_changed" - | "notifications/subscriptions/acknowledged" - | "notifications/tools/list_changed" - ) -} - -/// Determines if a notification should be ignored for compatibility. -fn should_ignore_notification(json_value: &serde_json::Value, method: &str) -> bool { - let is_notification = json_value.get("id").is_none(); - - // Ignore non-MCP notifications (like LSP messages) for compatibility - if is_notification && !is_standard_method(method) { - tracing::trace!( - "Ignoring non-MCP notification '{}' for compatibility", - method - ); - return true; - } - - // Ignore non-standard MCP notifications - matches!( - ( - method.starts_with("notifications/"), - is_standard_notification(method) - ), - (true, false) - ) -} - -/// Try to parse a message with compatibility handling for non-standard notifications -fn try_parse_with_compatibility( - line: &[u8], - context: &str, -) -> Result, JsonRpcMessageCodecError> { - let line = line.strip_prefix(UTF8_BOM.as_slice()).unwrap_or(line); - if let Ok(line_str) = std::str::from_utf8(line) { - match serde_json::from_slice(line) { - Ok(item) => Ok(Some(item)), - Err(e) => { - // Check if this is a notification that should be ignored for compatibility - if let Ok(json_value) = serde_json::from_str::(line_str) - && let Some(method) = - json_value.get("method").and_then(serde_json::Value::as_str) - && should_ignore_notification(&json_value, method) - { - return Ok(None); - } - - tracing::debug!( - "Failed to parse message {}: {} | Error: {}", - context, - line_str, - e - ); - Err(JsonRpcMessageCodecError::Serde(e)) - } - } - } else { - serde_json::from_slice(line) - .map(Some) - .map_err(JsonRpcMessageCodecError::Serde) - } -} - -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum JsonRpcMessageCodecError { - #[error("max line length exceeded")] - MaxLineLengthExceeded, - #[error("serde error {0}")] - Serde(#[from] serde_json::Error), - #[error("io error {0}")] - Io(#[from] std::io::Error), -} - -impl From for std::io::Error { - fn from(value: JsonRpcMessageCodecError) -> Self { - match value { - JsonRpcMessageCodecError::MaxLineLengthExceeded => { - std::io::Error::new(std::io::ErrorKind::InvalidData, value) - } - JsonRpcMessageCodecError::Serde(e) => e.into(), - JsonRpcMessageCodecError::Io(e) => e, - } - } -} - -impl Decoder for JsonRpcMessageCodec { - type Item = T; - - type Error = JsonRpcMessageCodecError; - - fn decode( - &mut self, - buf: &mut BytesMut, - ) -> Result, JsonRpcMessageCodecError> { - loop { - // Determine how far into the buffer we'll search for a newline. If - // there's no max_length set, we'll read to the end of the buffer. - let read_to = std::cmp::min(self.max_length.saturating_add(1), buf.len()); - - let newline_offset = buf[self.next_index..read_to] - .iter() - .position(|b| *b == b'\n'); - - match (self.is_discarding, newline_offset) { - (true, Some(offset)) => { - // If we found a newline, discard up to that offset and - // then stop discarding. On the next iteration, we'll try - // to read a line normally. - buf.advance(offset + self.next_index + 1); - self.is_discarding = false; - self.next_index = 0; - } - (true, None) => { - // Otherwise, we didn't find a newline, so we'll discard - // everything we read. On the next iteration, we'll continue - // discarding up to max_len bytes unless we find a newline. - buf.advance(read_to); - self.next_index = 0; - if buf.is_empty() { - return Ok(None); - } - } - (false, Some(offset)) => { - // Found a line! - let newline_index = offset + self.next_index; - self.next_index = 0; - let line = buf.split_to(newline_index + 1); - let line = &line[..line.len() - 1]; - let line = without_carriage_return(line); - - // Use compatibility handling function - let item = match try_parse_with_compatibility(line, "decode")? { - Some(item) => item, - None => return Ok(None), // Skip non-standard message - }; - return Ok(Some(item)); - } - (false, None) if buf.len() > self.max_length => { - // Reached the maximum length without finding a - // newline, return an error and start discarding on the - // next call. - self.is_discarding = true; - return Err(JsonRpcMessageCodecError::MaxLineLengthExceeded); - } - (false, None) => { - // We didn't find a line or reach the length limit, so the next - // call will resume searching at the current offset. - self.next_index = read_to; - return Ok(None); - } - } - } - } - - fn decode_eof(&mut self, buf: &mut BytesMut) -> Result, JsonRpcMessageCodecError> { - Ok(match self.decode(buf)? { - Some(frame) => Some(frame), - None => { - self.next_index = 0; - // No terminating newline - return remaining data, if any - if buf.is_empty() || buf == &b"\r"[..] { - None - } else { - let line = buf.split_to(buf.len()); - let line = without_carriage_return(&line); - - // Use compatibility handling function - let item = match try_parse_with_compatibility(line, "decode_eof")? { - Some(item) => item, - None => return Ok(None), // Skip non-standard message - }; - Some(item) - } - } - }) - } -} - -impl Encoder for JsonRpcMessageCodec { - type Error = JsonRpcMessageCodecError; - - fn encode(&mut self, item: T, buf: &mut BytesMut) -> Result<(), JsonRpcMessageCodecError> { - serde_json::to_writer(buf.writer(), &item)?; - buf.put_u8(b'\n'); - Ok(()) - } -} - -#[cfg(test)] -mod test { - use futures::{Sink, Stream, StreamExt}; - use tokio_util::codec::FramedRead; - - use super::*; - fn from_async_read(reader: R) -> impl Stream { - FramedRead::new(reader, JsonRpcMessageCodec::::default()).filter_map(|result| { - if let Err(e) = &result { - tracing::error!("Error reading from stream: {}", e); - } - futures::future::ready(result.ok()) - }) - } - - fn from_async_write( - writer: W, - ) -> impl Sink { - FramedWrite::new(writer, JsonRpcMessageCodec::::default()).sink_map_err(Into::into) - } - #[tokio::test] - async fn test_decode() { - use futures::StreamExt; - use tokio::io::BufReader; - - let data = r#"{"jsonrpc":"2.0","method":"subtract","params":[42,23],"id":1} - {"jsonrpc":"2.0","method":"subtract","params":[23,42],"id":2} - {"jsonrpc":"2.0","method":"subtract","params":[42,23],"id":3} - {"jsonrpc":"2.0","method":"subtract","params":[23,42],"id":4} - {"jsonrpc":"2.0","method":"subtract","params":[42,23],"id":5} - {"jsonrpc":"2.0","method":"subtract","params":[23,42],"id":6} - {"jsonrpc":"2.0","method":"subtract","params":[42,23],"id":7} - {"jsonrpc":"2.0","method":"subtract","params":[23,42],"id":8} - {"jsonrpc":"2.0","method":"subtract","params":[42,23],"id":9} - {"jsonrpc":"2.0","method":"subtract","params":[23,42],"id":10} - - "#; - - let mut cursor = BufReader::new(data.as_bytes()); - let mut stream = from_async_read::(&mut cursor); - - for i in 1..=10 { - let item = stream.next().await.unwrap(); - assert_eq!( - item, - serde_json::json!({ - "jsonrpc": "2.0", - "method": "subtract", - "params": if i % 2 != 0 { [42, 23] } else { [23, 42] }, - "id": i, - }) - ); - } - } - - #[tokio::test] - async fn test_encode() { - let test_messages = vec![ - serde_json::json!({ - "jsonrpc": "2.0", - "method": "subtract", - "params": [42, 23], - "id": 1, - }), - serde_json::json!({ - "jsonrpc": "2.0", - "method": "subtract", - "params": [23, 42], - "id": 2, - }), - ]; - - // Create a buffer to write to - let mut buffer = Vec::new(); - let mut writer = from_async_write(&mut buffer); - - // Write the test messages - for message in test_messages.iter() { - writer.send(message.clone()).await.unwrap(); - } - writer.close().await.unwrap(); - drop(writer); - // Parse the buffer back into lines and check each one - let output = String::from_utf8_lossy(&buffer); - let mut lines = output.lines(); - - for expected_message in test_messages { - let line = lines.next().unwrap(); - let parsed_message: serde_json::Value = serde_json::from_str(line).unwrap(); - assert_eq!(parsed_message, expected_message); - } - - // Make sure there are no extra lines - assert!(lines.next().is_none()); - } - - #[test] - fn test_standard_notification_check() { - // Test that all standard notifications are recognized - assert!(is_standard_notification("notifications/cancelled")); - assert!(is_standard_notification("notifications/initialized")); - assert!(is_standard_notification("notifications/progress")); - assert!(is_standard_notification( - "notifications/resources/list_changed" - )); - assert!(is_standard_notification("notifications/resources/updated")); - assert!(is_standard_notification( - "notifications/prompts/list_changed" - )); - assert!(is_standard_notification("notifications/tools/list_changed")); - assert!(is_standard_notification("notifications/message")); - assert!(is_standard_notification("notifications/roots/list_changed")); - assert!(is_standard_notification( - "notifications/subscriptions/acknowledged" - )); - - // Test that non-standard notifications are not recognized - assert!(!is_standard_notification("notifications/stderr")); - assert!(!is_standard_notification("notifications/custom")); - assert!(!is_standard_notification("notifications/debug")); - assert!(!is_standard_notification("some/other/method")); - } - - #[test] - fn test_2026_07_28_standard_method_check() { - for method in [ - "elicitation/create", - "server/discover", - "subscriptions/listen", - ] { - assert!(is_standard_method(method), "{method} should be standard"); - } - } - - #[test] - fn test_current_standard_notification_is_not_ignored() { - let method = "notifications/subscriptions/acknowledged"; - let notification = serde_json::json!({ - "jsonrpc": "2.0", - "method": method, - }); - - assert!(!should_ignore_notification(¬ification, method)); - } - - #[test] - fn test_unknown_notification_is_ignored() { - let method = "notifications/custom"; - let notification = serde_json::json!({ - "jsonrpc": "2.0", - "method": method, - }); - - assert!(should_ignore_notification(¬ification, method)); - } - - #[test] - fn test_compatibility_function() { - // Test the compatibility function directly - let stderr_message = - r#"{"method":"notifications/stderr","params":{"content":"stderr message"}}"#; - let custom_message = r#"{"method":"notifications/custom","params":{"data":"custom"}}"#; - let standard_message = - r#"{"method":"notifications/message","params":{"level":"info","data":"standard"}}"#; - let progress_message = r#"{"method":"notifications/progress","params":{"progressToken":"token","progress":50}}"#; - - // Test with valid JSON - all should parse successfully - let result1 = - try_parse_with_compatibility::(stderr_message.as_bytes(), "test"); - let result2 = - try_parse_with_compatibility::(custom_message.as_bytes(), "test"); - let result3 = - try_parse_with_compatibility::(standard_message.as_bytes(), "test"); - let result4 = - try_parse_with_compatibility::(progress_message.as_bytes(), "test"); - - // All should parse successfully since they're valid JSON - assert!(result1.is_ok()); - assert!(result2.is_ok()); - assert!(result3.is_ok()); - assert!(result4.is_ok()); - - // Standard notifications should return Some(value) - assert!(result3.unwrap().is_some()); - assert!(result4.unwrap().is_some()); - - println!("Standard notifications are preserved, non-standard are handled gracefully"); - } - - #[tokio::test] - async fn test_decode_strips_utf8_bom() { - use futures::StreamExt; - use tokio::io::BufReader; - - // Valid JSON-RPC message preceded by a UTF-8 BOM (EF BB BF). Some Windows - // tooling and editors prepend this; the codec should ignore it per RFC 8259 §8.1. - let mut data = Vec::new(); - data.extend_from_slice(UTF8_BOM); - data.extend_from_slice(br#"{"jsonrpc":"2.0","method":"ping","id":1}"#); - data.push(b'\n'); - - let mut cursor = BufReader::new(&data[..]); - let mut stream = from_async_read::(&mut cursor); - - let item = stream - .next() - .await - .expect("should decode BOM-prefixed line"); - assert_eq!( - item, - serde_json::json!({"jsonrpc": "2.0", "method": "ping", "id": 1}) - ); - } - - #[cfg(feature = "server")] - #[tokio::test] - async fn receive_ignores_parse_error() { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - use crate::{RoleServer, transport::Transport}; - - // Two paired streams: `server_io` is wrapped by the transport; the test - // drives `client_io` to act as the peer. - let (server_io, client_io) = tokio::io::duplex(4096); - let (server_r, server_w) = tokio::io::split(server_io); - let (mut client_r, mut client_w) = tokio::io::split(client_io); - - let mut transport = AsyncRwTransport::::new(server_r, server_w); - - client_w - .write_all( - b"not json\n{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n", - ) - .await - .unwrap(); - - // The unparsable line is skipped and the next valid message is still yielded. - let received = transport - .receive() - .await - .expect("transport should skip the invalid line and yield the next valid message"); - assert_eq!( - serde_json::to_value(&received).unwrap()["method"], - "notifications/initialized", - ); - - // No response is sent back for the unparsable message (issue #938). Dropping the - // transport closes its write side, so the peer reads to EOF and should see no bytes. - drop(transport); - let mut reply_buf = Vec::new(); - client_r.read_to_end(&mut reply_buf).await.unwrap(); - assert!( - reply_buf.is_empty(), - "expected no response to an unparsable message, got: {}", - String::from_utf8_lossy(&reply_buf), - ); - } - - #[cfg(feature = "server")] - #[tokio::test] - async fn receive_responds_to_protocol_error() { - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - - use crate::{RoleServer, transport::Transport}; - - let (server_io, client_io) = tokio::io::duplex(4096); - let (server_r, server_w) = tokio::io::split(server_io); - let (client_r, mut client_w) = tokio::io::split(client_io); - - let mut transport = AsyncRwTransport::::new(server_r, server_w); - - // Well-formed JSON that does not match the JSON-RPC message shape, followed by a - // valid notification. Unlike unparsable bytes, this is a protocol error: the - // transport should reply to it and still yield the next valid message. - client_w - .write_all( - b"{\"foo\":\"bar\"}\n{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n", - ) - .await - .unwrap(); - - let received = transport.receive().await.expect( - "transport should reply to the protocol error and yield the next valid message", - ); - assert_eq!( - serde_json::to_value(&received).unwrap()["method"], - "notifications/initialized", - ); - - // A protocol error gets an error response back (id omitted since it can't be read). - let mut reply_buf = Vec::new(); - let mut peer = BufReader::new(client_r); - peer.read_until(b'\n', &mut reply_buf).await.unwrap(); - let reply: serde_json::Value = serde_json::from_slice(&reply_buf).unwrap(); - assert_eq!( - reply, - serde_json::json!({ - "jsonrpc": "2.0", - "error": {"code": -32600, "message": "Invalid request"}, - }), - ); - } -} diff --git a/vendor/rmcp/src/transport/auth.rs b/vendor/rmcp/src/transport/auth.rs deleted file mode 100644 index 6f8987924..000000000 --- a/vendor/rmcp/src/transport/auth.rs +++ /dev/null @@ -1,8097 +0,0 @@ -use std::{ - collections::HashMap, - future::Future, - net::{IpAddr, Ipv4Addr, Ipv6Addr}, - pin::Pin, - sync::Arc, - time::{Duration, SystemTime, UNIX_EPOCH}, -}; - -use async_trait::async_trait; -use futures::StreamExt; -use oauth2::{ - AsyncHttpClient, AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, - EmptyExtraTokenFields, ExtraTokenFields, HttpRequest, HttpResponse, PkceCodeChallenge, - PkceCodeVerifier, RedirectUrl, RefreshToken, RequestTokenError, Scope, StandardTokenResponse, - TokenResponse, TokenUrl, - basic::{BasicErrorResponseType, BasicTokenType}, -}; -use reqwest::{ - Client as ReqwestClient, IntoUrl, StatusCode, Url, - header::{AUTHORIZATION, CONTENT_TYPE, LOCATION, WWW_AUTHENTICATE}, -}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use thiserror::Error; -use tokio::sync::{Mutex, RwLock}; -use tracing::{debug, warn}; - -use crate::transport::common::http_header::HEADER_MCP_PROTOCOL_VERSION; - -const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30); -const MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES: usize = 1024 * 1024; -const MAX_OAUTH_DISCOVERY_REDIRECTS: usize = 10; -const CLOUD_METADATA_HOSTS: &[&str] = &[ - "metadata", - "metadata.google.internal", - "metadata.azure.internal", -]; - -/// Redirect handling requested for an outbound OAuth HTTP operation. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -#[non_exhaustive] -pub enum OAuthHttpRedirectPolicy { - /// Follow redirects using the client's normal limits. - #[default] - Follow, - /// Return the redirect response without following its location. - Stop, -} - -/// A complete outbound HTTP operation requested by the OAuth implementation. -#[non_exhaustive] -pub struct OAuthHttpRequest { - /// HTTP request with an absolute URI and buffered body. - pub request: HttpRequest, - /// Redirect behavior required by the OAuth operation. - pub redirect_policy: OAuthHttpRedirectPolicy, - /// Suggested maximum duration for the operation, or no SDK-specified timeout. - /// Implementations with their own timeout policy may retain it instead. - pub timeout: Option, -} - -impl OAuthHttpRequest { - fn new(request: HttpRequest, redirect_policy: OAuthHttpRedirectPolicy) -> Self { - Self { - request, - redirect_policy, - timeout: Some(DEFAULT_HTTP_TIMEOUT), - } - } -} - -/// Type-erased error returned by an [`OAuthHttpClient`]. -pub type OAuthHttpClientError = Box; - -#[derive(Debug, Error)] -enum OAuthHttpError { - #[error("OAuth HTTP response body exceeds {0} bytes")] - ResponseBodyTooLarge(usize), - #[error("unexpected HTTP status {0}")] - UnexpectedStatus(StatusCode), - #[error("OAuth discovery redirect to non-same-origin URL rejected: {0}")] - CrossOriginRedirect(Url), - #[error("OAuth discovery exceeded {0} redirects")] - TooManyRedirects(usize), -} - -/// Future returned by [`OAuthHttpClient::execute`]. -pub type OAuthHttpClientFuture<'a> = - Pin> + Send + 'a>>; - -/// Executes every outbound HTTP request made by the OAuth state machine. -/// -/// Implementations may route requests through a remote execution environment. -/// They must honor the request's redirect policy and return the raw response -/// status, headers, and body. -pub trait OAuthHttpClient: Send + Sync { - /// Execute one OAuth HTTP operation. - fn execute(&self, request: OAuthHttpRequest) -> OAuthHttpClientFuture<'_>; -} - -struct ReqwestOAuthHttpClient { - follow_redirects: ReqwestClient, - stop_redirects: ReqwestClient, -} - -impl ReqwestOAuthHttpClient { - fn new(follow_redirects: ReqwestClient) -> Result { - let stop_redirects = ReqwestClient::builder() - .timeout(DEFAULT_HTTP_TIMEOUT) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|error| AuthError::InternalError(error.to_string()))?; - Ok(Self { - follow_redirects, - stop_redirects, - }) - } -} - -impl OAuthHttpClient for ReqwestOAuthHttpClient { - fn execute(&self, request: OAuthHttpRequest) -> OAuthHttpClientFuture<'_> { - Box::pin(async move { - let OAuthHttpRequest { - request, - redirect_policy, - .. - } = request; - let client = match redirect_policy { - OAuthHttpRedirectPolicy::Follow => &self.follow_redirects, - OAuthHttpRedirectPolicy::Stop => &self.stop_redirects, - }; - let request = reqwest::Request::try_from(request) - .map_err(|error| Box::new(error) as OAuthHttpClientError)?; - let response = client - .execute(request) - .await - .map_err(|error| Box::new(error) as OAuthHttpClientError)?; - - let mut builder = oauth2::http::Response::builder() - .status(response.status()) - .version(response.version()); - for (name, value) in response.headers() { - builder = builder.header(name, value); - } - let mut body = Vec::new(); - let mut body_stream = response.bytes_stream(); - while let Some(chunk) = body_stream.next().await { - let chunk = chunk.map_err(|error| Box::new(error) as OAuthHttpClientError)?; - if chunk.len() > MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES - body.len() { - return Err(Box::new(OAuthHttpError::ResponseBodyTooLarge( - MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES, - )) as OAuthHttpClientError); - } - body.extend_from_slice(&chunk); - } - builder - .body(body) - .map_err(|error| Box::new(error) as OAuthHttpClientError) - }) - } -} - -struct OAuth2HttpClient<'a> { - client: &'a dyn OAuthHttpClient, - redirect_policy: OAuthHttpRedirectPolicy, -} - -#[derive(Debug)] -struct OAuth2HttpClientError(OAuthHttpClientError); - -impl std::fmt::Display for OAuth2HttpClientError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("OAuth HTTP request failed") - } -} - -impl std::error::Error for OAuth2HttpClientError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - Some(self.0.as_ref()) - } -} - -impl<'c> AsyncHttpClient<'c> for OAuth2HttpClient<'_> { - type Error = OAuth2HttpClientError; - - type Future = std::pin::Pin< - Box> + Send + 'c>, - >; - - fn call(&'c self, request: HttpRequest) -> Self::Future { - Box::pin(async move { - self.client - .execute(OAuthHttpRequest::new(request, self.redirect_policy)) - .await - .map_err(OAuth2HttpClientError) - }) - } -} - -const DEFAULT_EXCHANGE_URL: &str = "http://localhost"; - -/// Default OIDC Dynamic Client Registration `application_type` (SEP-837) -const DEFAULT_APPLICATION_TYPE: &str = "native"; - -/// Stored credentials for OAuth2 authorization -#[derive(Clone, Serialize, Deserialize)] -#[non_exhaustive] -pub struct StoredCredentials { - pub client_id: String, - pub token_response: Option, - #[serde(default)] - pub granted_scopes: Vec, - #[serde(default)] - pub token_received_at: Option, - #[serde(default)] - pub issuer: Option, -} - -impl std::fmt::Debug for StoredCredentials { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("StoredCredentials") - .field("client_id", &self.client_id) - .field( - "token_response", - &self.token_response.as_ref().map(|_| "[REDACTED]"), - ) - .field("granted_scopes", &self.granted_scopes) - .field("token_received_at", &self.token_received_at) - .field("issuer", &self.issuer) - .finish() - } -} - -impl StoredCredentials { - /// Create a new `StoredCredentials` instance. - pub fn new( - client_id: String, - token_response: Option, - granted_scopes: Vec, - token_received_at: Option, - ) -> Self { - Self { - client_id, - token_response, - granted_scopes, - token_received_at, - issuer: None, - } - } - - pub fn with_issuer(mut self, issuer: Option) -> Self { - self.issuer = issuer; - self - } -} - -/// Trait for storing and retrieving OAuth2 credentials -/// -/// Implementations of this trait can provide custom storage backends -/// for OAuth2 credentials, such as file-based storage, keychain integration, -/// or database storage. -#[async_trait] -pub trait CredentialStore: Send + Sync { - async fn load(&self) -> Result, AuthError>; - - async fn save(&self, credentials: StoredCredentials) -> Result<(), AuthError>; - - async fn clear(&self) -> Result<(), AuthError>; -} - -/// In-memory credential store (default implementation) -/// -/// This store keeps credentials in memory only and does not persist them -/// between application restarts. This is the default behavior when no -/// custom credential store is provided. -#[derive(Debug, Default, Clone)] -pub struct InMemoryCredentialStore { - credentials: Arc>>, -} - -impl InMemoryCredentialStore { - pub fn new() -> Self { - Self { - credentials: Arc::new(RwLock::new(None)), - } - } -} - -#[async_trait::async_trait] -impl CredentialStore for InMemoryCredentialStore { - async fn load(&self) -> Result, AuthError> { - Ok(self.credentials.read().await.clone()) - } - - async fn save(&self, credentials: StoredCredentials) -> Result<(), AuthError> { - *self.credentials.write().await = Some(credentials); - Ok(()) - } - - async fn clear(&self) -> Result<(), AuthError> { - *self.credentials.write().await = None; - Ok(()) - } -} - -/// Stored authorization state for OAuth2 PKCE flow -#[derive(Clone, Serialize, Deserialize)] -#[non_exhaustive] -pub struct StoredAuthorizationState { - pub pkce_verifier: String, - pub csrf_token: String, - #[serde(default)] - pub expected_issuer: Option, - #[serde(default)] - pub require_issuer: bool, - pub created_at: u64, - /// scopes requested in this round, used to resolve the grant when the token response omits - /// `scope` (RFC 6749 §5.1) - #[serde(default)] - pub requested_scopes: Vec, -} - -impl std::fmt::Debug for StoredAuthorizationState { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("StoredAuthorizationState") - .field("pkce_verifier", &"[REDACTED]") - .field("csrf_token", &"[REDACTED]") - .field("expected_issuer", &self.expected_issuer) - .field("require_issuer", &self.require_issuer) - .field("created_at", &self.created_at) - .field("requested_scopes", &self.requested_scopes) - .finish() - } -} - -/// A transparent wrapper around a JSON object that captures any extra fields returned by the -/// authorization server during token exchange that are not part of the standard OAuth 2.0 token -/// response. -/// -/// OAuth providers may include non-standard fields alongside the -/// standard OAuth fields. Those fields are collected here so callers -/// can inspect them without losing data. -/// -/// The inner [`HashMap`] maps field names to their raw JSON values. -/// -/// # Accessing extra fields -/// -/// Extra fields are available through [`StandardTokenResponse::extra_fields()`], which returns a -/// reference to this struct. Use the inner map (`.0`) to look up individual fields by name: -/// -/// ```rust,ignore -/// // Obtain the token response from the AuthorizationManager, then: -/// if let Some(value) = token_response.extra_fields().0.get("vendorSpecificField") { -/// println!("vendorSpecificField = {value}"); -/// } -/// ``` -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[non_exhaustive] -pub struct VendorExtraTokenFields(pub HashMap); - -impl ExtraTokenFields for VendorExtraTokenFields {} - -impl StoredAuthorizationState { - pub fn new(pkce_verifier: &PkceCodeVerifier, csrf_token: &CsrfToken) -> Self { - Self::new_with_expected_issuer(pkce_verifier, csrf_token, None, false) - } - - pub fn new_with_expected_issuer( - pkce_verifier: &PkceCodeVerifier, - csrf_token: &CsrfToken, - expected_issuer: Option, - require_issuer: bool, - ) -> Self { - Self { - pkce_verifier: pkce_verifier.secret().to_string(), - csrf_token: csrf_token.secret().to_string(), - expected_issuer, - require_issuer, - created_at: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0), - requested_scopes: Vec::new(), - } - } - - /// record the scopes requested in this authorization round (SEP-2350) - pub fn with_requested_scopes(mut self, scopes: Vec) -> Self { - self.requested_scopes = scopes; - self - } - - pub fn into_pkce_verifier(self) -> PkceCodeVerifier { - PkceCodeVerifier::new(self.pkce_verifier) - } -} - -/// Trait for storing and retrieving OAuth2 authorization state -/// -/// Implementations of this trait can provide custom storage backends -/// for OAuth2 PKCE flow state, such as Redis or database storage. -/// -/// Implementors are responsible for expiring stale states (e.g., abandoned -/// authorization flows). Use [`StoredAuthorizationState::created_at`] for -/// TTL-based expiration. -#[async_trait] -pub trait StateStore: Send + Sync { - async fn save( - &self, - csrf_token: &str, - state: StoredAuthorizationState, - ) -> Result<(), AuthError>; - - async fn load(&self, csrf_token: &str) -> Result, AuthError>; - - async fn delete(&self, csrf_token: &str) -> Result<(), AuthError>; -} - -/// In-memory state store (default implementation) -/// -/// This store keeps authorization state in memory only and does not persist -/// between application restarts or across multiple server instances. -#[derive(Debug, Default, Clone)] -pub struct InMemoryStateStore { - states: Arc>>, -} - -impl InMemoryStateStore { - pub fn new() -> Self { - Self { - states: Arc::new(RwLock::new(HashMap::new())), - } - } -} - -#[async_trait] -impl StateStore for InMemoryStateStore { - async fn save( - &self, - csrf_token: &str, - state: StoredAuthorizationState, - ) -> Result<(), AuthError> { - self.states - .write() - .await - .insert(csrf_token.to_string(), state); - Ok(()) - } - - async fn load(&self, csrf_token: &str) -> Result, AuthError> { - Ok(self.states.read().await.get(csrf_token).cloned()) - } - - async fn delete(&self, csrf_token: &str) -> Result<(), AuthError> { - self.states.write().await.remove(csrf_token); - Ok(()) - } -} - -/// HTTP client with OAuth 2.0 authorization -#[derive(Clone)] -#[non_exhaustive] -pub struct AuthClient { - pub http_client: C, - pub auth_manager: Arc>, -} - -impl std::fmt::Debug for AuthClient { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("AuthorizedClient") - .field("http_client", &self.http_client) - .field("auth_manager", &"...") - .finish() - } -} - -impl AuthClient { - /// Create a new authorized HTTP client - pub fn new(http_client: C, auth_manager: AuthorizationManager) -> Self { - Self { - http_client, - auth_manager: Arc::new(Mutex::new(auth_manager)), - } - } -} - -impl AuthClient { - pub fn get_access_token(&self) -> impl Future> + Send { - let auth_manager = self.auth_manager.clone(); - async move { auth_manager.lock().await.get_access_token().await } - } -} - -/// Auth error -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum AuthError { - #[error("OAuth authorization required")] - AuthorizationRequired, - - #[error("OAuth authorization failed: {0}")] - AuthorizationFailed(String), - - #[error("OAuth token exchange failed: {0}")] - TokenExchangeFailed(String), - - /// The refresh attempt failed without a definitive refresh-token rejection. - /// - /// Callers may retry this error because it includes transient request and provider failures. - #[error("OAuth token refresh failed: {0}")] - TokenRefreshFailed(String), - - /// The authorization server definitively rejected the refresh token. - #[error("OAuth refresh token was rejected: {0}")] - TokenRefreshRejected(String), - - #[error("HTTP error: {0}")] - HttpError(#[from] reqwest::Error), - - #[error("OAuth error: {0}")] - OAuthError(String), - - #[error("Metadata error: {0}")] - MetadataError(String), - - #[error("Authorization server does not support the required PKCE code challenge method (S256)")] - PkceUnsupported, - - #[error("URL parse error: {0}")] - UrlError(#[from] url::ParseError), - - #[error("No authorization support detected")] - NoAuthorizationSupport, - - #[error("Internal error: {0}")] - InternalError(String), - - #[error("Invalid token type: {0}")] - InvalidTokenType(String), - - #[error("Token expired")] - TokenExpired, - - #[error("Invalid scope: {0}")] - InvalidScope(String), - - #[error("Registration failed: {0}")] - RegistrationFailed(String), - - #[error("Insufficient scope: {required_scope}")] - InsufficientScope { - required_scope: String, - upgrade_url: Option, - }, - - #[error( - "Authorization server issuer mismatch: expected {expected_issuer}, received {received_issuer}" - )] - AuthorizationServerMismatch { - expected_issuer: String, - received_issuer: String, - }, - - #[error("Authorization server response missing required issuer: expected {expected_issuer}")] - AuthorizationServerMissingIssuer { expected_issuer: String }, - - #[error("Client credentials error: {0}")] - ClientCredentialsError(String), - - #[cfg(feature = "auth-client-credentials-jwt")] - #[error("JWT signing error: {0}")] - JwtSigningError(String), -} - -/// oauth2 metadata -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -#[non_exhaustive] -pub struct AuthorizationMetadata { - pub authorization_endpoint: String, - pub token_endpoint: String, - pub registration_endpoint: Option, - pub issuer: Option, - pub jwks_uri: Option, - pub scopes_supported: Option>, - pub response_types_supported: Option>, - pub code_challenge_methods_supported: Option>, - // allow additional fields - #[serde(flatten)] - pub additional_fields: HashMap, -} - -/// How [`AuthorizationMetadata`] was obtained during discovery. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] -pub enum AuthorizationMetadataSource { - /// Discovered through RFC 9728 protected resource metadata. - ProtectedResourceMetadata, - /// Discovered through RFC 8414 / OpenID Connect metadata at the server's - /// base URL. - AuthorizationServerMetadata, - /// Nothing was discovered; the endpoints were synthesized from the base - /// URL (`/authorize`, `/token`, `/register`) for compatibility with the - /// 2025-03-26 MCP spec's default-endpoint fallback. The server gave no - /// evidence that it supports OAuth. - /// - /// [Newer MCP revisions] require metadata discovery and do not define an - /// endpoint-synthesis fallback. - /// - /// [Newer MCP revisions]: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery#protected-resource-metadata-discovery-requirements - LegacyEndpointFallback, -} - -impl AuthorizationMetadataSource { - /// Whether the metadata was actually published by the server, as opposed - /// to synthesized by the client as a legacy compatibility fallback. - pub fn is_discovered(self) -> bool { - !matches!(self, Self::LegacyEndpointFallback) - } -} - -/// [`AuthorizationMetadata`] together with how it was resolved. -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct AuthorizationMetadataResolution { - pub metadata: AuthorizationMetadata, - pub source: AuthorizationMetadataSource, -} - -#[derive(Debug, Clone, Deserialize)] -struct ResourceServerMetadata { - resource: Option, - authorization_server: Option, - authorization_servers: Option>, - scopes_supported: Option>, -} - -/// Parameters extracted from WWW-Authenticate header -#[derive(Debug, Clone, Default)] -#[non_exhaustive] -pub struct WWWAuthenticateParams { - pub resource_metadata_url: Option, - pub scope: Option, - pub error: Option, - pub error_description: Option, -} - -impl WWWAuthenticateParams { - /// Parse a `WWW-Authenticate` header value, resolving a relative - /// `resource_metadata` URL against `base_url`. - pub fn parse(header: &str, base_url: &Url) -> Self { - AuthorizationManager::extract_www_authenticate_params(header, base_url) - } - - /// check if this is an insufficient_scope error - pub fn is_insufficient_scope(&self) -> bool { - self.error.as_deref() == Some("insufficient_scope") - } - - /// check if this is an invalid_token error (expired/revoked) - pub fn is_invalid_token(&self) -> bool { - self.error.as_deref() == Some("invalid_token") - } -} - -/// oauth2 client config -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct OAuthClientConfig { - pub client_id: String, - pub client_secret: Option, - pub scopes: Vec, - pub redirect_uri: String, - pub application_type: Option, -} - -impl OAuthClientConfig { - pub fn new(client_id: impl Into, redirect_uri: impl Into) -> Self { - Self { - client_id: client_id.into(), - client_secret: None, - scopes: Vec::new(), - redirect_uri: redirect_uri.into(), - application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()), - } - } - - pub fn with_client_secret(mut self, secret: impl Into) -> Self { - self.client_secret = Some(secret.into()); - self - } - - pub fn with_scopes(mut self, scopes: Vec) -> Self { - self.scopes = scopes; - self - } - - /// Set the OIDC Dynamic Client Registration `application_type` (SEP-837), e.g. `"native"` or `"web"` - pub fn with_application_type(mut self, application_type: impl Into) -> Self { - self.application_type = Some(application_type.into()); - self - } -} - -/// Declarative description of the client identity material available for an -/// authorization flow. -/// -/// The [MCP authorization specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration) -/// recommends that clients obtain a client ID using the following priority -/// order. [`OAuthState::start_authorization`] and [`AuthorizationSession::new`] -/// apply it internally: -/// -/// 1. Pre-registered client information -/// ([`with_preregistered_client`](Self::with_preregistered_client)), when -/// the client already holds a `client_id` issued out of band -/// 2. Client ID Metadata Documents (SEP-991, -/// [`with_client_metadata_url`](Self::with_client_metadata_url)), when the -/// authorization server advertises `client_id_metadata_document_supported` -/// 3. Dynamic Client Registration as a fallback, when the authorization server -/// advertises a `registration_endpoint` -/// -/// Provide whichever identity material the client has available; the SDK -/// selects the highest-priority mechanism the server supports. -/// -/// ```rust,ignore -/// let request = AuthorizationRequest::new("http://localhost:8080/callback") -/// // Omit `with_scopes` to let the SDK auto-select scopes from server metadata. -/// // Used when the server supports CIMD and no pre-registered client is set. -/// .with_client_metadata_url("https://example.com/client-metadata.json") -/// // used for dynamic client registration as a last resort -/// .with_client_name("My MCP Client"); -/// oauth_state.start_authorization(request).await?; -/// ``` -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct AuthorizationRequest { - /// Redirect URI for the authorization-code flow. - pub redirect_uri: String, - /// Scopes to request. When empty, the SDK selects scopes from the - /// server's `WWW-Authenticate` challenge, Protected Resource Metadata, - /// or authorization server metadata. - pub scopes: Vec, - /// Human-readable client name, used for Dynamic Client Registration. - pub client_name: Option, - /// Pre-registered client ID obtained from the authorization server out of - /// band. When set, registration is skipped entirely. - pub client_id: Option, - /// Client secret paired with the pre-registered [`client_id`](Self::client_id). - pub client_secret: Option, - /// HTTPS URL of a Client ID Metadata Document (SEP-991). Used when the - /// authorization server advertises `client_id_metadata_document_supported` - /// and no pre-registered client is configured. - pub client_metadata_url: Option, - /// OIDC Dynamic Client Registration `application_type` (SEP-837), - /// e.g. `"native"` or `"web"`. - pub application_type: Option, - /// `WWW-Authenticate` header value from a real request's 401 response. - /// When set, discovery is seeded from the challenge (its - /// `resource_metadata` URL and `scope` hint) instead of probing the - /// server — the reactive discovery path. - pub challenge: Option, -} - -impl AuthorizationRequest { - /// Create a request for the given redirect URI. With no further identity - /// material, authorization falls back to Dynamic Client Registration. - pub fn new(redirect_uri: impl Into) -> Self { - Self { - redirect_uri: redirect_uri.into(), - scopes: Vec::new(), - client_name: None, - client_id: None, - client_secret: None, - client_metadata_url: None, - application_type: None, - challenge: None, - } - } - - /// Set the scopes to request. When not set, the SDK auto-selects scopes - /// using its normal scope-selection policy. - pub fn with_scopes(mut self, scopes: I) -> Self - where - I: IntoIterator, - S: Into, - { - self.scopes = scopes.into_iter().map(Into::into).collect(); - self - } - - /// Set the client name used for Dynamic Client Registration. - pub fn with_client_name(mut self, client_name: impl Into) -> Self { - self.client_name = Some(client_name.into()); - self - } - - /// Use a client ID that was pre-registered with the authorization server - /// out of band. This takes priority over every other mechanism. - /// - /// Pair with [`with_client_secret`](Self::with_client_secret) for - /// confidential clients. - pub fn with_preregistered_client(mut self, client_id: impl Into) -> Self { - self.client_id = Some(client_id.into()); - self - } - - /// Set the client secret paired with a pre-registered client ID. - /// - /// Must be used together with - /// [`with_preregistered_client`](Self::with_preregistered_client); - /// authorization fails with [`AuthError::RegistrationFailed`] if a secret - /// is provided without a client ID. - pub fn with_client_secret(mut self, client_secret: impl Into) -> Self { - self.client_secret = Some(client_secret.into()); - self - } - - /// Set the HTTPS URL of a Client ID Metadata Document (SEP-991). Used when - /// the authorization server advertises support and no pre-registered - /// client is configured. - pub fn with_client_metadata_url(mut self, client_metadata_url: impl Into) -> Self { - self.client_metadata_url = Some(client_metadata_url.into()); - self - } - - /// Set the OIDC Dynamic Client Registration `application_type` (SEP-837), - /// e.g. `"native"` or `"web"`. - pub fn with_application_type(mut self, application_type: impl Into) -> Self { - self.application_type = Some(application_type.into()); - self - } - - /// Seed discovery from the `WWW-Authenticate` header value of a real - /// request's 401 response instead of probing the server. - pub fn with_challenge(mut self, www_authenticate: impl Into) -> Self { - self.challenge = Some(www_authenticate.into()); - self - } -} - -// add type aliases for oauth2 types -type OAuthErrorResponse = oauth2::StandardErrorResponse; - -/// The token response returned by the authorization server after a successful OAuth 2.0 flow. -/// -/// This is a [`StandardTokenResponse`] parameterised with [`VendorExtraTokenFields`], which means -/// it carries both the standard OAuth fields and -/// any vendor-specific fields the server may have included in the JSON response body. -/// -/// # Accessing vendor-specific fields -/// -/// Call [`extra_fields()`][OAuthTokenResponse::extra_fields] to obtain a reference to the -/// [`VendorExtraTokenFields`] wrapper, then index into its inner map. -pub type OAuthTokenResponse = StandardTokenResponse; -type OAuthTokenIntrospection = - oauth2::StandardTokenIntrospectionResponse; -type OAuthRevocableToken = oauth2::StandardRevocableToken; -type OAuthRevocationError = oauth2::StandardErrorResponse; -type OAuthClient = oauth2::Client< - OAuthErrorResponse, - OAuthTokenResponse, - OAuthTokenIntrospection, - OAuthRevocableToken, - OAuthRevocationError, - oauth2::EndpointSet, - oauth2::EndpointNotSet, - oauth2::EndpointNotSet, - oauth2::EndpointNotSet, - oauth2::EndpointSet, ->; -type Credentials = (String, Option); - -/// OAuth 2.0 extension identifier for client credentials flow (SEP-1046) -pub const EXTENSION_OAUTH_CLIENT_CREDENTIALS: &str = - "io.modelcontextprotocol/oauth-client-credentials"; - -/// JWT signing algorithm for private_key_jwt authentication (SEP-1046) -#[cfg(feature = "auth-client-credentials-jwt")] -#[derive(Debug, Clone, Copy)] -#[non_exhaustive] -pub enum JwtSigningAlgorithm { - RS256, - RS384, - RS512, - ES256, - ES384, -} - -#[cfg(feature = "auth-client-credentials-jwt")] -impl JwtSigningAlgorithm { - fn to_jsonwebtoken_algorithm(self) -> jsonwebtoken::Algorithm { - match self { - JwtSigningAlgorithm::RS256 => jsonwebtoken::Algorithm::RS256, - JwtSigningAlgorithm::RS384 => jsonwebtoken::Algorithm::RS384, - JwtSigningAlgorithm::RS512 => jsonwebtoken::Algorithm::RS512, - JwtSigningAlgorithm::ES256 => jsonwebtoken::Algorithm::ES256, - JwtSigningAlgorithm::ES384 => jsonwebtoken::Algorithm::ES384, - } - } - - fn as_str(self) -> &'static str { - match self { - JwtSigningAlgorithm::RS256 => "RS256", - JwtSigningAlgorithm::RS384 => "RS384", - JwtSigningAlgorithm::RS512 => "RS512", - JwtSigningAlgorithm::ES256 => "ES256", - JwtSigningAlgorithm::ES384 => "ES384", - } - } -} - -/// Configuration for OAuth 2.0 Client Credentials flow (SEP-1046) -/// -/// This supports two authentication methods: -/// - `ClientSecret`: credentials sent in the request body -/// - `PrivateKeyJwt`: RFC 7523 signed JWT assertion (requires `auth-client-credentials-jwt` feature) -#[derive(Debug, Clone)] -#[non_exhaustive] -pub enum ClientCredentialsConfig { - /// Client secret authentication (credentials in request body) - ClientSecret { - client_id: String, - client_secret: String, - scopes: Vec, - resource: Option, - }, - /// Private key JWT authentication (RFC 7523) - #[cfg(feature = "auth-client-credentials-jwt")] - PrivateKeyJwt { - client_id: String, - signing_key: Vec, - signing_algorithm: JwtSigningAlgorithm, - /// Overrides the authorization server issuer used for the JWT `aud` claim. - token_endpoint_audience: Option, - scopes: Vec, - resource: Option, - }, -} - -#[cfg(feature = "auth-client-credentials-jwt")] -fn client_authentication_audience<'a>( - metadata: &'a AuthorizationMetadata, - configured_audience: Option<&'a str>, -) -> Result<&'a str, AuthError> { - configured_audience - .or(metadata.issuer.as_deref()) - .ok_or_else(|| { - AuthError::ClientCredentialsError( - "Authorization server metadata is missing the issuer required for private_key_jwt" - .to_string(), - ) - }) -} - -#[cfg(feature = "auth-client-credentials-jwt")] -fn client_authentication_header(algorithm: JwtSigningAlgorithm) -> jsonwebtoken::Header { - let mut header = jsonwebtoken::Header::new(algorithm.to_jsonwebtoken_algorithm()); - header.typ = Some("client-authentication+jwt".to_string()); - header -} - -impl ClientCredentialsConfig { - fn client_id(&self) -> &str { - match self { - ClientCredentialsConfig::ClientSecret { client_id, .. } => client_id, - #[cfg(feature = "auth-client-credentials-jwt")] - ClientCredentialsConfig::PrivateKeyJwt { client_id, .. } => client_id, - } - } - - fn scopes(&self) -> &[String] { - match self { - ClientCredentialsConfig::ClientSecret { scopes, .. } => scopes, - #[cfg(feature = "auth-client-credentials-jwt")] - ClientCredentialsConfig::PrivateKeyJwt { scopes, .. } => scopes, - } - } - - fn resource(&self) -> Option<&str> { - match self { - ClientCredentialsConfig::ClientSecret { resource, .. } => resource.as_deref(), - #[cfg(feature = "auth-client-credentials-jwt")] - ClientCredentialsConfig::PrivateKeyJwt { resource, .. } => resource.as_deref(), - } - } - - fn auth_method(&self) -> &str { - match self { - ClientCredentialsConfig::ClientSecret { .. } => "client_secret_post", - #[cfg(feature = "auth-client-credentials-jwt")] - ClientCredentialsConfig::PrivateKeyJwt { .. } => "private_key_jwt", - } - } -} - -/// Configuration for scope upgrade behavior -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct ScopeUpgradeConfig { - /// Maximum number of scope upgrade attempts before giving up - pub max_upgrade_attempts: u32, - /// Whether to automatically attempt scope upgrades on 403 - pub auto_upgrade: bool, -} - -impl Default for ScopeUpgradeConfig { - fn default() -> Self { - Self { - max_upgrade_attempts: 3, - auto_upgrade: true, - } - } -} - -/// oauth2 auth manager -pub struct AuthorizationManager { - http_client: Arc, - // Preserve legacy reqwest refresh behavior without weakening custom clients. - refresh_redirect_policy: OAuthHttpRedirectPolicy, - metadata: Option, - oauth_client: Option, - credential_store: Arc, - state_store: Arc, - base_url: Url, - current_scopes: RwLock>, - scope_upgrade_attempts: RwLock, - scope_upgrade_config: ScopeUpgradeConfig, - /// scopes from the initial 401 WWW-Authenticate header, used by select_scopes() - www_auth_scopes: RwLock>, - /// scopes_supported from protected resource metadata (RFC 9728) - resource_scopes: RwLock>, - /// resource indicator from protected resource metadata, used for RFC 8707 `resource` - discovered_resource: RwLock>, - /// OIDC Dynamic Client Registration `application_type` (SEP-837) - application_type: Option, - allow_missing_issuer: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub(crate) struct ClientRegistrationRequest { - pub client_name: String, - pub redirect_uris: Vec, - pub grant_types: Vec, - pub token_endpoint_auth_method: String, - pub response_types: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub scope: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub application_type: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[non_exhaustive] -pub struct ClientRegistrationResponse { - pub client_id: String, - pub client_secret: Option, - pub client_name: Option, - pub redirect_uris: Vec, - // allow additional fields - #[serde(flatten)] - pub additional_fields: HashMap, -} - -impl ClientRegistrationResponse { - pub fn new(client_id: impl Into, redirect_uris: Vec) -> Self { - Self { - client_id: client_id.into(), - client_secret: None, - client_name: None, - redirect_uris, - additional_fields: HashMap::new(), - } - } -} - -/// SEP-991: URL-based Client IDs -/// Validate that the client_id is a valid URL with https scheme and non-root pathname -fn is_https_url(value: &str) -> bool { - Url::parse(value) - .ok() - .map(|url| url.scheme() == "https" && url.path() != "/" && url.host_str().is_some()) - .unwrap_or(false) -} - -#[cfg(feature = "auth-client-credentials-jwt")] -fn is_allowed_client_credentials_endpoint(resource: &Url, token_endpoint: &Url) -> bool { - token_endpoint.scheme() == "https" - || (token_endpoint.scheme() == "http" - && resource - .host_str() - .is_some_and(AuthorizationManager::is_loopback_metadata_host) - && token_endpoint - .host_str() - .is_some_and(AuthorizationManager::is_loopback_metadata_host)) -} - -impl AuthorizationManager { - fn is_http_url(url: &Url) -> bool { - matches!(url.scheme(), "http" | "https") && url.host_str().is_some() - } - - fn is_same_origin(base: &Url, candidate: &Url) -> bool { - base.scheme() == candidate.scheme() - && base - .host_str() - .zip(candidate.host_str()) - .is_some_and(|(base, candidate)| base.eq_ignore_ascii_case(candidate)) - && base.port_or_known_default() == candidate.port_or_known_default() - } - - fn is_same_origin_resource_metadata_url(base_url: &Url, candidate: &Url) -> bool { - Self::is_http_url(candidate) && Self::is_same_origin(base_url, candidate) - } - - fn is_disallowed_metadata_ipv4(addr: Ipv4Addr) -> bool { - let octets = addr.octets(); - addr.is_private() - || addr.is_loopback() - || addr.is_link_local() - || addr.is_broadcast() - || addr.is_unspecified() - || addr.is_multicast() - || octets[0] == 0 - || (octets[0] == 100 && (64..=127).contains(&octets[1])) - || (octets[0] == 198 && matches!(octets[1], 18 | 19)) - } - - fn is_disallowed_metadata_ipv6(addr: Ipv6Addr) -> bool { - if let Some(mapped) = addr.to_ipv4_mapped() { - return Self::is_disallowed_metadata_ipv4(mapped); - } - - let segments = addr.segments(); - addr.is_loopback() - || addr.is_unspecified() - || addr.is_multicast() - || (segments[0] & 0xffc0) == 0xfe80 - || (segments[0] & 0xfe00) == 0xfc00 - } - - fn is_disallowed_metadata_hostname(host: &str) -> bool { - matches!(host, "localhost") - || host.ends_with(".localhost") - || CLOUD_METADATA_HOSTS.contains(&host) - } - - fn is_disallowed_metadata_host(host: &str) -> bool { - let host = host.trim_end_matches('.').to_ascii_lowercase(); - if Self::is_disallowed_metadata_hostname(&host) { - return true; - } - - match host.parse::() { - Ok(IpAddr::V4(addr)) => Self::is_disallowed_metadata_ipv4(addr), - Ok(IpAddr::V6(addr)) => Self::is_disallowed_metadata_ipv6(addr), - Err(_) => false, - } - } - - fn is_loopback_metadata_host(host: &str) -> bool { - let host = host.trim_end_matches('.').to_ascii_lowercase(); - host == "localhost" - || host.ends_with(".localhost") - || matches!(host.parse::(), Ok(IpAddr::V4(addr)) if addr.is_loopback()) - || matches!(host.parse::(), Ok(IpAddr::V6(addr)) if addr.is_loopback()) - } - - fn is_allowed_authorization_server_metadata_url(base_url: &Url, url: &Url) -> bool { - if !Self::is_http_url(url) { - return false; - } - - let Some(host) = url.host_str() else { - return false; - }; - - if !Self::is_disallowed_metadata_host(host) { - return true; - } - - base_url - .host_str() - .is_some_and(Self::is_loopback_metadata_host) - && Self::is_loopback_metadata_host(host) - } - - fn resolve_resource_metadata_url(value: &str, base_url: &Url) -> Option { - let value = value.trim(); - if value.is_empty() { - debug!("ignoring empty resource_metadata value"); - return None; - } - - let url = match Url::parse(value).or_else(|_| base_url.join(value)) { - Ok(url) => url, - Err(error) => { - debug!("failed to parse resource metadata value `{value}` as URL: {error}"); - return None; - } - }; - - if Self::is_same_origin_resource_metadata_url(base_url, &url) { - Some(url) - } else { - warn!( - "rejecting resource metadata URL `{url}` because it is not same-origin with `{base_url}`" - ); - None - } - } - - fn well_known_paths(base_path: &str, resource: &str) -> Vec { - let trimmed = base_path.trim_start_matches('/').trim_end_matches('/'); - let mut candidates = Vec::new(); - - let mut push_candidate = |candidate: String| { - if !candidates.contains(&candidate) { - candidates.push(candidate); - } - }; - - let canonical = format!("/.well-known/{resource}"); - - if trimmed.is_empty() { - push_candidate(canonical); - return candidates; - } - - // This follows the RFC 8414 recommendation for well-known URI discovery - push_candidate(format!("{canonical}/{trimmed}")); - // This is a common pattern used by some identity providers - push_candidate(format!("/{trimmed}/.well-known/{resource}")); - // The canonical path should always be the last fallback - push_candidate(canonical); - - candidates - } - - /// create new auth manager with base url - pub async fn new(base_url: U) -> Result { - let http_client = ReqwestClient::builder() - .timeout(DEFAULT_HTTP_TIMEOUT) - .build() - .map_err(|e| AuthError::InternalError(e.to_string()))?; - Self::new_inner( - base_url, - Arc::new(ReqwestOAuthHttpClient::new(http_client)?), - OAuthHttpRedirectPolicy::Stop, - ) - .await - } - - /// Create an auth manager with a client used for every OAuth HTTP operation. - pub async fn new_with_oauth_http_client( - base_url: U, - http_client: Arc, - ) -> Result { - Self::new_inner(base_url, http_client, OAuthHttpRedirectPolicy::Stop).await - } - - async fn new_inner( - base_url: U, - http_client: Arc, - refresh_redirect_policy: OAuthHttpRedirectPolicy, - ) -> Result { - let base_url = base_url.into_url()?; - - let manager = Self { - http_client, - refresh_redirect_policy, - metadata: None, - oauth_client: None, - credential_store: Arc::new(InMemoryCredentialStore::new()), - state_store: Arc::new(InMemoryStateStore::new()), - base_url, - current_scopes: RwLock::new(Vec::new()), - scope_upgrade_attempts: RwLock::new(0), - scope_upgrade_config: ScopeUpgradeConfig::default(), - www_auth_scopes: RwLock::new(Vec::new()), - resource_scopes: RwLock::new(Vec::new()), - discovered_resource: RwLock::new(None), - application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()), - allow_missing_issuer: false, - }; - - Ok(manager) - } - - /// Set the scope upgrade configuration - pub fn set_scope_upgrade_config(&mut self, config: ScopeUpgradeConfig) { - self.scope_upgrade_config = config; - } - - /// Configure whether authorization server metadata discovery tolerates a - /// missing `issuer` field. - /// - /// The default is `false`, enforcing the RFC 8414/OIDC requirement that - /// discovered metadata include `issuer` whenever the expected issuer can be - /// derived from the discovery URL. Set this to `true` only for compatibility - /// with authorization servers that return incomplete metadata. - pub fn set_allow_missing_issuer(&mut self, allow: bool) { - self.allow_missing_issuer = allow; - } - - /// Set a custom credential store - /// - /// This allows you to provide your own implementation of credential storage, - /// such as file-based storage, keychain integration, or database storage. - /// This should be called before any operations that need credentials. - pub fn set_credential_store(&mut self, store: S) { - self.credential_store = Arc::new(store); - } - - /// Set a custom state store for OAuth2 authorization flow state - /// - /// This should be called before initiating the authorization flow. - pub fn set_state_store(&mut self, store: S) { - self.state_store = Arc::new(store); - } - - /// Set OAuth2 authorization metadata - /// - /// This should be called with - /// [`AuthorizationMetadataResolution::metadata`] after - /// [`Self::resolve_metadata`] and before creating an - /// [`AuthorizationSession`]. - pub fn set_metadata(&mut self, metadata: AuthorizationMetadata) { - self.metadata = Some(metadata); - } - - /// Initialize from stored credentials if available - /// - /// This will load credentials from the credential store and configure - /// the client if credentials are found. Returns `false` when credentials - /// are absent or discarded after an authorization-server change. - pub async fn initialize_from_store(&mut self) -> Result { - if let Some(stored) = self.credential_store.load().await? - && stored.token_response.is_some() - { - if self.metadata.is_none() { - let resolution = self.resolve_metadata().await?; - self.metadata = Some(resolution.metadata); - } - - if let (Some(stored_issuer), Some(current_issuer)) = - (stored.issuer.as_deref(), self.metadata_issuer().as_deref()) - { - // A CIMD client ID is the client's metadata URL, so it is - // portable across authorization servers and exempt here. - if stored_issuer != current_issuer { - if is_https_url(&stored.client_id) { - // A CIMD client ID is the client's metadata URL, so it is - // portable across authorization servers — but the tokens - // were minted by the previous AS and must not be reused. - tracing::warn!( - stored_issuer, - current_issuer, - "authorization server issuer changed; discarding tokens but keeping portable CIMD client ID" - ); - self.credential_store - .save( - StoredCredentials::new( - stored.client_id.clone(), - None, - vec![], - None, - ) - .with_issuer(self.metadata_issuer()), - ) - .await?; - self.configure_client_id(&stored.client_id)?; - return Ok(false); - } - - tracing::warn!( - stored_issuer, - current_issuer, - "authorization server issuer changed; clearing stored credentials bound to the previous issuer" - ); - self.credential_store.clear().await?; - return Ok(false); - } - } - - self.configure_client_id(&stored.client_id)?; - return Ok(true); - } - Ok(false) - } - - /// Use a caller-configured `reqwest::Client` for every OAuth HTTP operation, - /// preserving all of its settings (proxy, TLS, timeout, default headers). - /// - /// The same client is reused for all requests, so its own redirect policy applies - /// and [`OAuthHttpRedirectPolicy::Stop`] is not enforced for token operations. - /// Callers needing strict no-redirect handling should pass a custom - /// [`OAuthHttpClient`] to [`AuthorizationManager::new_with_oauth_http_client`]. - pub fn with_client(&mut self, http_client: ReqwestClient) -> Result<(), AuthError> { - // One client for both modes: a built reqwest::Client can't be rebuilt as a - // no-redirect variant without dropping the caller's configuration. - self.http_client = Arc::new(ReqwestOAuthHttpClient { - follow_redirects: http_client.clone(), - stop_redirects: http_client, - }); - self.refresh_redirect_policy = OAuthHttpRedirectPolicy::Follow; - Ok(()) - } - - /// Resolve OAuth 2.0 metadata and report how it was obtained. - /// - /// Discovery follows SEP-985: protected resource metadata first, then - /// direct OAuth 2.0 Authorization Server Metadata or OpenID Connect - /// Discovery. When discovery finds nothing, the result contains legacy - /// default endpoints derived from the base URL and - /// [`AuthorizationMetadataSource::LegacyEndpointFallback`]. - /// - /// # Examples - /// - /// ```no_run - /// use rmcp::transport::auth::{AuthorizationManager, AuthorizationMetadataSource}; - /// - /// # async fn resolve() -> Result<(), Box> { - /// let mut manager = AuthorizationManager::new("https://mcp.example.com").await?; - /// let resolution = manager.resolve_metadata().await?; - /// - /// if resolution.source == AuthorizationMetadataSource::LegacyEndpointFallback { - /// println!("the server did not publish OAuth metadata"); - /// } - /// - /// manager.set_metadata(resolution.metadata); - /// # Ok(()) - /// # } - /// ``` - pub async fn resolve_metadata(&self) -> Result { - if let Some(metadata) = self.discover_oauth_server_via_resource_metadata().await? { - return Ok(AuthorizationMetadataResolution { - metadata, - source: AuthorizationMetadataSource::ProtectedResourceMetadata, - }); - } - - if let Some(metadata) = self.try_discover_oauth_server(&self.base_url).await? { - return Ok(AuthorizationMetadataResolution { - metadata, - source: AuthorizationMetadataSource::AuthorizationServerMetadata, - }); - } - - debug!("falling back to legacy OAuth endpoints derived from the base URL"); - Ok(AuthorizationMetadataResolution { - metadata: Self::legacy_authorization_metadata(&self.base_url), - source: AuthorizationMetadataSource::LegacyEndpointFallback, - }) - } - - /// Resolve authorization server metadata starting from the - /// `WWW-Authenticate` challenge of a real request's 401 response — the - /// reactive discovery path, matching the TypeScript and Python SDKs. - /// - /// Seeds scope selection with the challenge's `scope` hint and prefers - /// the challenge's `resource_metadata` URL; falls back to - /// [`resolve_metadata`](Self::resolve_metadata) when the challenge is - /// `None` or carries no usable metadata pointer. - pub async fn resolve_metadata_from_challenge( - &self, - www_authenticate: Option<&str>, - ) -> Result { - let Some(www_authenticate) = www_authenticate else { - return self.resolve_metadata().await; - }; - let params = WWWAuthenticateParams::parse(www_authenticate, &self.base_url); - - self.record_challenge_scope(¶ms).await; - - if let Some(resource_metadata_url) = ¶ms.resource_metadata_url - && let Some(metadata) = self - .discover_oauth_server_from_resource_metadata_url(resource_metadata_url) - .await? - { - return Ok(AuthorizationMetadataResolution { - metadata, - source: AuthorizationMetadataSource::ProtectedResourceMetadata, - }); - } - - self.resolve_metadata().await - } - - /// Store a challenge's `scope` hint for later scope selection. - async fn record_challenge_scope(&self, params: &WWWAuthenticateParams) { - let Some(scope) = ¶ms.scope else { - return; - }; - let scopes: Vec = scope.split_whitespace().map(str::to_string).collect(); - if scopes.is_empty() { - return; - } - debug!("WWW-Authenticate challenge contains scope: {scope}"); - *self.www_auth_scopes.write().await = scopes; - } - - fn legacy_authorization_metadata(base_url: &Url) -> AuthorizationMetadata { - let endpoint = |path: &str| { - let mut url = base_url.clone(); - url.set_query(None); - url.set_fragment(None); - url.set_path(path); - url.to_string() - }; - - AuthorizationMetadata { - authorization_endpoint: endpoint("/authorize"), - token_endpoint: endpoint("/token"), - registration_endpoint: Some(endpoint("/register")), - ..Default::default() - } - } - - /// get client id and credentials - pub async fn get_credentials(&self) -> Result { - let client_id = self - .oauth_client - .as_ref() - .ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))? - .client_id(); - - let stored = self.credential_store.load().await?; - let token_response = stored.and_then(|s| s.token_response); - - Ok((client_id.to_string(), token_response)) - } - - /// configure oauth2 client with client credentials - pub fn configure_client(&mut self, config: OAuthClientConfig) -> Result<(), AuthError> { - if self.metadata.is_none() { - return Err(AuthError::NoAuthorizationSupport); - } - - // SEP-837: only override application_type when the config sets one - if let Some(application_type) = &config.application_type { - self.application_type = Some(application_type.clone()); - } - - let metadata = self.metadata.as_ref().unwrap(); - - let auth_url = AuthUrl::new(metadata.authorization_endpoint.clone()) - .map_err(|e| AuthError::OAuthError(format!("Invalid authorization URL: {}", e)))?; - - let token_url = TokenUrl::new(metadata.token_endpoint.clone()) - .map_err(|e| AuthError::OAuthError(format!("Invalid token URL: {}", e)))?; - - let client_id = ClientId::new(config.client_id); - let redirect_url = RedirectUrl::new(config.redirect_uri.clone()) - .map_err(|e| AuthError::OAuthError(format!("Invalid re URL: {}", e)))?; - - let mut client_builder: OAuthClient = oauth2::Client::new(client_id.clone()) - .set_auth_uri(auth_url) - .set_token_uri(token_url) - .set_redirect_uri(redirect_url); - - if let Some(secret) = config.client_secret { - client_builder = client_builder.set_client_secret(ClientSecret::new(secret)); - } - - let uses_secret_post = metadata - .additional_fields - .get("token_endpoint_auth_methods_supported") - .and_then(|v| v.as_array()) - .map(|arr| { - let has_basic = arr - .iter() - .any(|m| m.as_str() == Some("client_secret_basic")); - let has_post = arr.iter().any(|m| m.as_str() == Some("client_secret_post")); - has_post && !has_basic - }) - .unwrap_or(false); - - if uses_secret_post { - client_builder = client_builder.set_auth_type(AuthType::RequestBody); - } - - self.oauth_client = Some(client_builder); - Ok(()) - } - /// validate authorization server metadata before starting authorization. - fn validate_server_metadata(&self, response_type: &str) -> Result<(), AuthError> { - let Some(metadata) = self.metadata.as_ref() else { - return Ok(()); - }; - - // RFC 8414 RECOMMENDS response_types_supported in the metadata. This field is optional, - // but if present and does not include the flow we use ("code"), bail out early with a clear error. - if let Some(response_types_supported) = metadata.response_types_supported.as_ref() - && !response_types_supported.contains(&response_type.to_string()) - { - return Err(AuthError::InvalidScope(response_type.to_string())); - } - - // The client always sends an S256 challenge. A server that advertises - // methods without S256 can't do the flow we require, so refuse it. A - // server that omits the field is tolerated: it usually means the server - // didn't advertise PKCE, not that it lacks S256. - match &metadata.code_challenge_methods_supported { - Some(methods) if !methods.iter().any(|m| m == "S256") => { - return Err(AuthError::PkceUnsupported); - } - None => { - warn!( - "authorization server metadata omits code_challenge_methods_supported; \ - proceeding with an S256 challenge anyway" - ); - } - Some(_) => {} - } - - Ok(()) - } - /// dynamic register oauth2 client - pub async fn register_client( - &mut self, - name: &str, - redirect_uri: &str, - scopes: &[&str], - ) -> Result { - if self.metadata.is_none() { - return Err(AuthError::NoAuthorizationSupport); - } - - let metadata = self.metadata.as_ref().unwrap(); - let Some(registration_url) = metadata.registration_endpoint.as_ref() else { - return Err(AuthError::RegistrationFailed( - "Dynamic client registration not supported".to_string(), - )); - }; - self.validate_server_metadata("code")?; - - let application_type = self.application_type.clone(); - let registration_request = ClientRegistrationRequest { - client_name: name.to_string(), - redirect_uris: vec![redirect_uri.to_string()], - grant_types: vec![ - "authorization_code".to_string(), - "refresh_token".to_string(), - ], - token_endpoint_auth_method: "none".to_string(), // public client - response_types: vec!["code".to_string()], - scope: if scopes.is_empty() { - None - } else { - Some(scopes.join(" ")) - }, - application_type: application_type.clone(), - }; - - let request = oauth2::http::Request::builder() - .method("POST") - .uri(registration_url) - .header(CONTENT_TYPE, "application/json") - .body( - serde_json::to_vec(®istration_request) - .map_err(|error| AuthError::RegistrationFailed(error.to_string()))?, - ) - .map_err(|error| AuthError::RegistrationFailed(error.to_string()))?; - let response = match self - .http_client - .execute(OAuthHttpRequest::new( - request, - OAuthHttpRedirectPolicy::Follow, - )) - .await - { - Ok(response) => response, - Err(e) => { - return Err(AuthError::RegistrationFailed(format!( - "HTTP request error: {}", - e - ))); - } - }; - - if !response.status().is_success() { - let status = response.status(); - let error_text = String::from_utf8_lossy(response.body()); - - return Err(AuthError::RegistrationFailed(format!( - "HTTP {}: {}", - status, error_text - ))); - } - - debug!("registration response status: {:?}", response.status()); - let reg_response = - match serde_json::from_slice::(response.body()) { - Ok(response) => response, - Err(e) => { - return Err(AuthError::RegistrationFailed(format!( - "analyze response error: {}", - e - ))); - } - }; - - let config = OAuthClientConfig { - client_id: reg_response.client_id, - // Some IdP returns a response where the field 'client_secret' is present but with empty string value. - // In that case, the interpretation is that the client is a public client and does not have a secret during the - // registration phase here, e.g. dynamic client registrations. - // - // Even though whether or not the empty string is valid is outside of the scope of Oauth2 spec, - // we should treat it as no secret since otherwise we end up authenticating with a valid client_id with an empty client_secret - // as a password, which is not a goal of the client secret. - client_secret: reg_response.client_secret.filter(|s| !s.is_empty()), - redirect_uri: redirect_uri.to_string(), - scopes: scopes.iter().map(|s| s.to_string()).collect(), - application_type, - }; - - self.configure_client(config.clone())?; - Ok(config) - } - - /// use provided client id to configure oauth2 client instead of dynamic registration - /// this is useful when you have a stored client id from previous registration - pub fn configure_client_id(&mut self, client_id: &str) -> Result<(), AuthError> { - let config = OAuthClientConfig { - client_id: client_id.to_string(), - client_secret: None, - scopes: vec![], - redirect_uri: self.base_url.to_string(), - // keep the manager's current application_type - application_type: None, - }; - self.configure_client(config) - } - - /// generate authorization url - pub async fn get_authorization_url(&self, scopes: &[&str]) -> Result { - let oauth_client = self - .oauth_client - .as_ref() - .ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))?; - self.validate_server_metadata("code")?; - - // generate pkce challenge - let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); - - // build authorization request - let mut auth_request = oauth_client - .authorize_url(CsrfToken::new_random) - .set_pkce_challenge(pkce_challenge) - .add_extra_param("resource", self.oauth_resource().await); - - // add request scopes - for scope in scopes { - auth_request = auth_request.add_scope(Scope::new(scope.to_string())); - } - - let (auth_url, csrf_token) = auth_request.url(); - - // store pkce verifier, expected issuer, and the requested scopes for later use via state store - let expected_issuer = self - .metadata - .as_ref() - .and_then(|metadata| metadata.issuer.clone()); - let require_issuer = self - .metadata - .as_ref() - .and_then(|metadata| { - metadata - .additional_fields - .get("authorization_response_iss_parameter_supported") - .and_then(|value| value.as_bool()) - }) - .unwrap_or(false); - let stored_state = StoredAuthorizationState::new_with_expected_issuer( - &pkce_verifier, - &csrf_token, - expected_issuer, - require_issuer, - ) - .with_requested_scopes(scopes.iter().map(|s| s.to_string()).collect()); - self.state_store - .save(csrf_token.secret(), stored_state) - .await?; - - Ok(auth_url.to_string()) - } - - async fn oauth_resource(&self) -> String { - self.discovered_resource - .read() - .await - .clone() - .unwrap_or_else(|| self.base_url.to_string()) - } - - /// get the current granted scopes - pub async fn get_current_scopes(&self) -> Vec { - self.current_scopes.read().await.clone() - } - - /// compute the union of current scopes and required scopes - fn compute_scope_union(current: &[String], required: &str) -> Vec { - let mut scopes = current.to_vec(); - scopes.extend(required.split_whitespace().map(|s| s.to_string())); - Self::dedup_scopes(scopes) - } - - /// deduplicate scopes preserving first-seen order (SEP-2350: stable for testability) - fn dedup_scopes(scopes: Vec) -> Vec { - let mut seen = std::collections::HashSet::new(); - scopes - .into_iter() - .filter(|s| seen.insert(s.clone())) - .collect() - } - - /// resolve the granted scope set from a token response (SEP-2350, RFC 6749 §5.1): an explicit - /// `scope` may narrow the grant; an omitted one means the request was granted in full, so fall - /// back to the requested scopes (or the previously granted set when none were recorded). - fn resolve_granted_scopes( - response_scopes: Option>, - requested_scopes: &[String], - current_scopes: &[String], - ) -> Vec { - match response_scopes { - Some(scopes) => scopes, - None if !requested_scopes.is_empty() => requested_scopes.to_vec(), - None => current_scopes.to_vec(), - } - } - - /// check if a scope upgrade is possible and allowed - pub async fn can_attempt_scope_upgrade(&self) -> bool { - if !self.scope_upgrade_config.auto_upgrade { - return false; - } - let attempts = *self.scope_upgrade_attempts.read().await; - attempts < self.scope_upgrade_config.max_upgrade_attempts - } - - /// select scopes to request from authorization server - pub fn select_scopes( - &self, - www_authenticate_scope: Option<&str>, - default_scopes: &[&str], - ) -> Vec { - let mut scopes = self.select_base_scopes(www_authenticate_scope, default_scopes); - self.add_offline_access_if_supported(&mut scopes); - scopes - } - - /// select scopes following SEP-2350: re-authorization requests the union of the - /// previously requested scopes and the newly challenged scopes. Server-reported - /// scopes (WWW-Authenticate challenge, protected resource metadata) are operational - /// requirements for the current operation, never an exclusive directive, so they - /// accumulate rather than replace. The AS metadata and caller defaults only seed the - /// request when nothing has been requested or challenged yet. - fn select_base_scopes( - &self, - www_authenticate_scope: Option<&str>, - default_scopes: &[&str], - ) -> Vec { - let mut accumulated: Vec = Vec::new(); - - // previously requested scopes - if let Ok(guard) = self.current_scopes.try_read() { - accumulated.extend(guard.iter().cloned()); - } - - // newly challenged scopes for the current operation (RFC 6750 §3.1) - if let Some(scope) = www_authenticate_scope { - accumulated.extend(scope.split_whitespace().map(|s| s.to_string())); - } - if let Ok(guard) = self.www_auth_scopes.try_read() { - accumulated.extend(guard.iter().cloned()); - } - - // scopes required for the current operation per protected resource metadata (RFC 9728) - if let Ok(guard) = self.resource_scopes.try_read() { - accumulated.extend(guard.iter().cloned()); - } - - if !accumulated.is_empty() { - return Self::dedup_scopes(accumulated); - } - - // nothing requested or challenged yet: seed from AS metadata, then caller defaults - if let Some(metadata) = &self.metadata - && let Some(scopes_supported) = &metadata.scopes_supported - && !scopes_supported.is_empty() - { - return scopes_supported.clone(); - } - - default_scopes.iter().map(|s| s.to_string()).collect() - } - - /// SEP-2207: when the AS advertises `offline_access` in `scopes_supported`, append - /// it so OIDC-flavored Authorization Servers will issue refresh tokens. - fn add_offline_access_if_supported(&self, scopes: &mut Vec) { - if scopes.is_empty() || scopes.iter().any(|s| s == "offline_access") { - return; - } - if let Some(metadata) = &self.metadata - && let Some(supported) = &metadata.scopes_supported - && supported.iter().any(|s| s == "offline_access") - { - scopes.push("offline_access".to_string()); - } - } - - /// attempt to upgrade scopes after receiving a 403 insufficient_scope error. - /// returns the authorization URL for re-authorization with upgraded scopes. - pub async fn request_scope_upgrade(&self, required_scope: &str) -> Result { - if !self.scope_upgrade_config.auto_upgrade { - return Err(AuthError::InvalidScope( - "Scope upgrade is disabled".to_string(), - )); - } - - let mut attempts = self.scope_upgrade_attempts.write().await; - if *attempts >= self.scope_upgrade_config.max_upgrade_attempts { - return Err(AuthError::InvalidScope(format!( - "Maximum scope upgrade attempts ({}) exceeded", - self.scope_upgrade_config.max_upgrade_attempts - ))); - } - - *attempts += 1; - drop(attempts); - - let current_scopes = self.current_scopes.read().await.clone(); - let mut upgraded_scopes = Self::compute_scope_union(¤t_scopes, required_scope); - self.add_offline_access_if_supported(&mut upgraded_scopes); - - debug!( - "Requesting scope upgrade: current={:?}, required={}, union={:?}", - current_scopes, required_scope, upgraded_scopes - ); - - let scope_refs: Vec<&str> = upgraded_scopes.iter().map(|s| s.as_str()).collect(); - self.get_authorization_url(&scope_refs).await - } - - /// reset scope upgrade attempt counter - pub async fn reset_scope_upgrade_attempts(&self) { - *self.scope_upgrade_attempts.write().await = 0; - } - - /// get the number of scope upgrade attempts made - pub async fn get_scope_upgrade_attempts(&self) -> u32 { - *self.scope_upgrade_attempts.read().await - } - - fn validate_authorization_response_issuer( - stored_state: &StoredAuthorizationState, - received_issuer: Option<&str>, - ) -> Result<(), AuthError> { - let Some(expected_issuer) = stored_state.expected_issuer.as_deref() else { - if received_issuer.is_some() || stored_state.require_issuer { - return Err(AuthError::AuthorizationFailed( - "Authorization callback issuer cannot be validated because expected issuer was not recorded" - .to_string(), - )); - } - // Without issuer metadata from discovery and without an issuer-bearing - // callback, there is no stable value to bind to. This preserves - // compatibility with older authorization servers. - return Ok(()); - }; - let Some(received_issuer) = received_issuer else { - if stored_state.require_issuer { - return Err(AuthError::AuthorizationServerMissingIssuer { - expected_issuer: expected_issuer.to_string(), - }); - } - // SEP-2468 recommends RFC 9207 `iss`, but tolerate older authorization - // servers that do not advertise support for backwards compatibility. - return Ok(()); - }; - if received_issuer != expected_issuer { - return Err(AuthError::AuthorizationServerMismatch { - expected_issuer: expected_issuer.to_string(), - received_issuer: received_issuer.to_string(), - }); - } - Ok(()) - } - - /// exchange authorization code for access token - pub async fn exchange_code_for_token( - &self, - code: &str, - csrf_token: &str, - ) -> Result { - self.exchange_code_for_token_with_issuer(code, csrf_token, None) - .await - } - - /// exchange authorization code for access token, validating the optional - /// RFC 9207 authorization response issuer (`iss`) when present. - pub async fn exchange_code_for_token_with_issuer( - &self, - code: &str, - csrf_token: &str, - received_issuer: Option<&str>, - ) -> Result { - debug!("start exchange code for token: {:?}", code); - let oauth_client = self - .oauth_client - .as_ref() - .ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))?; - - // Load state from state store using CSRF token as key - let stored_state = - self.state_store.load(csrf_token).await?.ok_or_else(|| { - AuthError::InternalError("Authorization state not found".to_string()) - })?; - - // Delete state after retrieval (one-time use) - self.state_store.delete(csrf_token).await?; - - Self::validate_authorization_response_issuer(&stored_state, received_issuer)?; - - // capture requested scopes before the state is consumed - let requested_scopes = stored_state.requested_scopes.clone(); - - // Reconstruct the PKCE verifier - let pkce_verifier = stored_state.into_pkce_verifier(); - - debug!("client_id: {:?}", oauth_client.client_id()); - - // exchange token - let token_result = match oauth_client - .exchange_code(AuthorizationCode::new(code.to_string())) - .set_pkce_verifier(pkce_verifier) - .add_extra_param("resource", self.oauth_resource().await) - .request_async(&OAuth2HttpClient { - client: self.http_client.as_ref(), - redirect_policy: OAuthHttpRedirectPolicy::Stop, - }) - .await - { - Ok(token) => token, - Err(RequestTokenError::Parse(_, body)) => { - match serde_json::from_slice::(&body) { - Ok(parsed) => { - warn!( - "token exchange failed to parse completely but included a valid token response. Accepting it." - ); - parsed - } - Err(parse_err) => { - return Err(AuthError::TokenExchangeFailed(parse_err.to_string())); - } - } - } - Err(e) => { - return Err(AuthError::TokenExchangeFailed(e.to_string())); - } - }; - - debug!("exchange token result: {:?}", token_result); - - // SEP-2350: an omitted `scope` means the grant equals the request (RFC 6749 §5.1). - let response_scopes = token_result - .scopes() - .map(|scopes| scopes.iter().map(|s| s.to_string()).collect()); - let granted_scopes = { - let current = self.current_scopes.read().await; - Self::resolve_granted_scopes(response_scopes, &requested_scopes, ¤t) - }; - - *self.current_scopes.write().await = granted_scopes.clone(); - *self.scope_upgrade_attempts.write().await = 0; - - let client_id = oauth_client.client_id().to_string(); - let stored = StoredCredentials { - client_id, - token_response: Some(token_result.clone()), - granted_scopes, - token_received_at: Some(Self::now_epoch_secs()), - issuer: self.metadata_issuer(), - }; - self.credential_store.save(stored).await?; - - Ok(token_result) - } - - fn now_epoch_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - } - - fn metadata_issuer(&self) -> Option { - self.metadata.as_ref().and_then(|m| m.issuer.clone()) - } - - /// Proactive refresh buffer: refresh tokens this many seconds before they expire - /// to avoid races between token retrieval and the actual HTTP request. - const REFRESH_BUFFER_SECS: u64 = 30; - - /// Get access token from local credential store. - /// If expired, refresh it automatically when a refresh token is available. - /// When the access token has expired and no refresh token is available, or the - /// authorization server rejects the refresh token, returns - /// [`AuthError::AuthorizationRequired`] so the caller can re-authenticate. - /// Transient refresh failures return [`AuthError::TokenRefreshFailed`] so the - /// caller can retry; other errors are propagated as-is. - pub async fn get_access_token(&self) -> Result { - let stored = self.credential_store.load().await?; - let Some(stored_creds) = stored else { - return Err(AuthError::AuthorizationRequired); - }; - let Some(creds) = stored_creds.token_response.as_ref() else { - return Err(AuthError::AuthorizationRequired); - }; - - if let (Some(expires_in), Some(received_at)) = - (creds.expires_in(), stored_creds.token_received_at) - { - let elapsed = Self::now_epoch_secs().saturating_sub(received_at); - let remaining = expires_in.as_secs().saturating_sub(elapsed); - - if remaining < Self::REFRESH_BUFFER_SECS { - tracing::info!( - remaining_secs = remaining, - "Access token expired or nearly expired, refreshing." - ); - return self.try_refresh_or_reauth().await; - } - } - - // When expiry info is unavailable (e.g., credentials stored before - // token_received_at was tracked), skip the expiry check and return - // the token as-is. - Ok(creds.access_token().secret().to_string()) - } - - /// Attempt to refresh the token. If refresh fails because there is no - /// refresh token or the server rejected it, return `AuthorizationRequired` - /// so the caller can re-prompt the user. Infrastructure errors (e.g. store - /// I/O failures, misconfigured client) are propagated as-is. - pub(crate) async fn try_refresh_or_reauth(&self) -> Result { - match self.refresh_token().await { - Ok(new_creds) => { - tracing::info!("Refreshed access token."); - Ok(new_creds.access_token().secret().to_string()) - } - Err(e @ (AuthError::AuthorizationRequired | AuthError::TokenRefreshRejected(_))) => { - tracing::warn!(error = %e, "Token refresh not possible, re-authorization required."); - Err(AuthError::AuthorizationRequired) - } - Err(e) => Err(e), - } - } - - /// refresh access token - pub async fn refresh_token(&self) -> Result { - let oauth_client = self - .oauth_client - .as_ref() - .ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))?; - - let stored = self.credential_store.load().await?; - let stored_credentials = stored.ok_or(AuthError::AuthorizationRequired)?; - let current_credentials = stored_credentials - .token_response - .ok_or(AuthError::AuthorizationRequired)?; - - let refresh_token = current_credentials - .refresh_token() - .ok_or(AuthError::AuthorizationRequired)?; - debug!("refresh token present, attempting refresh"); - - let refresh_token_value = RefreshToken::new(refresh_token.secret().to_string()); - let mut refresh_request = oauth_client - .exchange_refresh_token(&refresh_token_value) - // RFC 8707: the resource indicator is required on token requests, including refreshes - .add_extra_param("resource", self.oauth_resource().await); - let mut refresh_scopes = stored_credentials.granted_scopes; - self.add_offline_access_if_supported(&mut refresh_scopes); - for scope in refresh_scopes { - refresh_request = refresh_request.add_scope(Scope::new(scope)); - } - let mut token_result = refresh_request - .request_async(&OAuth2HttpClient { - client: self.http_client.as_ref(), - redirect_policy: self.refresh_redirect_policy, - }) - .await - .map_err(|error| match &error { - RequestTokenError::ServerResponse(response) - if response.error() == &BasicErrorResponseType::InvalidGrant => - { - AuthError::TokenRefreshRejected(error.to_string()) - } - _ => AuthError::TokenRefreshFailed(error.to_string()), - })?; - - // RFC 6749 section 6: issuing a new refresh token on refresh is optional. - // When the response omits one, keep the existing refresh token rather than - // dropping it. When a new one is present, the response value is used as-is. - if token_result.refresh_token().is_none() { - token_result.set_refresh_token(Some(refresh_token_value)); - } - - let granted_scopes: Vec = match token_result.scopes() { - Some(scopes) => scopes.iter().map(|s| s.to_string()).collect(), - None => self.current_scopes.read().await.clone(), - }; - - *self.current_scopes.write().await = granted_scopes.clone(); - - let client_id = oauth_client.client_id().to_string(); - let stored = StoredCredentials { - client_id, - token_response: Some(token_result.clone()), - granted_scopes, - token_received_at: Some(Self::now_epoch_secs()), - issuer: self.metadata_issuer(), - }; - self.credential_store.save(stored).await?; - - Ok(token_result) - } - - /// prepare request, add authorization header - pub async fn prepare_request( - &self, - request: reqwest::RequestBuilder, - ) -> Result { - let token = self.get_access_token().await?; - Ok(request.header(AUTHORIZATION, format!("Bearer {}", token))) - } - - /// handle response, check if need to re-authorize or scope upgrade - pub async fn handle_response( - &self, - response: reqwest::Response, - ) -> Result { - if response.status() == StatusCode::UNAUTHORIZED { - return Err(AuthError::AuthorizationRequired); - } - if response.status() == StatusCode::FORBIDDEN { - for value in response.headers().get_all(WWW_AUTHENTICATE).iter() { - let Ok(value_str) = value.to_str() else { - continue; - }; - let params = Self::extract_www_authenticate_params(value_str, &self.base_url); - if params.is_insufficient_scope() { - let required_scope = params.scope.unwrap_or_default(); - return Err(AuthError::InsufficientScope { - required_scope, - upgrade_url: None, - }); - } - } - return Err(AuthError::AuthorizationFailed("Forbidden".to_string())); - } - Ok(response) - } - - /// Generate discovery endpoint URLs following the priority order in spec-2025-11-25 4.3 "Authorization Server Metadata Discovery". - fn generate_discovery_urls(base_url: &Url) -> Vec { - let mut candidates = Vec::new(); - let path = base_url.path(); - let trimmed = path.trim_start_matches('/').trim_end_matches('/'); - let mut push_candidate = |discovery_path: String| { - let mut discovery_url = base_url.clone(); - discovery_url.set_query(None); - discovery_url.set_fragment(None); - discovery_url.set_path(&discovery_path); - candidates.push(discovery_url); - }; - if trimmed.is_empty() { - // No path components: try OAuth first, then OpenID Connect - push_candidate("/.well-known/oauth-authorization-server".to_string()); - push_candidate("/.well-known/openid-configuration".to_string()); - } else { - // Path components present: prefer OAuth discovery before OpenID Connect fallbacks. - // 1. OAuth 2.0 with path insertion - push_candidate(format!("/.well-known/oauth-authorization-server/{trimmed}")); - // 2. OpenID Connect with path insertion - push_candidate(format!("/.well-known/openid-configuration/{trimmed}")); - // 3. OpenID Connect with path appending - push_candidate(format!("/{trimmed}/.well-known/openid-configuration")); - // 4. Canonical OAuth fallback (without path suffix) - push_candidate("/.well-known/oauth-authorization-server".to_string()); - } - - candidates - } - - async fn try_discover_oauth_server( - &self, - base_url: &Url, - ) -> Result, AuthError> { - for discovery_url in Self::generate_discovery_urls(base_url) { - if let Some(metadata) = self.fetch_authorization_metadata(&discovery_url).await? { - return Ok(Some(metadata)); - } - } - Ok(None) - } - - async fn fetch_authorization_metadata( - &self, - discovery_url: &Url, - ) -> Result, AuthError> { - debug!("discovery url: {:?}", discovery_url); - let response = self - .discovery_get(discovery_url) - .await - .map_err(|error| Self::discovery_failed(discovery_url, error))?; - - if response.status() != StatusCode::OK { - debug!("discovery returned non-200: {}", response.status()); - return Ok(None); - } - - match serde_json::from_slice::(response.body()) { - Ok(metadata) => { - self.validate_authorization_metadata_issuer(discovery_url, &metadata)?; - Ok(Some(metadata)) - } - Err(err) => { - debug!("Failed to parse metadata for {}: {}", discovery_url, err); - Ok(None) // malformed JSON ⇒ try next candidate - } - } - } - - fn expected_issuer_for_authorization_metadata_url(discovery_url: &Url) -> Option { - let path = discovery_url.path(); - let oauth_prefix = "/.well-known/oauth-authorization-server"; - let oidc_prefix = "/.well-known/openid-configuration"; - - let issuer_path = if path == oauth_prefix || path == oidc_prefix { - "" - } else if let Some(suffix) = path.strip_prefix(&format!("{oauth_prefix}/")) { - // RFC 8414 path-insertion form - suffix - } else if let Some(suffix) = path.strip_prefix(&format!("{oidc_prefix}/")) { - // MCP-required OpenID Connect path-insertion compatibility form - suffix - } else if let Some(prefix) = path.strip_suffix(oidc_prefix) { - // OpenID Connect path-appended form - prefix.trim_start_matches('/') - } else { - return None; - }; - - let mut issuer = discovery_url.clone(); - issuer.set_query(None); - issuer.set_fragment(None); - if issuer_path.is_empty() { - issuer.set_path(""); - } else { - issuer.set_path(&format!("/{issuer_path}")); - } - Some(issuer.to_string()) - } - - fn issuer_identifiers_match(received_issuer: &str, expected_issuer: &str) -> bool { - if received_issuer == expected_issuer { - return true; - } - - let trim_root_slash = |issuer: &str| -> String { - issuer - .strip_suffix('/') - .filter(|without_slash| { - Url::parse(without_slash) - .map(|url| url.path().is_empty() || url.path() == "/") - .unwrap_or(false) - }) - .unwrap_or(issuer) - .to_string() - }; - - trim_root_slash(received_issuer) == trim_root_slash(expected_issuer) - } - - fn validate_authorization_metadata_issuer( - &self, - discovery_url: &Url, - metadata: &AuthorizationMetadata, - ) -> Result<(), AuthError> { - let Some(expected_issuer) = - Self::expected_issuer_for_authorization_metadata_url(discovery_url) - else { - return Ok(()); - }; - let Some(received_issuer) = metadata.issuer.as_deref() else { - if self.allow_missing_issuer { - return Ok(()); - } - return Err(AuthError::AuthorizationServerMissingIssuer { expected_issuer }); - }; - if !Self::issuer_identifiers_match(received_issuer, &expected_issuer) { - return Err(AuthError::AuthorizationServerMismatch { - expected_issuer, - received_issuer: received_issuer.to_string(), - }); - } - Ok(()) - } - - async fn discover_oauth_server_via_resource_metadata( - &self, - ) -> Result, AuthError> { - let Some(resource_metadata_url) = self.discover_resource_metadata_url().await? else { - return Ok(None); - }; - self.discover_oauth_server_from_resource_metadata_url(&resource_metadata_url) - .await - } - - async fn discover_oauth_server_from_resource_metadata_url( - &self, - resource_metadata_url: &Url, - ) -> Result, AuthError> { - let Some(resource_metadata) = self - .fetch_resource_metadata_from_url(resource_metadata_url) - .await? - else { - return Ok(None); - }; - - self.validate_resource_metadata_resource(&resource_metadata)?; - - self.discovered_resource - .write() - .await - .replace(resource_metadata.resource.clone().unwrap_or_default()); - - // store scopes_supported from protected resource metadata for select_scopes() - if let Some(scopes) = resource_metadata.scopes_supported - && !scopes.is_empty() - { - *self.resource_scopes.write().await = scopes; - } - - let mut candidates = Vec::new(); - - if let Some(single) = resource_metadata.authorization_server { - candidates.push(single); - } - if let Some(list) = resource_metadata.authorization_servers { - candidates.extend(list); - } - - for candidate in candidates { - let candidate = candidate.trim(); - if candidate.is_empty() { - continue; - } - - let candidate_url = match Url::parse(candidate) { - Ok(url) => url, - Err(_) => match resource_metadata_url.join(candidate) { - Ok(url) => url, - Err(e) => { - debug!("Failed to resolve authorization server URL `{candidate}`: {e}"); - continue; - } - }, - }; - - if !Self::is_allowed_authorization_server_metadata_url(&self.base_url, &candidate_url) { - warn!("rejecting authorization server metadata URL `{candidate_url}`"); - continue; - } - - if candidate_url.path().contains("/.well-known/") { - if let Some(metadata) = self.fetch_authorization_metadata(&candidate_url).await? { - return Ok(Some(metadata)); - } - continue; - } - - if let Some(metadata) = self.try_discover_oauth_server(&candidate_url).await? { - return Ok(Some(metadata)); - } - } - - Ok(None) - } - - fn validate_resource_metadata_resource( - &self, - metadata: &ResourceServerMetadata, - ) -> Result<(), AuthError> { - let Some(resource) = metadata.resource.as_deref() else { - return Err(AuthError::MetadataError( - "Protected resource metadata missing required resource field".to_string(), - )); - }; - - let Ok(resource_url) = Url::parse(resource) else { - return Err(AuthError::MetadataError( - "Protected resource metadata resource field is not a valid URL".to_string(), - )); - }; - - if resource_url.fragment().is_some() { - return Err(AuthError::MetadataError( - "Protected resource metadata resource does not permit fragment in URL as specified by RFC 8707".to_string() - )); - } - - if !Self::is_resource_identifier_valid(&self.base_url, &resource_url) { - return Err(AuthError::MetadataError(format!( - "Protected resource metadata resource mismatch: reference '{}', permitted '{}'", - self.base_url, resource - ))); - } - - Ok(()) - } - - fn is_resource_identifier_valid(expected: &Url, actual: &Url) -> bool { - if expected == actual { - return true; - } - - if expected.scheme() != actual.scheme() - || expected.host_str() != actual.host_str() - || expected.port_or_known_default() != actual.port_or_known_default() - { - return false; - } - - let expected_path = expected.path(); - let actual_path = actual.path(); - - // URL query part supported, even if it is discouraged in RFC 8707 - if expected_path == actual_path && expected.query() == actual.query() { - return true; - } - - expected_path.starts_with(actual_path) - && expected.query().is_none() - && actual.query().is_none() - && (actual_path.ends_with('/') - || expected_path.as_bytes().get(actual_path.len()) == Some(&b'/')) - } - - async fn discover_resource_metadata_url(&self) -> Result, AuthError> { - if let Some(resource_metadata_url) = - self.probe_resource_metadata_url(&self.base_url).await? - { - return Ok(Some(resource_metadata_url)); - } - - // If the primary URL doesn't use WWW-Authenticate, try oauth-protected-resource discovery. - // https://www.rfc-editor.org/rfc/rfc9728.html#name-obtaining-protected-resourc - for candidate_path in - Self::well_known_paths(self.base_url.path(), "oauth-protected-resource") - { - let mut discovery_url = self.base_url.clone(); - discovery_url.set_query(None); - discovery_url.set_fragment(None); - discovery_url.set_path(&candidate_path); - if let Some(resource_metadata_url) = - self.probe_resource_metadata_url(&discovery_url).await? - { - return Ok(Some(resource_metadata_url)); - } - } - - Ok(None) - } - - /// Probe `url` with a GET, extracting the resource metadata url from a - /// 200 (the url itself is the metadata document) or from a 401's - /// WWW-Authenticate header value. - /// https://www.rfc-editor.org/rfc/rfc9728.html#name-use-of-www-authenticate-for - async fn probe_resource_metadata_url(&self, url: &Url) -> Result, AuthError> { - let response = self - .discovery_get(url) - .await - .map_err(|error| Self::discovery_failed(url, error))?; - - match response.status() { - StatusCode::OK => Ok(Some(url.clone())), - StatusCode::UNAUTHORIZED => Ok(self - .extract_resource_metadata_url_from_www_authenticate(&response) - .await), - status => { - debug!("resource metadata probe returned unexpected status: {status}"); - Ok(None) - } - } - } - - async fn extract_resource_metadata_url_from_www_authenticate( - &self, - response: &HttpResponse, - ) -> Option { - let mut parsed_url = None; - for value in response.headers().get_all(WWW_AUTHENTICATE).iter() { - let Ok(value_str) = value.to_str() else { - continue; - }; - let params = Self::extract_www_authenticate_params(value_str, &self.base_url); - if params.resource_metadata_url.is_some() { - self.record_challenge_scope(¶ms).await; - parsed_url = params.resource_metadata_url; - break; - } - } - - parsed_url - } - - async fn fetch_resource_metadata_from_url( - &self, - resource_metadata_url: &Url, - ) -> Result, AuthError> { - debug!( - "resource metadata discovery url: {:?}", - resource_metadata_url - ); - let response = self - .discovery_get(resource_metadata_url) - .await - .map_err(|error| Self::discovery_failed(resource_metadata_url, error))?; - - if response.status() != StatusCode::OK { - debug!( - "resource metadata request returned non-200: {}", - response.status() - ); - return Ok(None); - } - - let metadata = match serde_json::from_slice::(response.body()) { - Ok(metadata) => metadata, - Err(e) => { - debug!("failed to parse resource metadata as JSON: {}", e); - return Ok(None); - } - }; - Ok(Some(metadata)) - } - - fn discovery_failed(url: &Url, error: OAuthHttpClientError) -> AuthError { - AuthError::MetadataError(format!( - "OAuth metadata discovery failed for {url}\n Caused by: {}", - crate::error::ErrorChain(error.as_ref()) - )) - } - - async fn discovery_request( - &self, - request: OAuthHttpRequest, - ) -> Result { - let response = self.http_client.execute(request).await?; - let status = response.status(); - if status.is_server_error() - || matches!( - status, - StatusCode::REQUEST_TIMEOUT | StatusCode::TOO_EARLY | StatusCode::TOO_MANY_REQUESTS - ) - { - return Err(Box::new(OAuthHttpError::UnexpectedStatus(status))); - } - Ok(response) - } - - async fn discovery_get(&self, url: &Url) -> Result { - let mut current_url = url.clone(); - for _ in 0..MAX_OAUTH_DISCOVERY_REDIRECTS { - let request = oauth2::http::Request::builder() - .method("GET") - .uri(current_url.as_str()) - .header(HEADER_MCP_PROTOCOL_VERSION, "2024-11-05") - .body(Vec::new()) - .map_err(|error| Box::new(error) as OAuthHttpClientError)?; - let response = self - .discovery_request(OAuthHttpRequest::new( - request, - OAuthHttpRedirectPolicy::Stop, - )) - .await?; - - if !response.status().is_redirection() { - return Ok(response); - } - - let Some(location) = response.headers().get(LOCATION) else { - return Ok(response); - }; - let location = location - .to_str() - .map_err(|error| Box::new(error) as OAuthHttpClientError)?; - let next_url = current_url - .join(location) - .map_err(|error| Box::new(error) as OAuthHttpClientError)?; - - if Self::is_http_url(&next_url) && Self::is_same_origin(¤t_url, &next_url) { - current_url = next_url; - continue; - } - - return Err(Box::new(OAuthHttpError::CrossOriginRedirect(next_url))); - } - - Err(Box::new(OAuthHttpError::TooManyRedirects( - MAX_OAUTH_DISCOVERY_REDIRECTS, - ))) - } - - /// extract parameters from WWW-Authenticate header (resource_metadata and scope) - fn extract_www_authenticate_params(header: &str, base_url: &Url) -> WWWAuthenticateParams { - let mut params = WWWAuthenticateParams::default(); - let header_lowercase = header.to_ascii_lowercase(); - - // extract resource_metadata - let mut search_offset = 0; - let resource_key = "resource_metadata="; - while let Some(pos) = header_lowercase[search_offset..].find(resource_key) { - let global_pos = search_offset + pos + resource_key.len(); - let value_slice = &header[global_pos..]; - if let Some((value, consumed)) = Self::parse_next_header_value(value_slice) { - if let Some(url) = Self::resolve_resource_metadata_url(&value, base_url) { - params.resource_metadata_url = Some(url); - break; - } - search_offset = global_pos + consumed; - continue; - } else { - break; - } - } - - // extract scope - let scope_key = "scope="; - if let Some(pos) = header_lowercase.find(scope_key) { - let global_pos = pos + scope_key.len(); - let value_slice = &header[global_pos..]; - if let Some((value, _consumed)) = Self::parse_next_header_value(value_slice) { - params.scope = Some(value); - } - } - - // extract error - let error_key = "error="; - if let Some(pos) = header_lowercase.find(error_key) { - let global_pos = pos + error_key.len(); - let value_slice = &header[global_pos..]; - if let Some((value, _consumed)) = Self::parse_next_header_value(value_slice) { - params.error = Some(value); - } - } - - // extract error_description - let desc_key = "error_description="; - if let Some(pos) = header_lowercase.find(desc_key) { - let global_pos = pos + desc_key.len(); - let value_slice = &header[global_pos..]; - if let Some((value, _consumed)) = Self::parse_next_header_value(value_slice) { - params.error_description = Some(value); - } - } - - params - } - - /// Parses an authentication parameter value from a `WWW-Authenticate` header fragment. - /// The header fragment should start with the header value after the `=` character and then - /// reads until the value ends. - /// - /// Returns the extracted value together with the number of bytes consumed from the provided - /// fragment. Quoted values support escaped characters (e.g. `\"`). The parser skips leading - /// whitespace before reading either a quoted or token value. If no well-formed value is found, - /// `None` is returned. - fn parse_next_header_value(header_fragment: &str) -> Option<(String, usize)> { - let trimmed = header_fragment.trim_start(); - let leading_ws = header_fragment.len() - trimmed.len(); - - if let Some(stripped) = trimmed.strip_prefix('"') { - let mut escaped = false; - let mut result = String::new(); - #[allow(clippy::manual_strip)] - for (idx, ch) in stripped.char_indices() { - if escaped { - result.push(ch); - escaped = false; - continue; - } - match ch { - '\\' => escaped = true, - '"' => return Some((result, leading_ws + idx + 2)), - _ => result.push(ch), - } - } - None - } else { - let end = trimmed - .find(|c: char| c == ',' || c == ';' || c.is_whitespace()) - .unwrap_or(trimmed.len()); - Some((trimmed[..end].to_string(), leading_ws + end)) - } - } - - // -- Client Credentials flow (SEP-1046) -- - - /// Validate that the authorization server metadata supports the requested - /// client credentials authentication method. - /// - /// For `client_secret_post`, checks `token_endpoint_auth_methods_supported`. - /// For `private_key_jwt`, additionally checks `token_endpoint_auth_signing_alg_values_supported`. - /// When the metadata field is absent, the method is permissive (assumes support). - pub fn validate_client_credentials_metadata( - &self, - config: &ClientCredentialsConfig, - ) -> Result<(), AuthError> { - let Some(metadata) = self.metadata.as_ref() else { - return Ok(()); - }; - - if let Some(methods) = metadata - .additional_fields - .get("token_endpoint_auth_methods_supported") - .and_then(|v| v.as_array()) - { - let is_supported = match config { - ClientCredentialsConfig::ClientSecret { .. } => { - // Accept either client_secret_post (request body) or - // client_secret_basic (HTTP Basic) per the MCP auth spec. - methods.iter().any(|m| { - matches!( - m.as_str(), - Some("client_secret_post") | Some("client_secret_basic") - ) - }) - } - #[cfg(feature = "auth-client-credentials-jwt")] - ClientCredentialsConfig::PrivateKeyJwt { .. } => methods - .iter() - .any(|m| m.as_str() == Some("private_key_jwt")), - }; - if !is_supported { - let required_method = config.auth_method(); - let supported: Vec<&str> = methods.iter().filter_map(|m| m.as_str()).collect(); - return Err(AuthError::ClientCredentialsError(format!( - "Authorization server does not support auth method '{}'. Supported: {:?}", - required_method, supported - ))); - } - } - - #[cfg(feature = "auth-client-credentials-jwt")] - if let ClientCredentialsConfig::PrivateKeyJwt { - signing_algorithm, .. - } = config - && let Some(algs) = metadata - .additional_fields - .get("token_endpoint_auth_signing_alg_values_supported") - .and_then(|v| v.as_array()) - { - let alg_str = signing_algorithm.as_str(); - if !algs.iter().any(|a| a.as_str() == Some(alg_str)) { - let supported: Vec<&str> = algs.iter().filter_map(|a| a.as_str()).collect(); - return Err(AuthError::ClientCredentialsError(format!( - "Authorization server does not support signing algorithm '{}'. Supported: {:?}", - alg_str, supported - ))); - } - } - - Ok(()) - } - - /// Configure the OAuth2 client for the client credentials flow. - /// - /// Selects `client_secret_post` (request body) by default. Switches to - /// `client_secret_basic` (HTTP Basic) only when the server advertises that - /// method exclusively. For `PrivateKeyJwt`, no OAuth client state is needed - /// here; the token request is built manually in `exchange_client_credentials_jwt`. - pub fn configure_client_credentials( - &mut self, - config: &ClientCredentialsConfig, - ) -> Result<(), AuthError> { - let metadata = self - .metadata - .as_ref() - .ok_or(AuthError::NoAuthorizationSupport)?; - - let token_url = TokenUrl::new(metadata.token_endpoint.clone()) - .map_err(|e| AuthError::OAuthError(format!("Invalid token URL: {}", e)))?; - - // auth_url is required by the type but won't be used for client credentials - let auth_url = AuthUrl::new(metadata.authorization_endpoint.clone()) - .map_err(|e| AuthError::OAuthError(format!("Invalid authorization URL: {}", e)))?; - - let client_id = ClientId::new(config.client_id().to_string()); - - let mut client_builder: OAuthClient = oauth2::Client::new(client_id) - .set_auth_uri(auth_url) - .set_token_uri(token_url); - - match config { - ClientCredentialsConfig::ClientSecret { client_secret, .. } => { - client_builder = - client_builder.set_client_secret(ClientSecret::new(client_secret.clone())); - // Use client_secret_basic (HTTP Basic) when that is the only method - // the server advertises; fall back to client_secret_post (request body). - let only_basic = metadata - .additional_fields - .get("token_endpoint_auth_methods_supported") - .and_then(|v| v.as_array()) - .map(|arr| { - let (has_basic, has_post) = - arr.iter() - .fold((false, false), |(b, p), m| match m.as_str() { - Some("client_secret_basic") => (true, p), - Some("client_secret_post") => (b, true), - _ => (b, p), - }); - has_basic && !has_post - }) - .unwrap_or_default(); - if !only_basic { - client_builder = client_builder.set_auth_type(AuthType::RequestBody); - } - } - #[cfg(feature = "auth-client-credentials-jwt")] - ClientCredentialsConfig::PrivateKeyJwt { .. } => { - // For JWT, client identity comes from the assertion's `sub` claim. - // The request is built manually in exchange_client_credentials_jwt to - // ensure client_id is not included in the body per RFC 7523 §3. - } - } - - self.oauth_client = Some(client_builder); - Ok(()) - } - - /// Exchange client credentials for an access token (SEP-1046). - /// - /// For `ClientSecret`: sends credentials in the request body (or Authorization header - /// for `client_secret_basic`) with scopes and resource. - /// For `PrivateKeyJwt`: builds the request manually (no `client_id` in body per RFC 7523 §3). - pub async fn exchange_client_credentials( - &self, - config: &ClientCredentialsConfig, - ) -> Result { - // The MCP auth spec requires the `resource` parameter in all token requests. - if config.resource().is_none() { - return Err(AuthError::ClientCredentialsError( - "resource parameter is required by the MCP auth spec".to_string(), - )); - } - - // For private_key_jwt, use a separate path that omits client_id from the request - // body, as required by RFC 7523 §3 (client is identified by the JWT `sub` claim). - #[cfg(feature = "auth-client-credentials-jwt")] - if matches!(config, ClientCredentialsConfig::PrivateKeyJwt { .. }) { - return self.exchange_client_credentials_jwt(config).await; - } - - let oauth_client = self - .oauth_client - .as_ref() - .ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))?; - - let mut request = oauth_client.exchange_client_credentials(); - - for scope in config.scopes() { - request = request.add_scope(Scope::new(scope.clone())); - } - - if let Some(resource) = config.resource() { - request = request.add_extra_param("resource", resource); - } - - let token_result = match request - .request_async(&OAuth2HttpClient { - client: self.http_client.as_ref(), - redirect_policy: OAuthHttpRedirectPolicy::Stop, - }) - .await - { - Ok(token) => token, - Err(RequestTokenError::Parse(_, body)) => { - match serde_json::from_slice::(&body) { - Ok(parsed) => { - warn!( - "client credentials token exchange failed to parse completely but included a valid token response. Accepting it." - ); - parsed - } - Err(parse_err) => { - return Err(AuthError::ClientCredentialsError(format!( - "Token exchange parse error: {}", - parse_err - ))); - } - } - } - Err(e) => { - return Err(AuthError::ClientCredentialsError(format!( - "Token exchange failed: {}", - e - ))); - } - }; - - debug!("client credentials token result: {:?}", token_result); - - let granted_scopes: Vec = token_result - .scopes() - .map(|scopes| scopes.iter().map(|s| s.to_string()).collect()) - .unwrap_or_default(); - - *self.current_scopes.write().await = granted_scopes.clone(); - - let client_id = config.client_id().to_string(); - let stored = StoredCredentials { - client_id, - token_response: Some(token_result.clone()), - granted_scopes, - token_received_at: Some(Self::now_epoch_secs()), - issuer: self.metadata_issuer(), - }; - self.credential_store.save(stored).await?; - - Ok(token_result) - } - - /// Exchange client credentials using a JWT assertion (RFC 7523). - /// - /// Builds the token request manually so that `client_id` is **not** included in the - /// request body; client identity is conveyed solely by the `sub` claim in the assertion. - #[cfg(feature = "auth-client-credentials-jwt")] - async fn exchange_client_credentials_jwt( - &self, - config: &ClientCredentialsConfig, - ) -> Result { - let ClientCredentialsConfig::PrivateKeyJwt { - client_id, - signing_key, - signing_algorithm, - token_endpoint_audience, - scopes, - resource, - } = config - else { - return Err(AuthError::InternalError( - "expected PrivateKeyJwt config".to_string(), - )); - }; - - let metadata = self - .metadata - .as_ref() - .ok_or(AuthError::NoAuthorizationSupport)?; - - let token_endpoint_url = url::Url::parse(&metadata.token_endpoint).map_err(|e| { - AuthError::ClientCredentialsError(format!( - "Invalid token endpoint URL in authorization metadata: {e}" - )) - })?; - if !is_allowed_client_credentials_endpoint(&self.base_url, &token_endpoint_url) { - return Err(AuthError::ClientCredentialsError( - "Insecure token endpoint URL: HTTPS is required for client credentials flow" - .to_string(), - )); - } - - let audience = - client_authentication_audience(metadata, token_endpoint_audience.as_deref())?; - - let assertion = - Self::build_jwt_assertion(client_id, audience, signing_key, *signing_algorithm)?; - - let scope_str = scopes.join(" "); - let mut serializer = url::form_urlencoded::Serializer::new(String::new()); - serializer.append_pair("grant_type", "client_credentials"); - serializer.append_pair( - "client_assertion_type", - "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", - ); - serializer.append_pair("client_assertion", &assertion); - if !scope_str.is_empty() { - serializer.append_pair("scope", &scope_str); - } - if let Some(res) = resource.as_deref() { - serializer.append_pair("resource", res); - } - let body_str = serializer.finish(); - - let request = oauth2::http::Request::builder() - .method("POST") - .uri(token_endpoint_url.as_str()) - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(body_str.into_bytes()) - .map_err(|error| AuthError::ClientCredentialsError(error.to_string()))?; - let response = self - .http_client - .execute(OAuthHttpRequest::new( - request, - OAuthHttpRedirectPolicy::Stop, - )) - .await - .map_err(|e| { - AuthError::ClientCredentialsError(format!("Token exchange request failed: {e}")) - })?; - - let status = response.status(); - let body = response.body(); - - if !status.is_success() { - let msg = if let Ok(v) = serde_json::from_slice::(body) { - let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("unknown"); - let desc = v - .get("error_description") - .and_then(|d| d.as_str()) - .unwrap_or(""); - format!("Token exchange failed: {error}: {desc}") - } else { - format!("Token exchange failed: HTTP {status}") - }; - return Err(AuthError::ClientCredentialsError(msg)); - } - - let token_result = serde_json::from_slice::(body).map_err(|e| { - AuthError::ClientCredentialsError(format!("Failed to parse token response: {e}")) - })?; - - debug!("client credentials JWT token result: {:?}", token_result); - - let granted_scopes: Vec = token_result - .scopes() - .map(|scopes| scopes.iter().map(|s| s.to_string()).collect()) - .unwrap_or_default(); - - *self.current_scopes.write().await = granted_scopes.clone(); - - let stored = StoredCredentials { - client_id: client_id.clone(), - token_response: Some(token_result.clone()), - granted_scopes, - token_received_at: Some(Self::now_epoch_secs()), - issuer: self.metadata_issuer(), - }; - self.credential_store.save(stored).await?; - - Ok(token_result) - } - - /// Build a JWT assertion per RFC 7523 for private_key_jwt authentication. - #[cfg(feature = "auth-client-credentials-jwt")] - fn build_jwt_assertion( - client_id: &str, - audience: &str, - signing_key: &[u8], - algorithm: JwtSigningAlgorithm, - ) -> Result { - use serde_json::json; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - let jti = uuid::Uuid::new_v4().to_string(); - - let claims = json!({ - "iss": client_id, - "sub": client_id, - "aud": audience, - "iat": now, - "exp": now + 300, // 5 minutes - "jti": jti, - }); - - let header = client_authentication_header(algorithm); - let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(signing_key).or_else(|_| { - jsonwebtoken::EncodingKey::from_ec_pem(signing_key).map_err(|e| { - AuthError::JwtSigningError(format!("Failed to parse signing key: {}", e)) - }) - })?; - - jsonwebtoken::encode(&header, &claims, &encoding_key) - .map_err(|e| AuthError::JwtSigningError(format!("Failed to sign JWT: {}", e))) - } -} - -/// Parameters returned by an OAuth authorization redirect. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub struct AuthorizationCallback { - pub code: String, - pub csrf_token: String, - pub issuer: Option, -} - -impl AuthorizationCallback { - /// Parse an OAuth redirect URL and extract `code`, `state`, and optional RFC 9207 `iss`. - pub fn from_redirect_url(url: &str) -> Result { - let url = Url::parse(url)?; - let mut code = None; - let mut csrf_token = None; - let mut issuer = None; - - for (key, value) in url.query_pairs() { - match key.as_ref() { - "code" => code = Some(value.into_owned()), - "state" => csrf_token = Some(value.into_owned()), - "iss" => issuer = Some(value.into_owned()), - _ => {} - } - } - - let code = code.ok_or_else(|| { - AuthError::AuthorizationFailed("Authorization callback missing code".to_string()) - })?; - let csrf_token = csrf_token.ok_or_else(|| { - AuthError::AuthorizationFailed("Authorization callback missing state".to_string()) - })?; - - Ok(Self { - code, - csrf_token, - issuer, - }) - } -} - -/// oauth2 authorization session, for guiding user to complete the authorization process -#[non_exhaustive] -pub struct AuthorizationSession { - pub auth_manager: AuthorizationManager, - pub auth_url: String, - pub redirect_uri: String, -} - -impl AuthorizationSession { - /// Create a new authorization session, selecting a client registration - /// mechanism per the [MCP authorization specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration) - /// priority order: - /// - /// 1. Pre-registered client information - /// ([`AuthorizationRequest::with_preregistered_client`]), when available - /// 2. Client ID Metadata Documents - /// ([`AuthorizationRequest::with_client_metadata_url`]), when the - /// authorization server advertises `client_id_metadata_document_supported` - /// 3. Dynamic Client Registration, when the authorization server - /// advertises a `registration_endpoint` - /// - /// The manager must already have discovered authorization server metadata. - /// If `request.scopes` is empty, scopes are selected using the SDK's - /// normal scope-selection policy. - /// - /// On failure, the manager is returned alongside the error so callers can - /// retry without losing the original configuration and stores. - pub async fn new( - mut auth_manager: AuthorizationManager, - mut request: AuthorizationRequest, - ) -> Result { - if request.client_secret.is_some() && request.client_id.is_none() { - return Err(( - auth_manager, - AuthError::RegistrationFailed( - "client_secret was provided without a pre-registered client_id; \ - pair with_client_secret with with_preregistered_client" - .to_string(), - ), - )); - } - - if request.scopes.is_empty() { - request.scopes = auth_manager.select_scopes(None, &[]); - } else { - auth_manager.add_offline_access_if_supported(&mut request.scopes); - } - - if request.application_type.is_some() { - auth_manager.application_type = request.application_type.clone(); - } - - let redirect_uri = request.redirect_uri.clone(); - let scopes = request.scopes.clone(); - let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - - let supports_url_based_client_id = auth_manager - .metadata - .as_ref() - .and_then(|m| { - m.additional_fields - .get("client_id_metadata_document_supported") - }) - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - // 1. pre-registered client information takes priority over everything else - let config = if let Some(client_id) = &request.client_id { - OAuthClientConfig { - client_id: client_id.clone(), - client_secret: request.client_secret.clone(), - scopes: scopes.clone(), - redirect_uri: redirect_uri.clone(), - application_type: request.application_type.clone(), - } - // 2. CIMD (SEP-991), when the server advertises support and the client hosts a metadata document - } else if let Some(client_metadata_url) = request.client_metadata_url.as_deref() - && supports_url_based_client_id - { - if !is_https_url(client_metadata_url) { - return Err(( - auth_manager, - AuthError::RegistrationFailed(format!( - "client_metadata_url must be a valid HTTPS URL with a non-root pathname, got: {}", - client_metadata_url - )), - )); - } - // SEP-991: URL-based Client IDs - use URL as client_id directly. - // SEP-837: match the hosted client-metadata.json application_type ("native") - OAuthClientConfig { - client_id: client_metadata_url.to_string(), - client_secret: None, - scopes: scopes.clone(), - redirect_uri: redirect_uri.clone(), - application_type: Some( - request - .application_type - .clone() - .unwrap_or_else(|| DEFAULT_APPLICATION_TYPE.to_string()), - ), - } - // 3. fall back to dynamic client registration - } else { - match auth_manager - .register_client( - request.client_name.as_deref().unwrap_or("MCP Client"), - &redirect_uri, - &scope_refs, - ) - .await - { - Ok(config) => config, - Err(e) => { - return Err(( - auth_manager, - AuthError::RegistrationFailed(format!( - "Dynamic registration failed: {}", - e - )), - )); - } - } - }; - - // reset client config - if let Err(e) = auth_manager.configure_client(config) { - return Err((auth_manager, e)); - } - let auth_url = match auth_manager.get_authorization_url(&scope_refs).await { - Ok(url) => url, - Err(e) => return Err((auth_manager, e)), - }; - - Ok(Self { - auth_manager, - auth_url, - redirect_uri, - }) - } - - /// create session for scope upgrade flow (existing manager + pre-computed auth url) - pub fn for_scope_upgrade( - auth_manager: AuthorizationManager, - auth_url: String, - redirect_uri: &str, - ) -> Self { - Self { - auth_manager, - auth_url, - redirect_uri: redirect_uri.to_string(), - } - } - - /// get client_id and credentials - pub async fn get_credentials(&self) -> Result { - self.auth_manager.get_credentials().await - } - - /// get authorization url - pub fn get_authorization_url(&self) -> &str { - &self.auth_url - } - - /// handle authorization code callback - pub async fn handle_callback( - &self, - code: &str, - csrf_token: &str, - ) -> Result { - self.handle_callback_with_issuer(code, csrf_token, None) - .await - } - - /// handle authorization code callback, validating the optional RFC 9207 - /// authorization response issuer (`iss`) when present. - pub async fn handle_callback_with_issuer( - &self, - code: &str, - csrf_token: &str, - issuer: Option<&str>, - ) -> Result { - self.auth_manager - .exchange_code_for_token_with_issuer(code, csrf_token, issuer) - .await - } - - /// handle an OAuth redirect URL, including optional RFC 9207 `iss` validation. - pub async fn handle_callback_url( - &self, - callback_url: &str, - ) -> Result { - let callback = AuthorizationCallback::from_redirect_url(callback_url)?; - self.handle_callback_with_issuer( - &callback.code, - &callback.csrf_token, - callback.issuer.as_deref(), - ) - .await - } -} - -/// http client extension, automatically add authorization header -pub struct AuthorizedHttpClient { - auth_manager: Arc, - inner_client: ReqwestClient, -} - -impl AuthorizedHttpClient { - /// create new authorized http client - pub fn new(auth_manager: Arc, client: Option) -> Self { - let inner_client = client.unwrap_or_default(); - Self { - auth_manager, - inner_client, - } - } - - /// send authorized request - pub async fn request( - &self, - method: reqwest::Method, - url: U, - ) -> Result { - let request = self.inner_client.request(method, url); - self.auth_manager.prepare_request(request).await - } - - /// send get request - pub async fn get(&self, url: U) -> Result { - let request = self.request(reqwest::Method::GET, url).await?; - let response = request.send().await?; - self.auth_manager.handle_response(response).await - } - - /// send post request - pub async fn post(&self, url: U) -> Result { - self.request(reqwest::Method::POST, url).await - } -} - -/// OAuth state machine -/// Use the OAuthState to manage the OAuth client is more recommend -/// But also you can use the AuthorizationManager,AuthorizationSession,AuthorizedHttpClient directly -#[non_exhaustive] -pub enum OAuthState { - /// the AuthorizationManager - Unauthorized(AuthorizationManager), - /// the AuthorizationSession - Session(AuthorizationSession), - /// the authd AuthorizationManager - Authorized(AuthorizationManager), - /// the authd http client - AuthorizedHttpClient(AuthorizedHttpClient), -} - -impl OAuthState { - fn oauth_http_client_config(&self) -> (Arc, OAuthHttpRedirectPolicy) { - let manager = match self { - OAuthState::Unauthorized(manager) | OAuthState::Authorized(manager) => manager, - OAuthState::Session(session) => &session.auth_manager, - OAuthState::AuthorizedHttpClient(client) => &client.auth_manager, - }; - ( - Arc::clone(&manager.http_client), - manager.refresh_redirect_policy, - ) - } - - async fn placeholder_state(&self) -> Result { - let (http_client, refresh_redirect_policy) = self.oauth_http_client_config(); - Ok(OAuthState::Unauthorized( - AuthorizationManager::new_inner( - DEFAULT_EXCHANGE_URL, - http_client, - refresh_redirect_policy, - ) - .await?, - )) - } - - /// Create new OAuth state machine - pub async fn new( - base_url: U, - client: Option, - ) -> Result { - let mut manager = AuthorizationManager::new(base_url).await?; - if let Some(client) = client { - manager.with_client(client)?; - } - - Ok(OAuthState::Unauthorized(manager)) - } - - /// Create an OAuth state machine that routes all OAuth HTTP operations - /// through the supplied client. - pub async fn new_with_oauth_http_client( - base_url: U, - client: Arc, - ) -> Result { - let manager = AuthorizationManager::new_with_oauth_http_client(base_url, client).await?; - Ok(OAuthState::Unauthorized(manager)) - } - - /// Get client_id and OAuth credentials - pub async fn get_credentials(&self) -> Result { - // return client_id and credentials - match self { - OAuthState::Unauthorized(manager) | OAuthState::Authorized(manager) => { - manager.get_credentials().await - } - OAuthState::Session(session) => session.get_credentials().await, - OAuthState::AuthorizedHttpClient(client) => client.auth_manager.get_credentials().await, - } - } - - /// Manually set credentials and move into authorized state - /// Useful if you're caching credentials externally and wish to reuse them - pub async fn set_credentials( - &mut self, - client_id: &str, - credentials: OAuthTokenResponse, - ) -> Result<(), AuthError> { - if let OAuthState::Unauthorized(manager) = self { - let replacement = AuthorizationManager::new_inner( - DEFAULT_EXCHANGE_URL, - Arc::clone(&manager.http_client), - manager.refresh_redirect_policy, - ) - .await?; - let mut manager = std::mem::replace(manager, replacement); - - let granted_scopes: Vec = credentials - .scopes() - .map(|scopes| scopes.iter().map(|s| s.to_string()).collect()) - .unwrap_or_default(); - - *manager.current_scopes.write().await = granted_scopes.clone(); - - let resolution = manager.resolve_metadata().await?; - manager.metadata = Some(resolution.metadata); - - let stored = StoredCredentials { - client_id: client_id.to_string(), - token_response: Some(credentials), - granted_scopes, - token_received_at: Some(AuthorizationManager::now_epoch_secs()), - issuer: manager.metadata_issuer(), - }; - manager.credential_store.save(stored).await?; - - manager.configure_client_id(client_id)?; - - *self = OAuthState::Authorized(manager); - Ok(()) - } else { - Err(AuthError::InternalError( - "Cannot set credentials in this state".to_string(), - )) - } - } - - /// Start authorization. - /// - /// Selects a client registration mechanism from the identity material in - /// `request`, following the [MCP authorization specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration) - /// priority order: - /// - /// 1. Pre-registered client information - /// ([`AuthorizationRequest::with_preregistered_client`]), when available - /// 2. Client ID Metadata Documents - /// ([`AuthorizationRequest::with_client_metadata_url`]), when the - /// authorization server advertises `client_id_metadata_document_supported` - /// 3. Dynamic Client Registration, when the authorization server - /// advertises a `registration_endpoint` - /// - /// If `request.scopes` is empty, scopes are selected using the SDK's - /// normal scope-selection policy. - /// - /// On failure, the state returns to `Unauthorized` so callers can retry - /// without losing the original configuration and stores. - pub async fn start_authorization( - &mut self, - request: AuthorizationRequest, - ) -> Result<(), AuthError> { - let placeholder = self.placeholder_state().await?; - let old = std::mem::replace(self, placeholder); - let OAuthState::Unauthorized(mut manager) = old else { - *self = old; - return Err(AuthError::InternalError( - "Already in session state".to_string(), - )); - }; - debug!("start discovery"); - let resolution = manager - .resolve_metadata_from_challenge(request.challenge.as_deref()) - .await; - let metadata = match resolution { - Ok(resolution) => resolution.metadata, - Err(e) => { - *self = OAuthState::Unauthorized(manager); - return Err(e); - } - }; - manager.metadata = Some(metadata); - debug!("start session"); - match AuthorizationSession::new(manager, request).await { - Ok(session) => { - *self = OAuthState::Session(session); - Ok(()) - } - Err((manager, e)) => { - *self = OAuthState::Unauthorized(manager); - Err(e) - } - } - } - - /// complete authorization - pub async fn complete_authorization(&mut self) -> Result<(), AuthError> { - let placeholder = self.placeholder_state().await?; - if let OAuthState::Session(session) = std::mem::replace(self, placeholder) { - *self = OAuthState::Authorized(session.auth_manager); - Ok(()) - } else { - Err(AuthError::InternalError("Not in session state".to_string())) - } - } - /// covert to authorized http client - pub async fn to_authorized_http_client(&mut self) -> Result<(), AuthError> { - let placeholder = self.placeholder_state().await?; - if let OAuthState::Authorized(manager) = std::mem::replace(self, placeholder) { - *self = OAuthState::AuthorizedHttpClient(AuthorizedHttpClient::new( - Arc::new(manager), - None, - )); - Ok(()) - } else { - Err(AuthError::InternalError( - "Not in authorized state".to_string(), - )) - } - } - - /// request scope upgrade (Authorized -> Session); returns auth URL to open - pub async fn request_scope_upgrade( - &mut self, - required_scope: &str, - redirect_uri: &str, - ) -> Result { - let placeholder = self.placeholder_state().await?; - let old = std::mem::replace(self, placeholder); - let OAuthState::Authorized(manager) = old else { - *self = old; - return Err(AuthError::InternalError( - "Not in authorized state".to_string(), - )); - }; - let auth_url = match manager.request_scope_upgrade(required_scope).await { - Ok(url) => url, - Err(e) => { - *self = OAuthState::Authorized(manager); - return Err(e); - } - }; - let session = - AuthorizationSession::for_scope_upgrade(manager, auth_url.clone(), redirect_uri); - *self = OAuthState::Session(session); - Ok(auth_url) - } - - /// get current authorization url - pub async fn get_authorization_url(&self) -> Result { - match self { - OAuthState::Session(session) => Ok(session.get_authorization_url().to_string()), - OAuthState::Unauthorized(_) => { - Err(AuthError::InternalError("Not in session state".to_string())) - } - OAuthState::Authorized(_) => { - Err(AuthError::InternalError("Already authorized".to_string())) - } - OAuthState::AuthorizedHttpClient(_) => { - Err(AuthError::InternalError("Already authorized".to_string())) - } - } - } - - /// handle authorization callback - pub async fn handle_callback(&mut self, code: &str, csrf_token: &str) -> Result<(), AuthError> { - self.handle_callback_with_issuer(code, csrf_token, None) - .await - } - - /// handle authorization callback, validating the optional RFC 9207 - /// authorization response issuer (`iss`) when present. - pub async fn handle_callback_with_issuer( - &mut self, - code: &str, - csrf_token: &str, - issuer: Option<&str>, - ) -> Result<(), AuthError> { - match self { - OAuthState::Session(session) => { - session - .handle_callback_with_issuer(code, csrf_token, issuer) - .await?; - self.complete_authorization().await - } - OAuthState::Unauthorized(_) => { - Err(AuthError::InternalError("Not in session state".to_string())) - } - OAuthState::Authorized(_) => { - Err(AuthError::InternalError("Already authorized".to_string())) - } - OAuthState::AuthorizedHttpClient(_) => { - Err(AuthError::InternalError("Already authorized".to_string())) - } - } - } - - /// handle an OAuth redirect URL, including optional RFC 9207 `iss` validation. - pub async fn handle_callback_url(&mut self, callback_url: &str) -> Result<(), AuthError> { - let callback = AuthorizationCallback::from_redirect_url(callback_url)?; - self.handle_callback_with_issuer( - &callback.code, - &callback.csrf_token, - callback.issuer.as_deref(), - ) - .await - } - - /// get access token - pub async fn get_access_token(&self) -> Result { - match self { - OAuthState::Unauthorized(manager) => manager.get_access_token().await, - OAuthState::Session(_) => { - Err(AuthError::InternalError("Not in manager state".to_string())) - } - OAuthState::Authorized(_) => { - Err(AuthError::InternalError("Already authorized".to_string())) - } - OAuthState::AuthorizedHttpClient(_) => { - Err(AuthError::InternalError("Already authorized".to_string())) - } - } - } - - /// refresh access token - pub async fn refresh_token(&self) -> Result<(), AuthError> { - match self { - OAuthState::Unauthorized(_) => { - Err(AuthError::InternalError("Not in manager state".to_string())) - } - OAuthState::Session(_) => { - Err(AuthError::InternalError("Not in manager state".to_string())) - } - OAuthState::Authorized(manager) => { - manager.refresh_token().await?; - Ok(()) - } - OAuthState::AuthorizedHttpClient(_) => { - Err(AuthError::InternalError("Already authorized".to_string())) - } - } - } - - pub fn into_authorization_manager(self) -> Option { - match self { - OAuthState::Authorized(manager) => Some(manager), - _ => None, - } - } - - /// Authenticate using OAuth 2.0 Client Credentials flow (SEP-1046). - /// - /// Transitions directly from `Unauthorized` to `Authorized`, skipping the - /// interactive `Session` state entirely. Discovers metadata, configures the - /// client, and exchanges credentials for an access token. - pub async fn authenticate_client_credentials( - &mut self, - config: ClientCredentialsConfig, - ) -> Result<(), AuthError> { - let placeholder = self.placeholder_state().await?; - let OAuthState::Unauthorized(mut manager) = std::mem::replace(self, placeholder) else { - return Err(AuthError::InternalError( - "Client credentials flow requires Unauthorized state".to_string(), - )); - }; - - // Discover metadata - let resolution = manager.resolve_metadata().await?; - manager.metadata = Some(resolution.metadata); - - // Validate server supports the requested auth method - manager.validate_client_credentials_metadata(&config)?; - - // Configure OAuth client - manager.configure_client_credentials(&config)?; - - // Exchange credentials for token - manager.exchange_client_credentials(&config).await?; - - *self = OAuthState::Authorized(manager); - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use std::{ - collections::{HashMap, VecDeque}, - sync::{Arc, Mutex as StdMutex}, - }; - - use oauth2::{AuthType, CsrfToken, HttpResponse, PkceCodeVerifier}; - use reqwest::StatusCode; - use rstest::rstest; - use url::Url; - - use super::{ - AuthError, AuthorizationCallback, AuthorizationManager, AuthorizationMetadata, - AuthorizationMetadataSource, AuthorizationRequest, AuthorizationSession, CredentialStore, - InMemoryCredentialStore, InMemoryStateStore, OAuthClientConfig, OAuthHttpClient, - OAuthHttpClientError, OAuthHttpClientFuture, OAuthHttpRedirectPolicy, OAuthHttpRequest, - ScopeUpgradeConfig, StateStore, StoredAuthorizationState, is_https_url, - }; - use crate::transport::auth::VendorExtraTokenFields; - - #[derive(Clone, Debug, PartialEq, Eq)] - struct RecordedOAuthRequest { - method: String, - uri: String, - redirect_policy: OAuthHttpRedirectPolicy, - body: Vec, - } - - #[derive(Clone, Default)] - struct RecordingOAuthHttpClient { - requests: Arc>>, - responses: Arc>>, - } - - impl RecordingOAuthHttpClient { - fn with_responses(responses: Vec) -> Self { - Self { - responses: Arc::new(StdMutex::new(responses.into())), - ..Default::default() - } - } - - fn requests(&self) -> Vec { - self.requests.lock().unwrap().clone() - } - } - - impl OAuthHttpClient for RecordingOAuthHttpClient { - fn execute(&self, request: OAuthHttpRequest) -> OAuthHttpClientFuture<'_> { - self.requests.lock().unwrap().push(RecordedOAuthRequest { - method: request.request.method().to_string(), - uri: request.request.uri().to_string(), - redirect_policy: request.redirect_policy, - body: request.request.body().clone(), - }); - let response = self.responses.lock().unwrap().pop_front(); - Box::pin(async move { response.ok_or_else(|| "missing fake response".into()) }) - } - } - - fn http_response(status: u16, body: serde_json::Value) -> HttpResponse { - oauth2::http::Response::builder() - .status(status) - .body(serde_json::to_vec(&body).unwrap()) - .unwrap() - } - - fn redirect_response(location: &str) -> HttpResponse { - oauth2::http::Response::builder() - .status(302) - .header("location", location) - .body(Vec::new()) - .unwrap() - } - - fn empty_response(status: u16) -> HttpResponse { - oauth2::http::Response::builder() - .status(status) - .body(Vec::new()) - .unwrap() - } - - #[test] - fn oauth_http_client_error_preserves_source_chain() { - #[derive(Debug, thiserror::Error)] - #[error("request failed")] - struct RequestError(#[source] std::io::Error); - - let error: OAuthHttpClientError = RequestError(std::io::Error::other( - "certificate signed by unknown authority", - )) - .into(); - assert!(error.downcast_ref::().is_some()); - - let url = Url::parse("https://mcp.example.com/mcp").unwrap(); - let error = AuthorizationManager::discovery_failed(&url, error); - assert_eq!( - error.to_string(), - "Metadata error: OAuth metadata discovery failed for https://mcp.example.com/mcp\n Caused by: request failed\n Caused by: certificate signed by unknown authority" - ); - } - - #[tokio::test] - async fn default_http_client_preserves_connection_failure_cause() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let url = format!("http://{}/mcp", listener.local_addr().unwrap()); - drop(listener); - - let manager = AuthorizationManager::new(&url).await.unwrap(); - let error = manager.resolve_metadata().await.unwrap_err(); - - assert!( - matches!( - error, - AuthError::MetadataError(ref reason) - if reason.contains(&url) - && reason.contains("\n Caused by: error sending request for url") - && reason.matches("error sending request for url").count() == 1 - && reason.to_ascii_lowercase().contains("connection refused") - ), - "unexpected discovery error: {error}" - ); - } - - #[tokio::test] - async fn authorization_metadata_propagates_transport_failure() { - let responses = preregistered_discovery_responses() - .into_iter() - .take(2) - .collect(); - let client = RecordingOAuthHttpClient::with_responses(responses); - let manager = AuthorizationManager::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let error = manager.resolve_metadata().await.unwrap_err(); - - assert!( - matches!( - error, - AuthError::MetadataError(ref reason) - if reason.contains("https://auth.example.com/.well-known/oauth-authorization-server") - && reason.contains("missing fake response") - ), - "unexpected discovery error: {error}" - ); - assert_eq!(client.requests().len(), 3); - } - - #[tokio::test] - async fn discovery_propagates_server_errors() { - let manager = AuthorizationManager::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(RecordingOAuthHttpClient::with_responses(vec![ - empty_response(503), - ])), - ) - .await - .unwrap(); - - let error = manager.resolve_metadata().await.unwrap_err(); - - assert!( - matches!( - error, - AuthError::MetadataError(ref reason) - if reason.contains("https://mcp.example.com/mcp") && reason.contains("503") - ), - "unexpected discovery error: {error}" - ); - } - - #[rstest] - #[case::resource_request_timeout(StatusCode::REQUEST_TIMEOUT, 0, "https://mcp.example.com/mcp")] - #[case::resource_too_early(StatusCode::TOO_EARLY, 0, "https://mcp.example.com/mcp")] - #[case::resource_too_many_requests( - StatusCode::TOO_MANY_REQUESTS, - 0, - "https://mcp.example.com/mcp" - )] - #[case::protected_metadata_request_timeout( - StatusCode::REQUEST_TIMEOUT, - 1, - "https://mcp.example.com/.well-known/oauth-protected-resource" - )] - #[case::protected_metadata_too_early( - StatusCode::TOO_EARLY, - 1, - "https://mcp.example.com/.well-known/oauth-protected-resource" - )] - #[case::protected_metadata_too_many_requests( - StatusCode::TOO_MANY_REQUESTS, - 1, - "https://mcp.example.com/.well-known/oauth-protected-resource" - )] - #[case::authorization_request_timeout( - StatusCode::REQUEST_TIMEOUT, - 2, - "https://auth.example.com/.well-known/oauth-authorization-server" - )] - #[case::authorization_too_early( - StatusCode::TOO_EARLY, - 2, - "https://auth.example.com/.well-known/oauth-authorization-server" - )] - #[case::authorization_too_many_requests( - StatusCode::TOO_MANY_REQUESTS, - 2, - "https://auth.example.com/.well-known/oauth-authorization-server" - )] - #[tokio::test] - async fn discovery_propagates_transient_client_errors( - #[case] status: StatusCode, - #[case] successful_response_count: usize, - #[case] expected_url: &str, - ) { - let mut responses = preregistered_discovery_responses(); - responses.insert(successful_response_count, empty_response(status.as_u16())); - - let client = RecordingOAuthHttpClient::with_responses(responses); - let manager = AuthorizationManager::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let error = manager.resolve_metadata().await.unwrap_err(); - - assert!( - matches!( - error, - AuthError::MetadataError(ref reason) - if reason.contains(expected_url) && reason.contains(status.as_str()) - ), - "unexpected discovery error for {status}: {error}" - ); - assert_eq!(client.requests().len(), successful_response_count + 1); - } - - #[tokio::test] - async fn custom_http_client_handles_protected_resource_discovery() { - let challenge = oauth2::http::Response::builder() - .status(401) - .header( - "www-authenticate", - r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, - ) - .body(Vec::new()) - .unwrap(); - let client = RecordingOAuthHttpClient::with_responses(vec![ - challenge, - http_response( - 200, - serde_json::json!({ - "resource": "https://mcp.example.com", - "authorization_servers": ["https://auth.example.com"] - }), - ), - http_response( - 200, - serde_json::json!({ - "issuer": "https://auth.example.com", - "authorization_endpoint": "https://auth.example.com/authorize", - "token_endpoint": "https://auth.example.com/token" - }), - ), - ]); - let manager = AuthorizationManager::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let metadata = manager.resolve_metadata().await.unwrap().metadata; - - assert_eq!(metadata.token_endpoint, "https://auth.example.com/token"); - assert_eq!( - manager.discovered_resource.read().await.as_deref(), - Some("https://mcp.example.com") - ); - assert_eq!( - client.requests(), - vec![ - RecordedOAuthRequest { - method: "GET".to_string(), - uri: "https://mcp.example.com/mcp".to_string(), - redirect_policy: OAuthHttpRedirectPolicy::Stop, - body: Vec::new(), - }, - RecordedOAuthRequest { - method: "GET".to_string(), - uri: "https://mcp.example.com/.well-known/oauth-protected-resource".to_string(), - redirect_policy: OAuthHttpRedirectPolicy::Stop, - body: Vec::new(), - }, - RecordedOAuthRequest { - method: "GET".to_string(), - uri: "https://auth.example.com/.well-known/oauth-authorization-server" - .to_string(), - redirect_policy: OAuthHttpRedirectPolicy::Stop, - body: Vec::new(), - }, - ] - ); - } - - #[tokio::test] - async fn refresh_token_uses_discovered_protected_resource() { - let challenge = oauth2::http::Response::builder() - .status(401) - .header( - "www-authenticate", - r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, - ) - .body(Vec::new()) - .unwrap(); - let client = RecordingOAuthHttpClient::with_responses(vec![ - challenge, - http_response( - 200, - serde_json::json!({ - "resource": "https://mcp.example.com", - "authorization_servers": ["https://auth.example.com"] - }), - ), - http_response( - 200, - serde_json::json!({ - "issuer": "https://auth.example.com", - "authorization_endpoint": "https://auth.example.com/authorize", - "token_endpoint": "https://auth.example.com/token", - "response_types_supported": ["code"], - "code_challenge_methods_supported": ["S256"] - }), - ), - http_response( - 200, - serde_json::json!({ - "access_token": "initial-access-token", - "token_type": "bearer", - "refresh_token": "initial-refresh-token", - "expires_in": 3600 - }), - ), - http_response( - 200, - serde_json::json!({ - "access_token": "refreshed-access-token", - "token_type": "bearer", - "refresh_token": "refreshed-refresh-token", - "expires_in": 3600 - }), - ), - ]); - let mut manager = AuthorizationManager::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let resolution = manager.resolve_metadata().await.unwrap(); - manager.set_metadata(resolution.metadata); - manager.configure_client_id("test-client-id").unwrap(); - - let authorization_url = manager.get_authorization_url(&[]).await.unwrap(); - let authorization_params: HashMap = Url::parse(&authorization_url) - .unwrap() - .query_pairs() - .into_owned() - .collect(); - let state = authorization_params.get("state").unwrap(); - - manager - .exchange_code_for_token("authorization-code", state) - .await - .unwrap(); - manager.refresh_token().await.unwrap(); - - let token_requests: Vec> = client - .requests() - .into_iter() - .filter(|request| request.uri == "https://auth.example.com/token") - .map(|request| { - url::form_urlencoded::parse(&request.body) - .into_owned() - .collect() - }) - .collect(); - - assert_eq!(token_requests.len(), 2); - assert_eq!( - token_requests[0].get("grant_type").map(String::as_str), - Some("authorization_code") - ); - assert_eq!( - token_requests[1].get("grant_type").map(String::as_str), - Some("refresh_token") - ); - assert_eq!( - [ - authorization_params.get("resource").map(String::as_str), - token_requests[0].get("resource").map(String::as_str), - token_requests[1].get("resource").map(String::as_str), - ], - [Some("https://mcp.example.com"); 3], - "authorization, code exchange, and refresh must use the discovered resource audience" - ); - } - - #[tokio::test] - async fn protected_resource_metadata_supports_authorization_server_path_insertion() { - let client = RecordingOAuthHttpClient::with_responses(vec![ - empty_response(401), - http_response( - 200, - serde_json::json!({ - "resource": "https://mcp.example.com/", - "authorization_servers": ["https://auth.example.com/tenant1"] - }), - ), - http_response( - 200, - serde_json::json!({ - "resource": "https://mcp.example.com/", - "authorization_servers": ["https://auth.example.com/tenant1"] - }), - ), - http_response( - 200, - serde_json::json!({ - "issuer": "https://auth.example.com/tenant1", - "authorization_endpoint": "https://auth.example.com/tenant1/authorize", - "token_endpoint": "https://auth.example.com/tenant1/token" - }), - ), - ]); - let manager = AuthorizationManager::new_with_oauth_http_client( - "https://mcp.example.com/", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let metadata = manager.resolve_metadata().await.unwrap().metadata; - - assert_eq!( - ( - metadata.issuer.as_deref(), - metadata.authorization_endpoint.as_str(), - client - .requests() - .iter() - .map(|request| request.uri.as_str()) - .collect::>(), - ), - ( - Some("https://auth.example.com/tenant1"), - "https://auth.example.com/tenant1/authorize", - vec![ - "https://mcp.example.com/", - "https://mcp.example.com/.well-known/oauth-protected-resource", - "https://mcp.example.com/.well-known/oauth-protected-resource", - "https://auth.example.com/.well-known/oauth-authorization-server/tenant1", - ], - ) - ); - } - - #[tokio::test] - async fn authorization_metadata_rejects_mismatched_issuer() { - let client = RecordingOAuthHttpClient::with_responses(vec![ - empty_response(401), - http_response( - 200, - serde_json::json!({ - "resource": "https://mcp.example.com/", - "authorization_servers": ["https://auth.example.com/tenant1"] - }), - ), - http_response( - 200, - serde_json::json!({ - "resource": "https://mcp.example.com/", - "authorization_servers": ["https://auth.example.com/tenant1"] - }), - ), - http_response( - 200, - serde_json::json!({ - "issuer": "https://evil.example.com/tenant1", - "authorization_endpoint": "https://evil.example.com/tenant1/authorize", - "token_endpoint": "https://evil.example.com/tenant1/token" - }), - ), - ]); - let manager = AuthorizationManager::new_with_oauth_http_client( - "https://mcp.example.com/", - Arc::new(client), - ) - .await - .unwrap(); - - let error = manager.resolve_metadata().await.unwrap_err(); - - assert!( - matches!( - error, - AuthError::AuthorizationServerMismatch { - ref expected_issuer, - ref received_issuer - } if expected_issuer == "https://auth.example.com/tenant1" - && received_issuer == "https://evil.example.com/tenant1" - ), - "expected authorization server issuer mismatch, got: {error:?}" - ); - } - - #[tokio::test] - async fn authorization_metadata_accepts_oidc_path_appended_issuer() { - let discovery_url = - Url::parse("https://auth.example.com/tenant1/.well-known/openid-configuration") - .unwrap(); - let metadata = AuthorizationMetadata { - issuer: Some("https://auth.example.com/tenant1".to_string()), - authorization_endpoint: "https://auth.example.com/tenant1/authorize".to_string(), - token_endpoint: "https://auth.example.com/tenant1/token".to_string(), - ..Default::default() - }; - let manager = AuthorizationManager::new("https://mcp.example.com/") - .await - .unwrap(); - - manager - .validate_authorization_metadata_issuer(&discovery_url, &metadata) - .unwrap(); - } - - #[test] - fn authorization_metadata_allows_only_root_trailing_slash_equivalence() { - assert!(AuthorizationManager::issuer_identifiers_match( - "https://auth.example.com/", - "https://auth.example.com" - )); - assert!(!AuthorizationManager::issuer_identifiers_match( - "https://auth.example.com/tenant1/", - "https://auth.example.com/tenant1" - )); - } - - #[tokio::test] - async fn authorization_metadata_accepts_oidc_path_inserted_issuer() { - let discovery_url = - Url::parse("https://auth.example.com/.well-known/openid-configuration/tenant1") - .unwrap(); - let metadata = AuthorizationMetadata { - issuer: Some("https://auth.example.com/tenant1".to_string()), - authorization_endpoint: "https://auth.example.com/tenant1/authorize".to_string(), - token_endpoint: "https://auth.example.com/tenant1/token".to_string(), - ..Default::default() - }; - let manager = AuthorizationManager::new("https://mcp.example.com/") - .await - .unwrap(); - - manager - .validate_authorization_metadata_issuer(&discovery_url, &metadata) - .unwrap(); - } - - #[tokio::test] - async fn authorization_metadata_allows_missing_issuer_when_configured() { - let discovery_url = - Url::parse("https://auth.example.com/.well-known/openid-configuration/tenant1") - .unwrap(); - let metadata = AuthorizationMetadata { - issuer: None, - authorization_endpoint: "https://auth.example.com/tenant1/authorize".to_string(), - token_endpoint: "https://auth.example.com/tenant1/token".to_string(), - ..Default::default() - }; - - let mut manager = AuthorizationManager::new("https://mcp.example.com/") - .await - .unwrap(); - manager.set_allow_missing_issuer(true); - - manager - .validate_authorization_metadata_issuer(&discovery_url, &metadata) - .unwrap(); - } - - #[tokio::test] - async fn authorization_metadata_rejects_missing_issuer_by_default() { - let discovery_url = - Url::parse("https://auth.example.com/.well-known/openid-configuration/tenant1") - .unwrap(); - let metadata = AuthorizationMetadata { - issuer: None, - authorization_endpoint: "https://auth.example.com/tenant1/authorize".to_string(), - token_endpoint: "https://auth.example.com/tenant1/token".to_string(), - ..Default::default() - }; - let manager = AuthorizationManager::new("https://mcp.example.com/") - .await - .unwrap(); - - let error = manager - .validate_authorization_metadata_issuer(&discovery_url, &metadata) - .unwrap_err(); - - assert!( - matches!( - error, - AuthError::AuthorizationServerMissingIssuer { ref expected_issuer } - if expected_issuer == "https://auth.example.com/tenant1" - ), - "expected missing issuer error, got: {error:?}" - ); - } - - #[tokio::test] - async fn protected_resource_metadata_supports_custom_location_and_oidc_path_append() { - let challenge = oauth2::http::Response::builder() - .status(401) - .header( - "www-authenticate", - r#"Bearer resource_metadata="/custom/metadata/location.json""#, - ) - .body(Vec::new()) - .unwrap(); - let client = RecordingOAuthHttpClient::with_responses(vec![ - challenge, - http_response( - 200, - serde_json::json!({ - "resource": "https://mcp.example.com/mcp", - "authorization_servers": ["https://auth.example.com/tenant1"] - }), - ), - empty_response(404), - empty_response(404), - http_response( - 200, - serde_json::json!({ - "issuer": "https://auth.example.com/tenant1", - "authorization_endpoint": "https://auth.example.com/tenant1/authorize", - "token_endpoint": "https://auth.example.com/tenant1/token" - }), - ), - ]); - let manager = AuthorizationManager::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let metadata = manager.resolve_metadata().await.unwrap().metadata; - - assert_eq!( - ( - metadata.token_endpoint.as_str(), - client - .requests() - .iter() - .map(|request| request.uri.as_str()) - .collect::>(), - ), - ( - "https://auth.example.com/tenant1/token", - vec![ - "https://mcp.example.com/mcp", - "https://mcp.example.com/custom/metadata/location.json", - "https://auth.example.com/.well-known/oauth-authorization-server/tenant1", - "https://auth.example.com/.well-known/openid-configuration/tenant1", - "https://auth.example.com/tenant1/.well-known/openid-configuration", - ], - ) - ); - assert!( - client - .requests() - .iter() - .all(|request| request.method == "GET"), - "discovery must not send non-GET requests" - ); - } - - #[rstest] - #[case::not_found(StatusCode::NOT_FOUND)] - #[case::method_not_allowed(StatusCode::METHOD_NOT_ALLOWED)] - #[tokio::test] - async fn resolve_metadata_reports_legacy_fallback_when_nothing_is_discovered( - #[case] status: StatusCode, - ) { - let client = RecordingOAuthHttpClient::with_responses(vec![ - empty_response(status.as_u16()), - empty_response(status.as_u16()), - empty_response(status.as_u16()), - empty_response(status.as_u16()), - ]); - let manager = AuthorizationManager::new_with_oauth_http_client( - "https://legacy.example.com/", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let resolution = manager.resolve_metadata().await.unwrap(); - - assert_eq!( - ( - resolution.source, - resolution.metadata.authorization_endpoint.as_str(), - resolution.metadata.token_endpoint.as_str(), - resolution.metadata.registration_endpoint.as_deref(), - client - .requests() - .iter() - .map(|request| request.uri.as_str()) - .collect::>(), - ), - ( - AuthorizationMetadataSource::LegacyEndpointFallback, - "https://legacy.example.com/authorize", - "https://legacy.example.com/token", - Some("https://legacy.example.com/register"), - vec![ - "https://legacy.example.com/", - "https://legacy.example.com/.well-known/oauth-protected-resource", - "https://legacy.example.com/.well-known/oauth-authorization-server", - "https://legacy.example.com/.well-known/openid-configuration", - ], - ) - ); - } - - #[tokio::test] - async fn resolve_metadata_reports_protected_resource_metadata() { - let challenge = oauth2::http::Response::builder() - .status(401) - .header( - "www-authenticate", - r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, - ) - .body(Vec::new()) - .unwrap(); - let client = RecordingOAuthHttpClient::with_responses(vec![ - challenge, - http_response( - 200, - serde_json::json!({ - "resource": "https://mcp.example.com/mcp", - "authorization_servers": ["https://auth.example.com"] - }), - ), - http_response( - 200, - serde_json::json!({ - "issuer": "https://auth.example.com", - "authorization_endpoint": "https://auth.example.com/authorize", - "token_endpoint": "https://auth.example.com/token" - }), - ), - ]); - let manager = AuthorizationManager::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client), - ) - .await - .unwrap(); - - let resolution = manager.resolve_metadata().await.unwrap(); - - assert_eq!( - ( - resolution.source, - resolution.metadata.token_endpoint.as_str(), - ), - ( - AuthorizationMetadataSource::ProtectedResourceMetadata, - "https://auth.example.com/token", - ) - ); - } - - #[tokio::test] - async fn resolve_metadata_from_challenge_uses_challenge_pointer_and_scope() { - let client = RecordingOAuthHttpClient::with_responses(vec![ - http_response( - 200, - serde_json::json!({ - "resource": "https://mcp.example.com/mcp", - "authorization_servers": ["https://auth.example.com"] - }), - ), - http_response( - 200, - serde_json::json!({ - "issuer": "https://auth.example.com", - "authorization_endpoint": "https://auth.example.com/authorize", - "token_endpoint": "https://auth.example.com/token" - }), - ), - ]); - let manager = AuthorizationManager::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let resolution = manager - .resolve_metadata_from_challenge(Some( - r#"Bearer resource_metadata="https://mcp.example.com/custom/prm.json", scope="mcp:read mcp:write""#, - )) - .await - .unwrap(); - - assert_eq!( - ( - resolution.source, - resolution.metadata.token_endpoint.as_str(), - manager.select_scopes(None, &[]), - client.requests().first().map(|request| request.uri.clone()), - ), - ( - AuthorizationMetadataSource::ProtectedResourceMetadata, - "https://auth.example.com/token", - vec!["mcp:read".to_string(), "mcp:write".to_string()], - // discovery starts at the challenge's pointer: no probing - Some("https://mcp.example.com/custom/prm.json".to_string()), - ) - ); - } - - #[tokio::test] - async fn resolve_metadata_from_challenge_falls_back_without_metadata_pointer() { - let client = RecordingOAuthHttpClient::with_responses(vec![ - empty_response(404), - empty_response(404), - empty_response(404), - empty_response(404), - ]); - let manager = AuthorizationManager::new_with_oauth_http_client( - "https://legacy.example.com/", - Arc::new(client), - ) - .await - .unwrap(); - - let resolution = manager - .resolve_metadata_from_challenge(Some(r#"Bearer scope="mcp:basic""#)) - .await - .unwrap(); - - assert_eq!( - (resolution.source, manager.select_scopes(None, &[]),), - ( - AuthorizationMetadataSource::LegacyEndpointFallback, - vec!["mcp:basic".to_string()], - ) - ); - } - - #[tokio::test] - async fn resolve_metadata_reports_authorization_server_metadata() { - let client = RecordingOAuthHttpClient::with_responses(vec![ - empty_response(404), - empty_response(404), - empty_response(404), - http_response( - 200, - serde_json::json!({ - "issuer": "https://mcp.example.com", - "authorization_endpoint": "https://mcp.example.com/oauth/authorize", - "token_endpoint": "https://mcp.example.com/oauth/token" - }), - ), - ]); - let manager = AuthorizationManager::new_with_oauth_http_client( - "https://mcp.example.com/", - Arc::new(client), - ) - .await - .unwrap(); - - let resolution = manager.resolve_metadata().await.unwrap(); - - assert_eq!( - ( - resolution.source, - resolution.metadata.token_endpoint.as_str(), - ), - ( - AuthorizationMetadataSource::AuthorizationServerMetadata, - "https://mcp.example.com/oauth/token", - ) - ); - } - - #[rstest] - #[case::protected_resource_metadata( - AuthorizationMetadataSource::ProtectedResourceMetadata, - true - )] - #[case::authorization_server_metadata( - AuthorizationMetadataSource::AuthorizationServerMetadata, - true - )] - #[case::legacy_endpoint_fallback(AuthorizationMetadataSource::LegacyEndpointFallback, false)] - fn is_discovered_is_false_only_for_the_legacy_fallback( - #[case] source: AuthorizationMetadataSource, - #[case] expected: bool, - ) { - assert_eq!(source.is_discovered(), expected); - } - - fn preregistered_as_metadata_response() -> HttpResponse { - http_response( - 200, - serde_json::json!({ - "issuer": "https://auth.example.com", - "authorization_endpoint": "https://auth.example.com/authorize", - "token_endpoint": "https://auth.example.com/token", - "registration_endpoint": "https://auth.example.com/register", - "scopes_supported": ["read", "write", "offline_access"] - }), - ) - } - - /// discovery responses for the preregistered-client tests: a 401 challenge - /// pointing at protected resource metadata, the PRM document, then the - /// authorization server metadata. - fn preregistered_discovery_responses() -> Vec { - let challenge = oauth2::http::Response::builder() - .status(401) - .header( - "www-authenticate", - r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, - ) - .body(Vec::new()) - .unwrap(); - vec![ - challenge, - http_response( - 200, - serde_json::json!({ - "resource": "https://mcp.example.com/mcp", - "authorization_servers": ["https://auth.example.com"] - }), - ), - preregistered_as_metadata_response(), - ] - } - - fn auth_url_query(auth_url: &str) -> HashMap { - Url::parse(auth_url) - .unwrap() - .query_pairs() - .map(|(k, v)| (k.into_owned(), v.into_owned())) - .collect() - } - - #[tokio::test] - async fn preregistered_client_skips_registration_endpoint() { - let client = RecordingOAuthHttpClient::with_responses(preregistered_discovery_responses()); - let mut state = super::OAuthState::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let request = AuthorizationRequest::new("http://localhost:8080/callback") - .with_preregistered_client("preregistered-client") - .with_client_secret("secret") - .with_scopes(["read"]); - state.start_authorization(request).await.unwrap(); - - // the registration endpoint was advertised but must not be called - let requests = client.requests(); - assert!( - requests - .iter() - .all(|request| !request.uri.contains("/register")), - "registration endpoint should not be called: {requests:?}" - ); - - let auth_url = state.get_authorization_url().await.unwrap(); - let query = auth_url_query(&auth_url); - assert_eq!(query.get("client_id").unwrap(), "preregistered-client"); - assert!(matches!(state, super::OAuthState::Session(_))); - } - - #[tokio::test] - async fn authorization_session_selects_default_scopes_when_none_provided() { - let client = RecordingOAuthHttpClient::with_responses(preregistered_discovery_responses()); - let mut manager = AuthorizationManager::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client), - ) - .await - .unwrap(); - manager.metadata = Some(manager.resolve_metadata().await.unwrap().metadata); - - let request = AuthorizationRequest::new("http://localhost:8080/callback") - .with_preregistered_client("preregistered-client"); - let session = match AuthorizationSession::new(manager, request).await { - Ok(session) => session, - Err((_, error)) => panic!("authorization session creation failed: {error}"), - }; - - // Empty request scopes fall back to the discovered scopes_supported. - let query = auth_url_query(&session.auth_url); - assert_eq!(query.get("scope").unwrap(), "read write offline_access"); - } - - #[tokio::test] - async fn preregistered_client_uses_explicit_scopes_and_adds_offline_access() { - let client = RecordingOAuthHttpClient::with_responses(preregistered_discovery_responses()); - let mut state = super::OAuthState::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let request = AuthorizationRequest::new("http://localhost:8080/callback") - .with_preregistered_client("preregistered-client") - .with_scopes(["read"]); - state.start_authorization(request).await.unwrap(); - - // explicit scopes are preserved; offline_access is appended per SEP-2207 - let auth_url = state.get_authorization_url().await.unwrap(); - let query = auth_url_query(&auth_url); - assert_eq!(query.get("scope").unwrap(), "read offline_access"); - } - - #[tokio::test] - async fn preregistered_client_recovers_unauthorized_state_after_discovery_failure() { - // first discovery attempt fails: protected resource metadata reports a - // mismatched resource identifier, which discover_metadata rejects - let challenge = oauth2::http::Response::builder() - .status(401) - .header( - "www-authenticate", - r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, - ) - .body(Vec::new()) - .unwrap(); - let client = RecordingOAuthHttpClient::with_responses(vec![ - challenge, - http_response( - 200, - serde_json::json!({ - "resource": "https://other.example.com/mcp", - "authorization_servers": ["https://auth.example.com"] - }), - ), - ]); - let mut state = super::OAuthState::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let request = AuthorizationRequest::new("http://localhost:8080/callback") - .with_preregistered_client("preregistered-client") - .with_scopes(["read"]); - let err = state - .start_authorization(request.clone()) - .await - .unwrap_err(); - assert!(!matches!(err, AuthError::InternalError(_)), "{err:?}"); - assert!( - matches!(state, super::OAuthState::Unauthorized(_)), - "state should return to Unauthorized after a transient failure" - ); - - // retrying with the same state succeeds once the server responds - client - .responses - .lock() - .unwrap() - .extend(preregistered_discovery_responses()); - state.start_authorization(request).await.unwrap(); - assert!(matches!(state, super::OAuthState::Session(_))); - } - - fn cimd_as_metadata_response() -> HttpResponse { - http_response( - 200, - serde_json::json!({ - "issuer": "https://auth.example.com", - "authorization_endpoint": "https://auth.example.com/authorize", - "token_endpoint": "https://auth.example.com/token", - "registration_endpoint": "https://auth.example.com/register", - "scopes_supported": ["read", "write", "offline_access"], - "client_id_metadata_document_supported": true - }), - ) - } - - /// discovery responses like [`preregistered_discovery_responses`] but the - /// authorization server advertises CIMD support. - fn cimd_discovery_responses() -> Vec { - let challenge = oauth2::http::Response::builder() - .status(401) - .header( - "www-authenticate", - r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, - ) - .body(Vec::new()) - .unwrap(); - vec![ - challenge, - http_response( - 200, - serde_json::json!({ - "resource": "https://mcp.example.com/mcp", - "authorization_servers": ["https://auth.example.com"] - }), - ), - cimd_as_metadata_response(), - ] - } - - #[tokio::test] - async fn preregistered_client_takes_priority_over_cimd() { - // server supports CIMD and the request carries both pre-registered - // credentials and a client metadata URL: pre-registration wins - let client = RecordingOAuthHttpClient::with_responses(cimd_discovery_responses()); - let mut state = super::OAuthState::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let request = AuthorizationRequest::new("http://localhost:8080/callback") - .with_preregistered_client("preregistered-client") - .with_client_metadata_url("https://client.example.com/client-metadata.json") - .with_scopes(["read"]); - state.start_authorization(request).await.unwrap(); - - let requests = client.requests(); - assert!( - requests - .iter() - .all(|request| !request.uri.contains("/register")), - "registration endpoint should not be called: {requests:?}" - ); - let auth_url = state.get_authorization_url().await.unwrap(); - let query = auth_url_query(&auth_url); - assert_eq!(query.get("client_id").unwrap(), "preregistered-client"); - } - - #[tokio::test] - async fn cimd_used_when_server_advertises_support() { - let client = RecordingOAuthHttpClient::with_responses(cimd_discovery_responses()); - let mut state = super::OAuthState::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let request = AuthorizationRequest::new("http://localhost:8080/callback") - .with_client_metadata_url("https://client.example.com/client-metadata.json") - .with_scopes(["read"]); - state.start_authorization(request).await.unwrap(); - - // CIMD takes priority over the advertised registration endpoint - let requests = client.requests(); - assert!( - requests - .iter() - .all(|request| !request.uri.contains("/register")), - "registration endpoint should not be called: {requests:?}" - ); - let auth_url = state.get_authorization_url().await.unwrap(); - let query = auth_url_query(&auth_url); - assert_eq!( - query.get("client_id").unwrap(), - "https://client.example.com/client-metadata.json" - ); - } - - #[tokio::test] - async fn cimd_falls_back_to_dcr_when_server_lacks_support() { - // server does not advertise client_id_metadata_document_supported, so - // the client metadata URL is ignored and DCR is used instead - let mut responses = preregistered_discovery_responses(); - responses.push(http_response( - 201, - serde_json::json!({ - "client_id": "dcr-client", - "redirect_uris": ["http://localhost:8080/callback"] - }), - )); - let client = RecordingOAuthHttpClient::with_responses(responses); - let mut state = super::OAuthState::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let request = AuthorizationRequest::new("http://localhost:8080/callback") - .with_client_metadata_url("https://client.example.com/client-metadata.json") - .with_scopes(["read"]); - state.start_authorization(request).await.unwrap(); - - let requests = client.requests(); - assert!( - requests - .iter() - .any(|request| request.uri.contains("/register")), - "registration endpoint should be called: {requests:?}" - ); - let auth_url = state.get_authorization_url().await.unwrap(); - let query = auth_url_query(&auth_url); - assert_eq!(query.get("client_id").unwrap(), "dcr-client"); - } - - #[tokio::test] - async fn dcr_used_when_no_identity_material_is_provided() { - let mut responses = preregistered_discovery_responses(); - responses.push(http_response( - 201, - serde_json::json!({ - "client_id": "dcr-client", - "redirect_uris": ["http://localhost:8080/callback"] - }), - )); - let client = RecordingOAuthHttpClient::with_responses(responses); - let mut state = super::OAuthState::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let request = AuthorizationRequest::new("http://localhost:8080/callback") - .with_client_name("test-client") - .with_scopes(["read"]); - state.start_authorization(request).await.unwrap(); - - let auth_url = state.get_authorization_url().await.unwrap(); - let query = auth_url_query(&auth_url); - assert_eq!(query.get("client_id").unwrap(), "dcr-client"); - assert!(matches!(state, super::OAuthState::Session(_))); - } - - #[tokio::test] - async fn dcr_registration_uses_requested_application_type() { - let mut responses = preregistered_discovery_responses(); - responses.push(http_response( - 201, - serde_json::json!({ - "client_id": "dcr-client", - "redirect_uris": ["http://localhost:8080/callback"] - }), - )); - let client = RecordingOAuthHttpClient::with_responses(responses); - let mut state = super::OAuthState::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let request = AuthorizationRequest::new("http://localhost:8080/callback") - .with_client_name("test-client") - .with_application_type("web") - .with_scopes(["read"]); - state.start_authorization(request).await.unwrap(); - - // SEP-837: the requested application_type must be sent in the DCR request - let requests = client.requests(); - let registration = requests - .iter() - .find(|request| request.uri.contains("/register")) - .expect("registration endpoint should be called"); - let body: serde_json::Value = serde_json::from_slice(®istration.body).unwrap(); - assert_eq!(body.get("application_type").unwrap(), "web"); - } - - #[tokio::test] - async fn cimd_rejects_non_https_client_metadata_url_and_recovers_state() { - let client = RecordingOAuthHttpClient::with_responses(cimd_discovery_responses()); - let mut state = super::OAuthState::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let request = AuthorizationRequest::new("http://localhost:8080/callback") - .with_client_metadata_url("http://client.example.com/client-metadata.json") - .with_scopes(["read"]); - let err = state.start_authorization(request).await.unwrap_err(); - assert!(matches!(err, AuthError::RegistrationFailed(_)), "{err:?}"); - assert!( - matches!(state, super::OAuthState::Unauthorized(_)), - "state should return to Unauthorized after a registration failure" - ); - } - - #[tokio::test] - async fn dcr_recovers_unauthorized_state_after_registration_failure() { - // discovery succeeds, but the registration endpoint rejects the request - let mut responses = preregistered_discovery_responses(); - responses.push(http_response( - 400, - serde_json::json!({"error": "invalid_client_metadata"}), - )); - let client = RecordingOAuthHttpClient::with_responses(responses); - let mut state = super::OAuthState::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let request = AuthorizationRequest::new("http://localhost:8080/callback") - .with_client_name("test-client") - .with_scopes(["read"]); - let err = state - .start_authorization(request.clone()) - .await - .unwrap_err(); - assert!(matches!(err, AuthError::RegistrationFailed(_)), "{err:?}"); - assert!( - matches!(state, super::OAuthState::Unauthorized(_)), - "state should return to Unauthorized after a registration failure" - ); - - // retrying with the same state succeeds once the server accepts - // registration (discovery runs again on retry) - { - let mut responses = client.responses.lock().unwrap(); - responses.extend(preregistered_discovery_responses()); - responses.push_back(http_response( - 201, - serde_json::json!({ - "client_id": "dcr-client", - "redirect_uris": ["http://localhost:8080/callback"] - }), - )); - } - state.start_authorization(request).await.unwrap(); - assert!(matches!(state, super::OAuthState::Session(_))); - } - - #[tokio::test] - async fn discovery_get_follows_same_origin_redirects() { - let client = RecordingOAuthHttpClient::with_responses(vec![ - redirect_response("/redirected"), - http_response(200, serde_json::json!({})), - ]); - let manager = AuthorizationManager::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let response = manager - .discovery_get(&Url::parse("https://mcp.example.com/start").unwrap()) - .await - .unwrap(); - let requests = client.requests(); - - assert_eq!( - ( - response.status(), - requests - .iter() - .map(|request| request.uri.as_str()) - .collect::>() - ), - ( - oauth2::http::StatusCode::OK, - vec![ - "https://mcp.example.com/start", - "https://mcp.example.com/redirected" - ] - ) - ); - } - - #[tokio::test] - async fn discovery_get_rejects_cross_origin_redirects() { - let client = RecordingOAuthHttpClient::with_responses(vec![redirect_response( - "http://169.254.169.254/", - )]); - let manager = AuthorizationManager::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let err = manager - .discovery_get(&Url::parse("https://mcp.example.com/start").unwrap()) - .await - .unwrap_err(); - - assert_eq!( - ( - err.to_string().contains("non-same-origin"), - client.requests().len() - ), - (true, 1) - ); - } - - #[tokio::test] - async fn protected_resource_metadata_rejects_private_authorization_server_urls() { - let challenge = oauth2::http::Response::builder() - .status(401) - .header( - "www-authenticate", - r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, - ) - .body(Vec::new()) - .unwrap(); - let client = RecordingOAuthHttpClient::with_responses(vec![ - challenge, - http_response( - 200, - serde_json::json!({ - "resource": "https://mcp.example.com/mcp", - "authorization_servers": [ - "http://169.254.169.254/latest/meta-data/", - "http://127.0.0.1:8080/tenant1", - "https://auth.example.com" - ] - }), - ), - http_response( - 200, - serde_json::json!({ - "issuer": "https://auth.example.com", - "authorization_endpoint": "https://auth.example.com/authorize", - "token_endpoint": "https://auth.example.com/token" - }), - ), - ]); - let manager = AuthorizationManager::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let metadata = manager.resolve_metadata().await.unwrap().metadata; - let requests = client.requests(); - - assert_eq!( - ( - metadata.token_endpoint.as_str(), - requests - .iter() - .map(|request| request.uri.as_str()) - .collect::>() - ), - ( - "https://auth.example.com/token", - vec![ - "https://mcp.example.com/mcp", - "https://mcp.example.com/.well-known/oauth-protected-resource", - "https://auth.example.com/.well-known/oauth-authorization-server" - ] - ) - ); - } - - #[tokio::test] - async fn allows_loopback_authorization_server_when_resource_is_loopback() { - let challenge = oauth2::http::Response::builder() - .status(401) - .header( - "www-authenticate", - r#"Bearer resource_metadata="http://localhost/custom-metadata.json""#, - ) - .body(Vec::new()) - .unwrap(); - let client = RecordingOAuthHttpClient::with_responses(vec![ - challenge, - http_response( - 200, - serde_json::json!({ - "resource": "http://localhost/mcp", - "authorization_servers": ["http://127.0.0.1:8080/tenant1"] - }), - ), - http_response( - 200, - serde_json::json!({ - "issuer": "http://127.0.0.1:8080/tenant1", - "authorization_endpoint": "http://127.0.0.1:8080/tenant1/authorize", - "token_endpoint": "http://127.0.0.1:8080/tenant1/token" - }), - ), - ]); - let manager = AuthorizationManager::new_with_oauth_http_client( - "http://localhost/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let metadata = manager.resolve_metadata().await.unwrap().metadata; - - assert_eq!( - ( - metadata.issuer.as_deref(), - client - .requests() - .iter() - .map(|request| request.uri.as_str()) - .collect::>(), - ), - ( - Some("http://127.0.0.1:8080/tenant1"), - vec![ - "http://localhost/mcp", - "http://localhost/custom-metadata.json", - "http://127.0.0.1:8080/.well-known/oauth-authorization-server/tenant1", - ], - ) - ); - } - - #[tokio::test] - async fn protected_resource_discovery_rejects_mismatched_resource() { - let challenge = oauth2::http::Response::builder() - .status(401) - .header( - "www-authenticate", - r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, - ) - .body(Vec::new()) - .unwrap(); - let client = RecordingOAuthHttpClient::with_responses(vec![ - challenge, - http_response( - 200, - serde_json::json!({ - "resource": "https://real.example.com/mcp", - "authorization_servers": ["https://auth.example.com"] - }), - ), - ]); - let manager = AuthorizationManager::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let error = manager.resolve_metadata().await.unwrap_err(); - - assert!( - matches!(error, AuthError::MetadataError(ref message) if message.contains("resource mismatch")), - "expected resource mismatch metadata error, got: {error:?}" - ); - assert_eq!(client.requests().len(), 2); - } - - #[tokio::test] - async fn protected_resource_discovery_rejects_missing_resource() { - let challenge = oauth2::http::Response::builder() - .status(401) - .header( - "www-authenticate", - r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, - ) - .body(Vec::new()) - .unwrap(); - let client = RecordingOAuthHttpClient::with_responses(vec![ - challenge, - http_response( - 200, - serde_json::json!({ - "authorization_servers": ["https://auth.example.com"] - }), - ), - ]); - let manager = AuthorizationManager::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - - let error = manager.resolve_metadata().await.unwrap_err(); - - assert!( - matches!(error, AuthError::MetadataError(ref message) if message.contains("missing required resource")), - "expected missing resource metadata error, got: {error:?}" - ); - assert_eq!(client.requests().len(), 2); - } - - #[test] - fn resource_identifier_matching_allows_matching_host_or_parent_path() { - assert!(AuthorizationManager::is_resource_identifier_valid( - &Url::parse("https://mcp.example.com/").unwrap(), - &Url::parse("https://mcp.example.com").unwrap() - )); - assert!(AuthorizationManager::is_resource_identifier_valid( - &Url::parse("https://mcp.example.com").unwrap(), - &Url::parse("https://mcp.example.com/").unwrap() - )); - assert!(AuthorizationManager::is_resource_identifier_valid( - &Url::parse("https://mcp.example.com/mcp").unwrap(), - &Url::parse("https://mcp.example.com").unwrap() - )); - assert!(AuthorizationManager::is_resource_identifier_valid( - &Url::parse("https://mcp.example.com/mcp/tools").unwrap(), - &Url::parse("https://mcp.example.com/mcp").unwrap() - )); - assert!(AuthorizationManager::is_resource_identifier_valid( - &Url::parse("https://mcp.example.com/mcp?query=param").unwrap(), - &Url::parse("https://mcp.example.com/mcp?query=param").unwrap() - )); - - assert!(!AuthorizationManager::is_resource_identifier_valid( - &Url::parse("https://mcp.example.com/mcp").unwrap(), - &Url::parse("https://mcp.example.com/mcp/").unwrap() - )); - assert!(!AuthorizationManager::is_resource_identifier_valid( - &Url::parse("https://mcp.example.com/mcp-tools").unwrap(), - &Url::parse("https://mcp.example.com/mcp").unwrap() - )); - assert!(!AuthorizationManager::is_resource_identifier_valid( - &Url::parse("https://mcp.example.com/mcp").unwrap(), - &Url::parse("https://mcp.example.com/mcp-tools").unwrap() - )); - assert!(!AuthorizationManager::is_resource_identifier_valid( - &Url::parse("https://mcp.example.com/mcp").unwrap(), - &Url::parse("https://mcp.example.com/mcp/tools").unwrap() - )); - assert!(!AuthorizationManager::is_resource_identifier_valid( - &Url::parse("https://mcp.example.com/mcp").unwrap(), - &Url::parse("https://real.example.com/mcp").unwrap() - )); - assert!(!AuthorizationManager::is_resource_identifier_valid( - &Url::parse("https://mcp.example.com/mcp").unwrap(), - &Url::parse("https://mcp.example.com/mcp?query=value1").unwrap() - )); - assert!(!AuthorizationManager::is_resource_identifier_valid( - &Url::parse("https://mcp.example.com/mcp?query=value1").unwrap(), - &Url::parse("https://mcp.example.com/mcp?query=value2").unwrap() - )); - } - - #[tokio::test] - async fn custom_http_client_handles_registration_exchange_and_refresh() { - let client = RecordingOAuthHttpClient::with_responses(vec![ - http_response( - 201, - serde_json::json!({ - "client_id": "test-client", - "redirect_uris": ["http://localhost/callback"] - }), - ), - http_response( - 200, - serde_json::json!({ - "access_token": "access-1", - "token_type": "bearer", - "refresh_token": "refresh-1", - "expires_in": 3600 - }), - ), - http_response( - 200, - serde_json::json!({ - "access_token": "access-2", - "token_type": "bearer", - "refresh_token": "refresh-2", - "expires_in": 3600 - }), - ), - ]); - let mut manager = AuthorizationManager::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(client.clone()), - ) - .await - .unwrap(); - manager.set_metadata(AuthorizationMetadata { - authorization_endpoint: "https://auth.example.com/authorize".to_string(), - token_endpoint: "https://auth.example.com/token".to_string(), - registration_endpoint: Some("https://auth.example.com/register".to_string()), - response_types_supported: Some(vec!["code".to_string()]), - ..Default::default() - }); - manager - .register_client( - "Codex", - "http://localhost/callback", - &["profile", "offline_access"], - ) - .await - .unwrap(); - let authorization_url = manager - .get_authorization_url(&["profile", "offline_access"]) - .await - .unwrap(); - let state = Url::parse(&authorization_url) - .unwrap() - .query_pairs() - .find(|(name, _)| name == "state") - .unwrap() - .1 - .into_owned(); - - manager - .exchange_code_for_token("authorization-code", &state) - .await - .unwrap(); - manager.refresh_token().await.unwrap(); - - let requests = client.requests(); - let registration: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(registration["scope"], "profile offline_access"); - assert_eq!( - requests - .iter() - .map(|request| request.uri.as_str()) - .collect::>(), - vec![ - "https://auth.example.com/register", - "https://auth.example.com/token", - "https://auth.example.com/token", - ] - ); - assert_eq!( - requests - .iter() - .map(|request| request.redirect_policy) - .collect::>(), - vec![ - OAuthHttpRedirectPolicy::Follow, - OAuthHttpRedirectPolicy::Stop, - OAuthHttpRedirectPolicy::Stop, - ] - ); - } - - // -- url helpers -- - - #[test] - fn test_is_https_url_scenarios() { - assert!(is_https_url("https://example.com/client-metadata.json")); - assert!(is_https_url("https://example.com/metadata?version=1")); - assert!(!is_https_url("https://example.com")); - assert!(!is_https_url("https://example.com/")); - assert!(!is_https_url("https://")); - assert!(!is_https_url("http://example.com/metadata")); - assert!(!is_https_url("not a url")); - assert!(!is_https_url("")); - assert!(!is_https_url("javascript:alert(1)")); - assert!(!is_https_url("data:text/html,")); - } - - // -- well-known path generation -- - - #[test] - fn well_known_paths_root() { - let paths = AuthorizationManager::well_known_paths("/", "oauth-authorization-server"); - assert_eq!( - paths, - vec!["/.well-known/oauth-authorization-server".to_string()] - ); - } - - #[test] - fn well_known_paths_with_suffix() { - let paths = AuthorizationManager::well_known_paths("/mcp", "oauth-authorization-server"); - assert_eq!( - paths, - vec![ - "/.well-known/oauth-authorization-server/mcp".to_string(), - "/mcp/.well-known/oauth-authorization-server".to_string(), - "/.well-known/oauth-authorization-server".to_string(), - ] - ); - } - - #[test] - fn well_known_paths_trailing_slash() { - let paths = - AuthorizationManager::well_known_paths("/v1/mcp/", "oauth-authorization-server"); - assert_eq!( - paths, - vec![ - "/.well-known/oauth-authorization-server/v1/mcp".to_string(), - "/v1/mcp/.well-known/oauth-authorization-server".to_string(), - "/.well-known/oauth-authorization-server".to_string(), - ] - ); - } - - #[test] - fn test_protected_resource_metadata_paths() { - let paths = - AuthorizationManager::well_known_paths("/mcp/example", "oauth-protected-resource"); - assert!(paths.contains(&"/.well-known/oauth-protected-resource/mcp/example".to_string())); - assert!(paths.contains(&"/.well-known/oauth-protected-resource".to_string())); - } - - // -- discovery url generation -- - - #[test] - fn generate_discovery_urls() { - let base_url = Url::parse("https://auth.example.com").unwrap(); - let urls = AuthorizationManager::generate_discovery_urls(&base_url); - assert_eq!(urls.len(), 2); - assert_eq!( - urls[0].as_str(), - "https://auth.example.com/.well-known/oauth-authorization-server" - ); - assert_eq!( - urls[1].as_str(), - "https://auth.example.com/.well-known/openid-configuration" - ); - - let base_url = Url::parse("https://auth.example.com/tenant1").unwrap(); - let urls = AuthorizationManager::generate_discovery_urls(&base_url); - assert_eq!(urls.len(), 4); - assert_eq!( - urls[0].as_str(), - "https://auth.example.com/.well-known/oauth-authorization-server/tenant1" - ); - assert_eq!( - urls[1].as_str(), - "https://auth.example.com/.well-known/openid-configuration/tenant1" - ); - assert_eq!( - urls[2].as_str(), - "https://auth.example.com/tenant1/.well-known/openid-configuration" - ); - assert_eq!( - urls[3].as_str(), - "https://auth.example.com/.well-known/oauth-authorization-server" - ); - - let base_url = Url::parse("https://auth.example.com/v1/mcp/").unwrap(); - let urls = AuthorizationManager::generate_discovery_urls(&base_url); - assert_eq!(urls.len(), 4); - assert_eq!( - urls[0].as_str(), - "https://auth.example.com/.well-known/oauth-authorization-server/v1/mcp" - ); - assert_eq!( - urls[1].as_str(), - "https://auth.example.com/.well-known/openid-configuration/v1/mcp" - ); - assert_eq!( - urls[2].as_str(), - "https://auth.example.com/v1/mcp/.well-known/openid-configuration" - ); - assert_eq!( - urls[3].as_str(), - "https://auth.example.com/.well-known/oauth-authorization-server" - ); - - let base_url = Url::parse("https://auth.example.com/tenant1/subtenant").unwrap(); - let urls = AuthorizationManager::generate_discovery_urls(&base_url); - assert_eq!(urls.len(), 4); - assert_eq!( - urls[0].as_str(), - "https://auth.example.com/.well-known/oauth-authorization-server/tenant1/subtenant" - ); - assert_eq!( - urls[1].as_str(), - "https://auth.example.com/.well-known/openid-configuration/tenant1/subtenant" - ); - assert_eq!( - urls[2].as_str(), - "https://auth.example.com/tenant1/subtenant/.well-known/openid-configuration" - ); - assert_eq!( - urls[3].as_str(), - "https://auth.example.com/.well-known/oauth-authorization-server" - ); - } - - #[test] - fn test_discovery_urls_with_path_suffix() { - let base_url = Url::parse("https://mcp.example.com/mcp").unwrap(); - let urls = AuthorizationManager::generate_discovery_urls(&base_url); - - let canonical_oauth_fallback = - "https://mcp.example.com/.well-known/oauth-authorization-server"; - - assert!( - urls.iter().any(|u| u.as_str() == canonical_oauth_fallback), - "Expected discovery URLs to include canonical OAuth fallback '{}', but got: {:?}", - canonical_oauth_fallback, - urls.iter().map(|u| u.as_str()).collect::>() - ); - } - - // -- header value parsing -- - - #[rstest] - #[case::quoted_string(r#""example", realm="foo""#, "example", r#""example""#)] - #[case::escaped_quotes_and_whitespace( - r#" "a\"b\\c" ,next=value"#, - r#"a"b\c"#, - r#" "a\"b\\c""# - )] - #[case::token_values(" token,next", "token", " token")] - #[case::semicolon_separated_tokens( - r#" https://example.com/meta; error="invalid_token""#, - "https://example.com/meta", - " https://example.com/meta" - )] - #[case::semicolon_after_quoted_value( - r#" "https://example.com/meta"; error="invalid_token""#, - "https://example.com/meta", - r#" "https://example.com/meta""# - )] - fn parse_auth_param_value_handles_supported_values( - #[case] fragment: &str, - #[case] expected_value: &str, - #[case] expected_consumed_prefix: &str, - ) { - let (value, consumed) = AuthorizationManager::parse_next_header_value(fragment).unwrap(); - - assert_eq!( - (value.as_str(), &fragment[..consumed]), - (expected_value, expected_consumed_prefix) - ); - } - - #[test] - fn parse_auth_param_value_returns_none_for_unterminated_quotes() { - let fragment = r#""unterminated,value"#; - assert!(AuthorizationManager::parse_next_header_value(fragment).is_none()); - } - - // -- www-authenticate param extraction -- - - #[test] - fn parses_resource_metadata_parameter() { - let header = r#"Bearer error="invalid_request", error_description="missing token", resource_metadata="https://example.com/.well-known/oauth-protected-resource/api""#; - let base = Url::parse("https://example.com/api").unwrap(); - let params = AuthorizationManager::extract_www_authenticate_params(header, &base); - assert_eq!( - params.resource_metadata_url.unwrap().as_str(), - "https://example.com/.well-known/oauth-protected-resource/api" - ); - } - - #[test] - fn parses_relative_resource_metadata_parameter() { - let header = r#"Bearer error="invalid_request", resource_metadata="/.well-known/oauth-protected-resource/api""#; - let base = Url::parse("https://example.com/api").unwrap(); - let params = AuthorizationManager::extract_www_authenticate_params(header, &base); - assert_eq!( - params.resource_metadata_url.unwrap().as_str(), - "https://example.com/.well-known/oauth-protected-resource/api" - ); - } - - #[test] - fn rejects_cross_origin_resource_metadata_parameter() { - let header = r#"Bearer error="invalid_request", resource_metadata="http://169.254.169.254/latest/meta-data/", scope="read""#; - let base = Url::parse("https://example.com/api").unwrap(); - let params = AuthorizationManager::extract_www_authenticate_params(header, &base); - - assert!(params.resource_metadata_url.is_none()); - assert_eq!(params.scope.unwrap(), "read"); - } - - #[test] - fn rejects_non_http_resource_metadata_parameter() { - let header = r#"Bearer resource_metadata="file:///etc/passwd""#; - let base = Url::parse("https://example.com/api").unwrap(); - let params = AuthorizationManager::extract_www_authenticate_params(header, &base); - - assert!(params.resource_metadata_url.is_none()); - } - - #[test] - fn extract_www_authenticate_params_with_all_fields() { - let header = r#"Bearer error="invalid_token", resource_metadata="https://example.com/.well-known/oauth-protected-resource", scope="read:data write:data", error_description="token expired""#; - let base = Url::parse("https://example.com/api").unwrap(); - let params = AuthorizationManager::extract_www_authenticate_params(header, &base); - - assert_eq!( - params.resource_metadata_url.unwrap().as_str(), - "https://example.com/.well-known/oauth-protected-resource" - ); - assert_eq!(params.scope.unwrap(), "read:data write:data"); - assert_eq!(params.error.unwrap(), "invalid_token"); - assert_eq!(params.error_description.unwrap(), "token expired"); - } - - #[test] - fn extract_www_authenticate_params_insufficient_scope() { - let header = r#"Bearer error="insufficient_scope", scope="admin:write", error_description="Additional file write permission required""#; - let base = Url::parse("https://example.com/api").unwrap(); - let params = AuthorizationManager::extract_www_authenticate_params(header, &base); - - assert!(params.resource_metadata_url.is_none()); - assert!(params.is_insufficient_scope()); - assert!(!params.is_invalid_token()); - assert_eq!(params.scope.unwrap(), "admin:write"); - assert_eq!( - params.error_description.unwrap(), - "Additional file write permission required" - ); - } - - #[test] - fn extract_www_authenticate_params_with_only_resource_metadata() { - let header = r#"Bearer resource_metadata="/.well-known/oauth-protected-resource""#; - let base = Url::parse("https://example.com/api").unwrap(); - let params = AuthorizationManager::extract_www_authenticate_params(header, &base); - - assert_eq!( - params.resource_metadata_url.unwrap().as_str(), - "https://example.com/.well-known/oauth-protected-resource" - ); - assert!(params.scope.is_none()); - } - - #[test] - fn extract_www_authenticate_params_bare_bearer() { - let header = "Bearer"; - let base = Url::parse("https://example.com/api").unwrap(); - let params = AuthorizationManager::extract_www_authenticate_params(header, &base); - - assert!(params.resource_metadata_url.is_none()); - assert!(params.scope.is_none()); - assert!(params.error.is_none()); - assert!(params.error_description.is_none()); - } - - #[test] - fn extract_www_authenticate_params_error_only() { - let header = r#"Bearer error="invalid_token""#; - let base = Url::parse("https://example.com/api").unwrap(); - let params = AuthorizationManager::extract_www_authenticate_params(header, &base); - - assert!(params.resource_metadata_url.is_none()); - assert!(params.scope.is_none()); - assert!(params.is_invalid_token()); - assert!(!params.is_insufficient_scope()); - assert!(params.error_description.is_none()); - } - - #[test] - fn extract_www_authenticate_params_with_unquoted_scope() { - let header = r#"Bearer scope=read:data, error="insufficient_scope""#; - let base = Url::parse("https://example.com/api").unwrap(); - let params = AuthorizationManager::extract_www_authenticate_params(header, &base); - - assert_eq!(params.scope.unwrap(), "read:data"); - } - - // -- stored authorization state -- - - #[test] - fn test_stored_authorization_state_serialization() { - let pkce = PkceCodeVerifier::new("my-verifier".to_string()); - let csrf = CsrfToken::new("my-csrf".to_string()); - let state = StoredAuthorizationState::new(&pkce, &csrf) - .with_requested_scopes(vec!["read".to_string(), "write".to_string()]); - - let json = serde_json::to_string(&state).unwrap(); - let deserialized: StoredAuthorizationState = serde_json::from_str(&json).unwrap(); - - assert_eq!(deserialized.pkce_verifier, "my-verifier"); - assert_eq!(deserialized.csrf_token, "my-csrf"); - assert_eq!(deserialized.requested_scopes, vec!["read", "write"]); - assert_eq!(deserialized.expected_issuer, None); - assert!(!deserialized.require_issuer); - } - - #[test] - fn stored_authorization_state_defaults_requested_scopes_when_absent() { - let json = r#"{"pkce_verifier":"v","csrf_token":"c","created_at":1}"#; - let state: StoredAuthorizationState = serde_json::from_str(json).unwrap(); - - assert!(state.requested_scopes.is_empty()); - } - - #[test] - fn test_stored_authorization_state_records_expected_issuer() { - let pkce = PkceCodeVerifier::new("my-verifier".to_string()); - let csrf = CsrfToken::new("my-csrf".to_string()); - let state = StoredAuthorizationState::new_with_expected_issuer( - &pkce, - &csrf, - Some("https://auth.example.com".to_string()), - false, - ); - - let json = serde_json::to_string(&state).unwrap(); - let deserialized: StoredAuthorizationState = serde_json::from_str(&json).unwrap(); - - assert_eq!( - deserialized.expected_issuer.as_deref(), - Some("https://auth.example.com") - ); - assert!(!deserialized.require_issuer); - } - - #[test] - fn test_stored_authorization_state_debug_redacts_secrets() { - let pkce = PkceCodeVerifier::new("super-secret-verifier".to_string()); - let csrf = CsrfToken::new("super-secret-csrf".to_string()); - let state = StoredAuthorizationState::new(&pkce, &csrf); - let debug_output = format!("{:?}", state); - - assert!(!debug_output.contains("super-secret-verifier")); - assert!(!debug_output.contains("super-secret-csrf")); - assert!(debug_output.contains("[REDACTED]")); - assert!(debug_output.contains("expected_issuer")); - assert!(debug_output.contains("created_at")); - } - - #[test] - fn test_stored_credentials_debug_redacts_token_response() { - use oauth2::{AccessToken, basic::BasicTokenType}; - - use super::{OAuthTokenResponse, StoredCredentials}; - - let token_response = OAuthTokenResponse::new( - AccessToken::new("super-secret-access-token".to_string()), - BasicTokenType::Bearer, - VendorExtraTokenFields::default(), - ); - let creds = StoredCredentials { - client_id: "my-client".to_string(), - token_response: Some(token_response), - granted_scopes: vec![], - token_received_at: None, - issuer: None, - }; - let debug_output = format!("{:?}", creds); - - assert!(!debug_output.contains("super-secret-access-token")); - assert!(debug_output.contains("[REDACTED]")); - assert!(debug_output.contains("my-client")); - } - - #[test] - fn test_stored_authorization_state_into_pkce_verifier() { - let pkce = PkceCodeVerifier::new("original-verifier".to_string()); - let csrf = CsrfToken::new("csrf-token".to_string()); - let state = StoredAuthorizationState::new(&pkce, &csrf); - - let recovered = state.into_pkce_verifier(); - assert_eq!(recovered.secret(), "original-verifier"); - } - - #[test] - fn test_stored_authorization_state_created_at() { - let pkce = PkceCodeVerifier::new("verifier".to_string()); - let csrf = CsrfToken::new("csrf".to_string()); - let state = StoredAuthorizationState::new(&pkce, &csrf); - - assert!(state.created_at > 1577836800); // Jan 1, 2020 - } - - // -- state store -- - - #[tokio::test] - async fn test_in_memory_state_store_save_and_load() { - let store = InMemoryStateStore::new(); - let pkce = PkceCodeVerifier::new("test-verifier".to_string()); - let csrf = CsrfToken::new("test-csrf".to_string()); - let state = StoredAuthorizationState::new(&pkce, &csrf); - - store.save("test-csrf", state).await.unwrap(); - - let loaded = store.load("test-csrf").await.unwrap(); - assert!(loaded.is_some()); - let loaded = loaded.unwrap(); - assert_eq!(loaded.csrf_token, "test-csrf"); - assert_eq!(loaded.pkce_verifier, "test-verifier"); - } - - #[tokio::test] - async fn test_in_memory_state_store_load_nonexistent() { - let store = InMemoryStateStore::new(); - let result = store.load("nonexistent").await.unwrap(); - assert!(result.is_none()); - } - - #[tokio::test] - async fn test_in_memory_state_store_delete() { - let store = InMemoryStateStore::new(); - let pkce = PkceCodeVerifier::new("verifier".to_string()); - let csrf = CsrfToken::new("csrf".to_string()); - let state = StoredAuthorizationState::new(&pkce, &csrf); - - store.save("csrf", state).await.unwrap(); - store.delete("csrf").await.unwrap(); - - let result = store.load("csrf").await.unwrap(); - assert!(result.is_none()); - } - - #[tokio::test] - async fn test_in_memory_state_store_overwrite() { - let store = InMemoryStateStore::new(); - let csrf_key = "same-csrf"; - - let pkce1 = PkceCodeVerifier::new("verifier-1".to_string()); - let csrf1 = CsrfToken::new(csrf_key.to_string()); - let state1 = StoredAuthorizationState::new(&pkce1, &csrf1); - store.save(csrf_key, state1).await.unwrap(); - - let pkce2 = PkceCodeVerifier::new("verifier-2".to_string()); - let csrf2 = CsrfToken::new(csrf_key.to_string()); - let state2 = StoredAuthorizationState::new(&pkce2, &csrf2); - store.save(csrf_key, state2).await.unwrap(); - - let loaded = store.load(csrf_key).await.unwrap().unwrap(); - assert_eq!(loaded.pkce_verifier, "verifier-2"); - } - - #[tokio::test] - async fn test_in_memory_state_store_concurrent_access() { - let store = Arc::new(InMemoryStateStore::new()); - let mut handles = vec![]; - - for i in 0..10 { - let store = Arc::clone(&store); - let handle = tokio::spawn(async move { - let csrf_key = format!("csrf-{}", i); - let verifier = format!("verifier-{}", i); - - let pkce = PkceCodeVerifier::new(verifier.clone()); - let csrf = CsrfToken::new(csrf_key.clone()); - let state = StoredAuthorizationState::new(&pkce, &csrf); - - store.save(&csrf_key, state).await.unwrap(); - let loaded = store.load(&csrf_key).await.unwrap().unwrap(); - assert_eq!(loaded.pkce_verifier, verifier); - - store.delete(&csrf_key).await.unwrap(); - let deleted = store.load(&csrf_key).await.unwrap(); - assert!(deleted.is_none()); - }); - handles.push(handle); - } - - for handle in handles { - handle.await.unwrap(); - } - } - - #[tokio::test] - async fn test_custom_state_store_with_authorization_manager() { - use std::sync::atomic::{AtomicUsize, Ordering}; - - #[derive(Debug, Default)] - struct TrackingStateStore { - inner: InMemoryStateStore, - save_count: AtomicUsize, - load_count: AtomicUsize, - delete_count: AtomicUsize, - } - - #[async_trait::async_trait] - impl StateStore for TrackingStateStore { - async fn save( - &self, - csrf_token: &str, - state: StoredAuthorizationState, - ) -> Result<(), AuthError> { - self.save_count.fetch_add(1, Ordering::SeqCst); - self.inner.save(csrf_token, state).await - } - - async fn load( - &self, - csrf_token: &str, - ) -> Result, AuthError> { - self.load_count.fetch_add(1, Ordering::SeqCst); - self.inner.load(csrf_token).await - } - - async fn delete(&self, csrf_token: &str) -> Result<(), AuthError> { - self.delete_count.fetch_add(1, Ordering::SeqCst); - self.inner.delete(csrf_token).await - } - } - - let store = TrackingStateStore::default(); - let pkce = PkceCodeVerifier::new("test-verifier".to_string()); - let csrf = CsrfToken::new("test-csrf".to_string()); - let state = StoredAuthorizationState::new(&pkce, &csrf); - - store.save("test-csrf", state).await.unwrap(); - assert_eq!(store.save_count.load(Ordering::SeqCst), 1); - - let _ = store.load("test-csrf").await.unwrap(); - assert_eq!(store.load_count.load(Ordering::SeqCst), 1); - - store.delete("test-csrf").await.unwrap(); - assert_eq!(store.delete_count.load(Ordering::SeqCst), 1); - - let mut manager = AuthorizationManager::new("http://localhost").await.unwrap(); - manager.set_state_store(TrackingStateStore::default()); - } - - /// Helper: create an AuthorizationManager with minimal metadata so - /// `configure_client` can be exercised without a live server. - async fn manager_with_metadata( - metadata_override: Option, - ) -> AuthorizationManager { - let mut mgr = AuthorizationManager::new("http://localhost").await.unwrap(); - mgr.set_metadata(metadata_override.unwrap_or(AuthorizationMetadata { - authorization_endpoint: "http://localhost/authorize".to_string(), - token_endpoint: "http://localhost/token".to_string(), - ..Default::default() - })); - mgr - } - - #[tokio::test] - async fn initialize_from_store_clears_dcr_credentials_when_issuer_changes() { - let store = InMemoryCredentialStore::new(); - store - .save(StoredCredentials { - client_id: "dcr-client".to_string(), - token_response: Some(make_token_response("old-token", Some(3600))), - granted_scopes: vec![], - token_received_at: Some(AuthorizationManager::now_epoch_secs()), - issuer: Some("https://old.example.com".to_string()), - }) - .await - .unwrap(); - let mut manager = manager_with_metadata(Some(AuthorizationMetadata { - authorization_endpoint: "https://new.example.com/authorize".to_string(), - token_endpoint: "https://new.example.com/token".to_string(), - issuer: Some("https://new.example.com".to_string()), - ..Default::default() - })) - .await; - manager.set_credential_store(store.clone()); - - let initialized = manager.initialize_from_store().await.unwrap(); - let credentials_cleared = store.load().await.unwrap().is_none(); - - assert_eq!((initialized, credentials_cleared), (false, true)); - } - - fn test_client_config() -> OAuthClientConfig { - OAuthClientConfig { - client_id: "my-client".to_string(), - client_secret: Some("my-secret".to_string()), - scopes: vec![], - redirect_uri: "http://localhost/callback".to_string(), - application_type: None, - } - } - - #[tokio::test] - async fn test_configure_client_uses_client_secret_post_from_metadata() { - let mut additional_fields = HashMap::new(); - additional_fields.insert( - "token_endpoint_auth_methods_supported".to_string(), - serde_json::json!(["client_secret_post"]), - ); - let meta = AuthorizationMetadata { - authorization_endpoint: "http://localhost/authorize".to_string(), - token_endpoint: "http://localhost/token".to_string(), - additional_fields, - ..Default::default() - }; - let mut mgr = manager_with_metadata(Some(meta)).await; - mgr.configure_client(test_client_config()).unwrap(); - assert!(matches!( - mgr.oauth_client.as_ref().unwrap().auth_type(), - AuthType::RequestBody - )); - } - - #[tokio::test] - async fn test_configure_client_defaults_to_basic_auth() { - let mut mgr = manager_with_metadata(None).await; - mgr.configure_client(test_client_config()).unwrap(); - assert!(matches!( - mgr.oauth_client.as_ref().unwrap().auth_type(), - AuthType::BasicAuth - )); - } - - #[tokio::test] - async fn test_configure_client_with_explicit_basic_in_metadata() { - let mut additional_fields = HashMap::new(); - additional_fields.insert( - "token_endpoint_auth_methods_supported".to_string(), - serde_json::json!(["client_secret_basic"]), - ); - let meta = AuthorizationMetadata { - authorization_endpoint: "http://localhost/authorize".to_string(), - token_endpoint: "http://localhost/token".to_string(), - additional_fields, - ..Default::default() - }; - let mut mgr = manager_with_metadata(Some(meta)).await; - mgr.configure_client(test_client_config()).unwrap(); - assert!(matches!( - mgr.oauth_client.as_ref().unwrap().auth_type(), - AuthType::BasicAuth - )); - } - - #[tokio::test] - async fn test_configure_client_ignores_unsupported_auth_methods_in_metadata() { - let mut additional_fields = HashMap::new(); - additional_fields.insert( - "token_endpoint_auth_methods_supported".to_string(), - serde_json::json!(["private_key_jwt"]), - ); - let meta = AuthorizationMetadata { - authorization_endpoint: "http://localhost/authorize".to_string(), - token_endpoint: "http://localhost/token".to_string(), - additional_fields, - ..Default::default() - }; - let mut mgr = manager_with_metadata(Some(meta)).await; - // Unsupported method should fall through to default (basic auth) - mgr.configure_client(test_client_config()).unwrap(); - assert!(matches!( - mgr.oauth_client.as_ref().unwrap().auth_type(), - AuthType::BasicAuth - )); - } - - #[tokio::test] - async fn test_configure_client_prefers_basic_when_both_methods_supported() { - let mut additional_fields = HashMap::new(); - additional_fields.insert( - "token_endpoint_auth_methods_supported".to_string(), - serde_json::json!(["client_secret_post", "client_secret_basic"]), - ); - let meta = AuthorizationMetadata { - authorization_endpoint: "http://localhost/authorize".to_string(), - token_endpoint: "http://localhost/token".to_string(), - additional_fields, - ..Default::default() - }; - let mut mgr = manager_with_metadata(Some(meta)).await; - mgr.configure_client(test_client_config()).unwrap(); - assert!(matches!( - mgr.oauth_client.as_ref().unwrap().auth_type(), - AuthType::BasicAuth - )); - } - // -- metadata deserialization -- - - #[test] - fn test_code_challenge_methods_supported_deserialization() { - let json = r#"{ - "authorization_endpoint": "https://auth.example.com/authorize", - "token_endpoint": "https://auth.example.com/token", - "code_challenge_methods_supported": ["S256", "plain"] - }"#; - let metadata: AuthorizationMetadata = serde_json::from_str(json).unwrap(); - let methods = metadata.code_challenge_methods_supported.unwrap(); - assert!(methods.contains(&"S256".to_string())); - assert!(methods.contains(&"plain".to_string())); - } - - #[test] - fn test_code_challenge_methods_supported_missing_from_json() { - let json = r#"{ - "authorization_endpoint": "https://auth.example.com/authorize", - "token_endpoint": "https://auth.example.com/token" - }"#; - let metadata: AuthorizationMetadata = serde_json::from_str(json).unwrap(); - assert!(metadata.code_challenge_methods_supported.is_none()); - } - - // -- server validation -- - - #[tokio::test] - async fn test_validate_as_metadata_rejects_unsupported_response_type() { - let mut manager = AuthorizationManager::new("https://example.com") - .await - .unwrap(); - let metadata = AuthorizationMetadata { - authorization_endpoint: "https://auth.example.com/authorize".to_string(), - token_endpoint: "https://auth.example.com/token".to_string(), - response_types_supported: Some(vec!["token".to_string()]), - ..Default::default() - }; - manager.set_metadata(metadata); - assert!(manager.validate_server_metadata("code").is_err()); - } - - fn as_metadata_with_pkce(methods: Option>) -> AuthorizationMetadata { - AuthorizationMetadata { - authorization_endpoint: "https://auth.example.com/authorize".to_string(), - token_endpoint: "https://auth.example.com/token".to_string(), - response_types_supported: Some(vec!["code".to_string()]), - code_challenge_methods_supported: methods, - ..Default::default() - } - } - - #[tokio::test] - async fn test_validate_as_metadata_rejects_without_pkce_s256() { - let mut manager = AuthorizationManager::new("https://example.com") - .await - .unwrap(); - manager.set_metadata(as_metadata_with_pkce(Some(vec!["plain".to_string()]))); - assert!(matches!( - manager.validate_server_metadata("code"), - Err(AuthError::PkceUnsupported) - )); - } - - #[tokio::test] - async fn test_validate_as_metadata_allows_absent_pkce_methods_by_default() { - let mut manager = AuthorizationManager::new("https://example.com") - .await - .unwrap(); - manager.set_metadata(as_metadata_with_pkce(None)); - assert!(manager.validate_server_metadata("code").is_ok()); - } - - #[tokio::test] - async fn test_validate_as_metadata_passes_with_pkce_s256() { - let mut manager = AuthorizationManager::new("https://example.com") - .await - .unwrap(); - manager.set_metadata(as_metadata_with_pkce(Some(vec!["S256".to_string()]))); - assert!(manager.validate_server_metadata("code").is_ok()); - } - - #[tokio::test] - async fn test_validate_as_metadata_passes_without_metadata() { - let manager = AuthorizationManager::new("https://example.com") - .await - .unwrap(); - assert!(manager.validate_server_metadata("code").is_ok()); - } - - // -- authorization flow -- - - #[tokio::test] - async fn test_authorization_url_is_valid() { - let base_url = "https://mcp.example.com/api"; - let auth_endpoint = "https://auth.example.com/authorize"; - let mut manager = AuthorizationManager::new(base_url).await.unwrap(); - - let metadata = AuthorizationMetadata { - authorization_endpoint: auth_endpoint.to_string(), - token_endpoint: "https://auth.example.com/token".to_string(), - registration_endpoint: None, - issuer: None, - jwks_uri: None, - scopes_supported: None, - response_types_supported: Some(vec!["code".to_string()]), - code_challenge_methods_supported: Some(vec!["S256".to_string()]), - additional_fields: std::collections::HashMap::new(), - }; - manager.set_metadata(metadata); - manager.configure_client_id("test-client-id").unwrap(); - - let auth_url = manager - .get_authorization_url(&["read", "write"]) - .await - .unwrap(); - let parsed = Url::parse(&auth_url).unwrap(); - - assert!(auth_url.starts_with(auth_endpoint)); - - let params: std::collections::HashMap<_, _> = parsed.query_pairs().collect(); - - assert_eq!( - params.get("response_type").map(|v| v.as_ref()), - Some("code") - ); - assert_eq!( - params.get("client_id").map(|v| v.as_ref()), - Some("test-client-id") - ); - assert!(params.contains_key("state")); - assert_eq!( - params.get("redirect_uri").map(|v| v.as_ref()), - Some(base_url) - ); - assert!(params.contains_key("code_challenge")); - assert_eq!( - params.get("code_challenge_method").map(|v| v.as_ref()), - Some("S256") - ); - assert_eq!(params.get("resource").map(|v| v.as_ref()), Some(base_url)); - - let scope = params - .get("scope") - .map(|v| v.to_string()) - .unwrap_or_default(); - assert!(scope.contains("read")); - assert!(scope.contains("write")); - } - - #[tokio::test] - async fn authorization_url_uses_discovered_resource() { - let base_url = "https://mcp.example.com/mcp"; - let auth_endpoint = "https://auth.example.com/authorize"; - let mut manager = AuthorizationManager::new(base_url).await.unwrap(); - - let metadata = AuthorizationMetadata { - authorization_endpoint: auth_endpoint.to_string(), - token_endpoint: "https://auth.example.com/token".to_string(), - registration_endpoint: None, - issuer: None, - jwks_uri: None, - scopes_supported: None, - response_types_supported: Some(vec!["code".to_string()]), - code_challenge_methods_supported: Some(vec!["S256".to_string()]), - additional_fields: std::collections::HashMap::new(), - }; - manager.set_metadata(metadata); - manager.configure_client_id("test-client-id").unwrap(); - *manager.discovered_resource.write().await = Some("https://mcp.example.com".to_string()); - - let auth_url = manager.get_authorization_url(&["read"]).await.unwrap(); - let parsed = Url::parse(&auth_url).unwrap(); - let params: std::collections::HashMap<_, _> = parsed.query_pairs().collect(); - - assert_eq!( - params.get("resource").map(|v| v.as_ref()), - Some("https://mcp.example.com") - ); - } - - #[tokio::test] - async fn authorization_url_uses_default_resource_without_protected_resource_document() { - let base_url = "https://mcp.example.com/mcp"; - let auth_endpoint = "https://auth.example.com/authorize"; - let mut manager = AuthorizationManager::new(base_url).await.unwrap(); - - let metadata = AuthorizationMetadata { - authorization_endpoint: auth_endpoint.to_string(), - token_endpoint: "https://auth.example.com/token".to_string(), - registration_endpoint: None, - issuer: None, - jwks_uri: None, - scopes_supported: None, - response_types_supported: Some(vec!["code".to_string()]), - code_challenge_methods_supported: Some(vec!["S256".to_string()]), - additional_fields: std::collections::HashMap::new(), - }; - manager.set_metadata(metadata); - manager.configure_client_id("test-client-id").unwrap(); - - let auth_url = manager.get_authorization_url(&["read"]).await.unwrap(); - let parsed = Url::parse(&auth_url).unwrap(); - let params: std::collections::HashMap<_, _> = parsed.query_pairs().collect(); - - assert_eq!(params.get("resource").map(|v| v.as_ref()), Some(base_url)); - } - - #[test] - fn authorization_callback_parses_optional_issuer() { - let callback = AuthorizationCallback::from_redirect_url( - "http://localhost/callback?code=abc&state=csrf&iss=https%3A%2F%2Fauth.example.com", - ) - .unwrap(); - - assert_eq!(callback.code, "abc"); - assert_eq!(callback.csrf_token, "csrf"); - assert_eq!(callback.issuer.as_deref(), Some("https://auth.example.com")); - } - - #[test] - fn authorization_callback_requires_code_and_state() { - let missing_code = AuthorizationCallback::from_redirect_url( - "http://localhost/callback?state=csrf&iss=https%3A%2F%2Fauth.example.com", - ); - assert!(matches!( - missing_code, - Err(AuthError::AuthorizationFailed(message)) if message.contains("missing code") - )); - - let missing_state = AuthorizationCallback::from_redirect_url( - "http://localhost/callback?code=abc&iss=https%3A%2F%2Fauth.example.com", - ); - assert!(matches!( - missing_state, - Err(AuthError::AuthorizationFailed(message)) if message.contains("missing state") - )); - } - - #[rstest] - #[case::matching_issuer( - Some("https://auth.example.com"), - false, - Some("https://auth.example.com") - )] - #[case::missing_issuer_when_not_required(Some("https://auth.example.com"), false, None)] - fn validate_authorization_response_issuer_accepts_valid_cases( - #[case] expected_issuer: Option<&str>, - #[case] require_issuer: bool, - #[case] received_issuer: Option<&str>, - ) { - let pkce = PkceCodeVerifier::new("verifier".to_string()); - let csrf = CsrfToken::new("csrf".to_string()); - let state = StoredAuthorizationState::new_with_expected_issuer( - &pkce, - &csrf, - expected_issuer.map(str::to_owned), - require_issuer, - ); - - assert!( - AuthorizationManager::validate_authorization_response_issuer(&state, received_issuer) - .is_ok() - ); - } - - #[derive(Clone, Copy, Debug)] - enum ExpectedIssuerError { - Missing { - expected_issuer: &'static str, - }, - NotRecorded, - Mismatch { - expected_issuer: &'static str, - received_issuer: &'static str, - }, - } - - fn assert_expected_issuer_error(error: AuthError, expected: ExpectedIssuerError) { - match expected { - ExpectedIssuerError::Missing { expected_issuer } => assert!(matches!( - error, - AuthError::AuthorizationServerMissingIssuer { expected_issuer: actual } - if actual == expected_issuer - )), - ExpectedIssuerError::NotRecorded => assert!( - matches!(error, AuthError::AuthorizationFailed(message) if message.contains("expected issuer was not recorded")) - ), - ExpectedIssuerError::Mismatch { - expected_issuer, - received_issuer, - } => assert!(matches!( - error, - AuthError::AuthorizationServerMismatch { - expected_issuer: actual_expected, - received_issuer: actual_received - } if actual_expected == expected_issuer && actual_received == received_issuer - )), - } - } - - #[rstest] - #[case::requires_advertised_issuer( - Some("https://auth.example.com"), - true, - None, - ExpectedIssuerError::Missing { - expected_issuer: "https://auth.example.com", - } - )] - #[case::present_issuer_without_expected( - None, - false, - Some("https://auth.example.com"), - ExpectedIssuerError::NotRecorded - )] - #[case::required_issuer_without_expected(None, true, None, ExpectedIssuerError::NotRecorded)] - #[case::mismatched_issuer( - Some("https://auth.example.com"), - false, - Some("https://evil.example.com"), - ExpectedIssuerError::Mismatch { - expected_issuer: "https://auth.example.com", - received_issuer: "https://evil.example.com", - } - )] - fn validate_authorization_response_issuer_rejects_invalid_cases( - #[case] expected_issuer: Option<&str>, - #[case] require_issuer: bool, - #[case] received_issuer: Option<&str>, - #[case] expected_error: ExpectedIssuerError, - ) { - let pkce = PkceCodeVerifier::new("verifier".to_string()); - let csrf = CsrfToken::new("csrf".to_string()); - let state = StoredAuthorizationState::new_with_expected_issuer( - &pkce, - &csrf, - expected_issuer.map(str::to_owned), - require_issuer, - ); - - let error = - AuthorizationManager::validate_authorization_response_issuer(&state, received_issuer) - .unwrap_err(); - - assert_expected_issuer_error(error, expected_error); - } - - #[tokio::test] - async fn authorization_url_stores_expected_issuer_for_callback_validation() { - let mut manager = manager_with_metadata(Some(AuthorizationMetadata { - authorization_endpoint: "https://auth.example.com/authorize".to_string(), - token_endpoint: "https://auth.example.com/token".to_string(), - issuer: Some("https://auth.example.com".to_string()), - ..Default::default() - })) - .await; - manager.configure_client_id("test-client-id").unwrap(); - - let auth_url = manager.get_authorization_url(&[]).await.unwrap(); - let parsed = Url::parse(&auth_url).unwrap(); - let state = parsed - .query_pairs() - .find_map(|(key, value)| (key == "state").then(|| value.into_owned())) - .expect("authorization URL should contain state"); - - let stored_state = manager - .state_store - .load(&state) - .await - .unwrap() - .expect("authorization state should be stored"); - assert_eq!( - stored_state.expected_issuer.as_deref(), - Some("https://auth.example.com") - ); - } - - // -- scope management -- - - #[test] - fn compute_scope_union_adds_new_scopes() { - let current = vec!["read".to_string(), "write".to_string()]; - let result = AuthorizationManager::compute_scope_union(¤t, "admin delete"); - - assert!(result.contains(&"read".to_string())); - assert!(result.contains(&"write".to_string())); - assert!(result.contains(&"admin".to_string())); - assert!(result.contains(&"delete".to_string())); - assert_eq!(result.len(), 4); - } - - #[test] - fn compute_scope_union_deduplicates() { - let current = vec!["read".to_string(), "write".to_string()]; - let result = AuthorizationManager::compute_scope_union(¤t, "read admin"); - - assert!(result.contains(&"read".to_string())); - assert!(result.contains(&"write".to_string())); - assert!(result.contains(&"admin".to_string())); - assert_eq!(result.len(), 3); - } - - #[test] - fn compute_scope_union_handles_empty_current() { - let current: Vec = vec![]; - let result = AuthorizationManager::compute_scope_union(¤t, "read write"); - - assert!(result.contains(&"read".to_string())); - assert!(result.contains(&"write".to_string())); - assert_eq!(result.len(), 2); - } - - // -- SEP-2207: offline_access -- - - #[tokio::test] - async fn select_scopes_adds_offline_access_when_as_supports_it() { - let mgr = manager_with_metadata(Some(AuthorizationMetadata { - authorization_endpoint: "http://localhost/authorize".to_string(), - token_endpoint: "http://localhost/token".to_string(), - scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]), - ..Default::default() - })) - .await; - *mgr.resource_scopes.write().await = vec!["profile".to_string()]; - - let scopes = mgr.select_scopes(None, &[]); - assert!( - scopes.contains(&"offline_access".to_string()), - "offline_access should be added when AS supports it" - ); - assert!(scopes.contains(&"profile".to_string())); - } - - #[tokio::test] - async fn select_scopes_does_not_add_offline_access_when_as_does_not_support_it() { - let mgr = manager_with_metadata(Some(AuthorizationMetadata { - authorization_endpoint: "http://localhost/authorize".to_string(), - token_endpoint: "http://localhost/token".to_string(), - scopes_supported: Some(vec!["profile".to_string(), "email".to_string()]), - ..Default::default() - })) - .await; - *mgr.resource_scopes.write().await = vec!["profile".to_string()]; - - let scopes = mgr.select_scopes(None, &[]); - assert!( - !scopes.contains(&"offline_access".to_string()), - "offline_access should not be added when AS does not support it" - ); - } - - #[tokio::test] - async fn select_scopes_falls_back_to_defaults() { - let mgr = manager_with_metadata(Some(AuthorizationMetadata { - authorization_endpoint: "http://localhost/authorize".to_string(), - token_endpoint: "http://localhost/token".to_string(), - scopes_supported: None, - ..Default::default() - })) - .await; - - let scopes = mgr.select_scopes(None, &["default_scope"]); - assert_eq!(scopes, vec!["default_scope".to_string()]); - } - - #[tokio::test] - async fn select_scopes_does_not_duplicate_offline_access() { - let mgr = manager_with_metadata(Some(AuthorizationMetadata { - authorization_endpoint: "http://localhost/authorize".to_string(), - token_endpoint: "http://localhost/token".to_string(), - scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]), - ..Default::default() - })) - .await; - - // When AS metadata is the scope source and already contains offline_access, - // it should appear exactly once. - let scopes = mgr.select_scopes(None, &[]); - let count = scopes.iter().filter(|s| *s == "offline_access").count(); - assert_eq!(count, 1, "offline_access should not be duplicated"); - } - - #[tokio::test] - async fn select_scopes_adds_offline_access_to_www_authenticate_scopes() { - let mgr = manager_with_metadata(Some(AuthorizationMetadata { - authorization_endpoint: "http://localhost/authorize".to_string(), - token_endpoint: "http://localhost/token".to_string(), - scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]), - ..Default::default() - })) - .await; - *mgr.www_auth_scopes.write().await = vec!["profile".to_string()]; - - let scopes = mgr.select_scopes(None, &[]); - assert!(scopes.contains(&"offline_access".to_string())); - assert!(scopes.contains(&"profile".to_string())); - } - - #[tokio::test] - async fn select_scopes_adds_offline_access_to_www_authenticate_argument() { - let mgr = manager_with_metadata(Some(AuthorizationMetadata { - authorization_endpoint: "http://localhost/authorize".to_string(), - token_endpoint: "http://localhost/token".to_string(), - scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]), - ..Default::default() - })) - .await; - - let scopes = mgr.select_scopes(Some("profile email"), &[]); - assert!(scopes.contains(&"offline_access".to_string())); - assert!(scopes.contains(&"profile".to_string())); - assert!(scopes.contains(&"email".to_string())); - } - - // -- SEP-2350: client-side scope accumulation in step-up authorization -- - - #[tokio::test] - async fn select_scopes_unions_challenge_with_previously_requested() { - let mgr = manager_with_metadata(None).await; - *mgr.current_scopes.write().await = vec!["read".to_string()]; - - let scopes = mgr.select_scopes(Some("write"), &[]); - - assert_eq!(scopes, vec!["read".to_string(), "write".to_string()]); - } - - #[tokio::test] - async fn select_scopes_does_not_replace_previously_requested_with_challenge() { - let mgr = manager_with_metadata(None).await; - *mgr.current_scopes.write().await = vec!["read".to_string(), "profile".to_string()]; - - let scopes = mgr.select_scopes(Some("write"), &[]); - - assert!(scopes.contains(&"read".to_string())); - assert!(scopes.contains(&"profile".to_string())); - assert!(scopes.contains(&"write".to_string())); - } - - #[tokio::test] - async fn select_scopes_accumulates_across_multiple_step_up_rounds() { - let mgr = manager_with_metadata(None).await; - *mgr.current_scopes.write().await = vec!["read".to_string()]; - - // round one: server challenges for "write" - let round_one = mgr.select_scopes(Some("write"), &[]); - assert_eq!(round_one, vec!["read".to_string(), "write".to_string()]); - *mgr.current_scopes.write().await = round_one; - - // round two: server challenges for "admin", earlier scopes are retained - let round_two = mgr.select_scopes(Some("admin"), &[]); - assert_eq!( - round_two, - vec!["read".to_string(), "write".to_string(), "admin".to_string()] - ); - } - - #[tokio::test] - async fn select_scopes_deduplicates_challenge_already_requested() { - let mgr = manager_with_metadata(None).await; - *mgr.current_scopes.write().await = vec!["read".to_string(), "write".to_string()]; - - let scopes = mgr.select_scopes(Some("write admin"), &[]); - - assert_eq!( - scopes, - vec!["read".to_string(), "write".to_string(), "admin".to_string()] - ); - } - - #[tokio::test] - async fn select_scopes_unions_resource_metadata_as_operational_requirement() { - let mgr = manager_with_metadata(None).await; - *mgr.current_scopes.write().await = vec!["read".to_string()]; - *mgr.resource_scopes.write().await = vec!["profile".to_string()]; - - let scopes = mgr.select_scopes(Some("write"), &[]); - - assert!(scopes.contains(&"read".to_string())); - assert!(scopes.contains(&"write".to_string())); - assert!(scopes.contains(&"profile".to_string())); - } - - #[test] - fn resolve_granted_scopes_uses_requested_when_response_omits_scope() { - let granted = AuthorizationManager::resolve_granted_scopes( - None, - &["read".to_string(), "write".to_string()], - &["read".to_string()], - ); - - assert_eq!(granted, vec!["read".to_string(), "write".to_string()]); - } - - #[test] - fn resolve_granted_scopes_honors_explicit_server_downgrade() { - let granted = AuthorizationManager::resolve_granted_scopes( - Some(vec!["read".to_string()]), - &["read".to_string(), "write".to_string()], - &["read".to_string()], - ); - - assert_eq!(granted, vec!["read".to_string()]); - } - - #[test] - fn resolve_granted_scopes_falls_back_to_current_when_nothing_requested() { - let granted = - AuthorizationManager::resolve_granted_scopes(None, &[], &["read".to_string()]); - - assert_eq!(granted, vec!["read".to_string()]); - } - - #[tokio::test] - async fn add_offline_access_if_supported_works_with_explicit_scopes() { - let mgr = manager_with_metadata(Some(AuthorizationMetadata { - authorization_endpoint: "http://localhost/authorize".to_string(), - token_endpoint: "http://localhost/token".to_string(), - scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]), - ..Default::default() - })) - .await; - - let mut explicit = vec!["read".to_string(), "write".to_string()]; - mgr.add_offline_access_if_supported(&mut explicit); - assert!(explicit.contains(&"offline_access".to_string())); - } - - #[tokio::test] - async fn add_offline_access_if_supported_skips_empty_scopes() { - let mgr = manager_with_metadata(Some(AuthorizationMetadata { - authorization_endpoint: "http://localhost/authorize".to_string(), - token_endpoint: "http://localhost/token".to_string(), - scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]), - ..Default::default() - })) - .await; - - let mut empty: Vec = vec![]; - mgr.add_offline_access_if_supported(&mut empty); - assert!( - empty.is_empty(), - "offline_access should not be the only scope" - ); - } - - #[tokio::test] - async fn scope_upgrade_adds_offline_access_when_as_supports_it() { - let mut mgr = manager_with_metadata(Some(AuthorizationMetadata { - authorization_endpoint: "http://localhost/authorize".to_string(), - token_endpoint: "http://localhost/token".to_string(), - scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]), - ..Default::default() - })) - .await; - mgr.configure_client_id("my-client").unwrap(); - *mgr.current_scopes.write().await = vec!["profile".to_string()]; - - let auth_url = mgr.request_scope_upgrade("email").await.unwrap(); - let parsed = Url::parse(&auth_url).unwrap(); - let scope = parsed - .query_pairs() - .find_map(|(key, value)| (key == "scope").then(|| value.into_owned())) - .expect("scope should be present"); - let mut scope_parts: Vec<&str> = scope.split_whitespace().collect(); - scope_parts.sort_unstable(); - assert_eq!(scope_parts, vec!["email", "offline_access", "profile"]); - } - - #[test] - fn scope_upgrade_config_default_values() { - let config = ScopeUpgradeConfig::default(); - assert_eq!(config.max_upgrade_attempts, 3); - assert!(config.auto_upgrade); - } - - #[tokio::test] - async fn authorization_manager_tracks_scope_upgrade_attempts() { - let manager = AuthorizationManager::new("http://localhost").await.unwrap(); - - assert_eq!(manager.get_scope_upgrade_attempts().await, 0); - - *manager.scope_upgrade_attempts.write().await = 2; - assert_eq!(manager.get_scope_upgrade_attempts().await, 2); - - manager.reset_scope_upgrade_attempts().await; - assert_eq!(manager.get_scope_upgrade_attempts().await, 0); - } - - #[tokio::test] - async fn authorization_manager_can_attempt_scope_upgrade_respects_config() { - let mut manager = AuthorizationManager::new("http://localhost").await.unwrap(); - - assert!(manager.can_attempt_scope_upgrade().await); - - manager.set_scope_upgrade_config(ScopeUpgradeConfig { - max_upgrade_attempts: 3, - auto_upgrade: false, - }); - assert!(!manager.can_attempt_scope_upgrade().await); - - manager.set_scope_upgrade_config(ScopeUpgradeConfig { - max_upgrade_attempts: 2, - auto_upgrade: true, - }); - *manager.scope_upgrade_attempts.write().await = 2; - assert!(!manager.can_attempt_scope_upgrade().await); - - *manager.scope_upgrade_attempts.write().await = 1; - assert!(manager.can_attempt_scope_upgrade().await); - } - - // -- get_access_token -- - - use super::{OAuthTokenResponse, StoredCredentials}; - - fn make_token_response(access_token: &str, expires_in_secs: Option) -> OAuthTokenResponse { - use oauth2::{AccessToken, basic::BasicTokenType}; - let mut resp = OAuthTokenResponse::new( - AccessToken::new(access_token.to_string()), - BasicTokenType::Bearer, - VendorExtraTokenFields { - ..Default::default() - }, - ); - if let Some(secs) = expires_in_secs { - resp.set_expires_in(Some(&std::time::Duration::from_secs(secs))); - } - resp - } - - #[tokio::test] - async fn get_access_token_returns_error_when_no_credentials() { - let manager = AuthorizationManager::new("http://localhost").await.unwrap(); - let err = manager.get_access_token().await.unwrap_err(); - assert!(matches!(err, AuthError::AuthorizationRequired)); - } - - #[tokio::test] - async fn get_access_token_returns_token_when_not_expired() { - let manager = AuthorizationManager::new("http://localhost").await.unwrap(); - let stored = StoredCredentials { - client_id: "test".to_string(), - token_response: Some(make_token_response("my-access-token", Some(3600))), - granted_scopes: vec![], - token_received_at: Some(AuthorizationManager::now_epoch_secs()), - issuer: None, - }; - manager.credential_store.save(stored).await.unwrap(); - - let token = manager.get_access_token().await.unwrap(); - assert_eq!(token, "my-access-token"); - } - - #[tokio::test] - async fn get_access_token_requires_reauth_when_expired_and_no_refresh_token() { - let mut manager = manager_with_metadata(None).await; - manager.configure_client(test_client_config()).unwrap(); - - let stored = StoredCredentials { - client_id: "my-client".to_string(), - token_response: Some(make_token_response("stale-token", Some(3600))), - granted_scopes: vec![], - token_received_at: Some(AuthorizationManager::now_epoch_secs() - 7200), - issuer: None, - }; - manager.credential_store.save(stored).await.unwrap(); - - let err = manager.get_access_token().await.unwrap_err(); - assert!( - matches!(err, AuthError::AuthorizationRequired), - "expected AuthorizationRequired when token is expired and refresh is impossible, got: {err:?}" - ); - } - - #[tokio::test] - async fn get_access_token_returns_token_without_expiry_info() { - let manager = AuthorizationManager::new("http://localhost").await.unwrap(); - let stored = StoredCredentials { - client_id: "test".to_string(), - token_response: Some(make_token_response("no-expiry-token", None)), - granted_scopes: vec![], - token_received_at: None, - issuer: None, - }; - manager.credential_store.save(stored).await.unwrap(); - - let token = manager.get_access_token().await.unwrap(); - assert_eq!(token, "no-expiry-token"); - } - - #[tokio::test] - async fn get_access_token_requires_reauth_when_within_refresh_buffer() { - let mut manager = manager_with_metadata(None).await; - manager.configure_client(test_client_config()).unwrap(); - - let stored = StoredCredentials { - client_id: "my-client".to_string(), - token_response: Some(make_token_response("almost-expired", Some(3600))), - granted_scopes: vec![], - token_received_at: Some(AuthorizationManager::now_epoch_secs() - 3590), - issuer: None, - }; - manager.credential_store.save(stored).await.unwrap(); - - let err = manager.get_access_token().await.unwrap_err(); - assert!( - matches!(err, AuthError::AuthorizationRequired), - "expected AuthorizationRequired when token is within refresh buffer, got: {err:?}" - ); - } - - #[tokio::test] - async fn get_access_token_propagates_internal_errors() { - let manager = AuthorizationManager::new("http://localhost").await.unwrap(); - let stored = StoredCredentials { - client_id: "test".to_string(), - token_response: Some(make_token_response("stale-token", Some(3600))), - granted_scopes: vec![], - token_received_at: Some(AuthorizationManager::now_epoch_secs() - 7200), - issuer: None, - }; - manager.credential_store.save(stored).await.unwrap(); - - let err = manager.get_access_token().await.unwrap_err(); - assert!( - matches!(err, AuthError::InternalError(_)), - "expected InternalError when OAuth client is not configured, got: {err:?}" - ); - } - - // -- ClientRegistrationRequest serialization -- - - #[test] - fn client_registration_request_includes_scope_when_present() { - let req = super::ClientRegistrationRequest { - client_name: "test".to_string(), - redirect_uris: vec!["http://localhost/callback".to_string()], - grant_types: vec!["authorization_code".to_string()], - token_endpoint_auth_method: "none".to_string(), - response_types: vec!["code".to_string()], - scope: Some("read write".to_string()), - application_type: None, - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["scope"], "read write"); - } - - #[test] - fn client_registration_request_omits_scope_when_none() { - let req = super::ClientRegistrationRequest { - client_name: "test".to_string(), - redirect_uris: vec!["http://localhost/callback".to_string()], - grant_types: vec!["authorization_code".to_string()], - token_endpoint_auth_method: "none".to_string(), - response_types: vec!["code".to_string()], - scope: None, - application_type: None, - }; - let json = serde_json::to_value(&req).unwrap(); - assert!(!json.as_object().unwrap().contains_key("scope")); - } - - // -- ClientRegistrationRequest application_type (SEP-837) -- - - #[test] - fn client_registration_request_includes_application_type_when_present() { - let req = super::ClientRegistrationRequest { - client_name: "test".to_string(), - redirect_uris: vec!["http://localhost/callback".to_string()], - grant_types: vec!["authorization_code".to_string()], - token_endpoint_auth_method: "none".to_string(), - response_types: vec!["code".to_string()], - scope: None, - application_type: Some("native".to_string()), - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["application_type"], "native"); - } - - #[test] - fn client_registration_request_omits_application_type_when_none() { - let req = super::ClientRegistrationRequest { - client_name: "test".to_string(), - redirect_uris: vec!["http://localhost/callback".to_string()], - grant_types: vec!["authorization_code".to_string()], - token_endpoint_auth_method: "none".to_string(), - response_types: vec!["code".to_string()], - scope: None, - application_type: None, - }; - let json = serde_json::to_value(&req).unwrap(); - assert!(!json.as_object().unwrap().contains_key("application_type")); - } - - // -- OAuthClientConfig application_type (SEP-837) -- - - #[test] - fn oauth_client_config_defaults_application_type_to_native() { - let config = super::OAuthClientConfig::new("client-id", "http://127.0.0.1:8080/callback"); - assert_eq!(config.application_type.as_deref(), Some("native")); - } - - #[test] - fn oauth_client_config_with_application_type_overrides_default() { - let config = super::OAuthClientConfig::new("client-id", "https://app.example.com/callback") - .with_application_type("web"); - assert_eq!(config.application_type.as_deref(), Some("web")); - } - - // -- client credentials (SEP-1046) -- - - #[cfg(feature = "auth-client-credentials-jwt")] - #[test] - fn client_authentication_audience_defaults_to_metadata_issuer() { - let metadata = AuthorizationMetadata { - authorization_endpoint: "https://auth.example.com/authorize".to_string(), - token_endpoint: "https://auth.example.com/token".to_string(), - issuer: Some("https://auth.example.com".to_string()), - ..Default::default() - }; - - let audience = super::client_authentication_audience(&metadata, None).unwrap(); - - assert_eq!(audience, "https://auth.example.com"); - } - - #[cfg(feature = "auth-client-credentials-jwt")] - #[test] - fn client_authentication_audience_honors_explicit_override() { - let metadata = AuthorizationMetadata { - authorization_endpoint: "https://auth.example.com/authorize".to_string(), - token_endpoint: "https://auth.example.com/token".to_string(), - issuer: Some("https://auth.example.com".to_string()), - ..Default::default() - }; - - let audience = super::client_authentication_audience( - &metadata, - Some("https://legacy.example.com/token"), - ) - .unwrap(); - - assert_eq!(audience, "https://legacy.example.com/token"); - } - - #[cfg(feature = "auth-client-credentials-jwt")] - #[test] - fn client_authentication_audience_rejects_missing_metadata_issuer() { - let metadata = AuthorizationMetadata { - authorization_endpoint: "https://auth.example.com/authorize".to_string(), - token_endpoint: "https://auth.example.com/token".to_string(), - ..Default::default() - }; - - let error = super::client_authentication_audience(&metadata, None).unwrap_err(); - - assert!(matches!(error, AuthError::ClientCredentialsError(_))); - } - - #[cfg(feature = "auth-client-credentials-jwt")] - #[test] - fn client_authentication_header_sets_explicit_type() { - let header = super::client_authentication_header(super::JwtSigningAlgorithm::ES256); - - assert_eq!(header.typ.as_deref(), Some("client-authentication+jwt")); - } - - #[cfg(feature = "auth-client-credentials-jwt")] - #[test] - fn client_credentials_endpoint_allows_http_between_loopback_hosts() { - let resource = Url::parse("http://localhost:8000/mcp").unwrap(); - let token_endpoint = Url::parse("http://127.0.0.1:9000/token").unwrap(); - - assert!(super::is_allowed_client_credentials_endpoint( - &resource, - &token_endpoint - )); - } - - #[cfg(feature = "auth-client-credentials-jwt")] - #[test] - fn client_credentials_endpoint_rejects_http_to_non_loopback_host() { - let resource = Url::parse("http://localhost:8000/mcp").unwrap(); - let token_endpoint = Url::parse("http://auth.example.com/token").unwrap(); - - assert!(!super::is_allowed_client_credentials_endpoint( - &resource, - &token_endpoint - )); - } - - #[tokio::test] - async fn configure_client_credentials_uses_request_body_auth_for_client_secret() { - let mut mgr = manager_with_metadata(None).await; - let config = super::ClientCredentialsConfig::ClientSecret { - client_id: "my-m2m-client".to_string(), - client_secret: "super-secret".to_string(), - scopes: vec!["read".to_string()], - resource: None, - }; - mgr.configure_client_credentials(&config).unwrap(); - let oauth_client = mgr.oauth_client.as_ref().unwrap(); - assert!(matches!(oauth_client.auth_type(), AuthType::RequestBody)); - } - - #[tokio::test] - async fn configure_client_credentials_sets_correct_client_id() { - let mut mgr = manager_with_metadata(None).await; - let config = super::ClientCredentialsConfig::ClientSecret { - client_id: "my-m2m-client".to_string(), - client_secret: "super-secret".to_string(), - scopes: vec!["read".to_string()], - resource: None, - }; - mgr.configure_client_credentials(&config).unwrap(); - let oauth_client = mgr.oauth_client.as_ref().unwrap(); - assert_eq!(oauth_client.client_id().as_str(), "my-m2m-client"); - } - - #[tokio::test] - async fn configure_client_credentials_returns_error_without_metadata() { - let mut mgr = AuthorizationManager::new("http://localhost").await.unwrap(); - let config = super::ClientCredentialsConfig::ClientSecret { - client_id: "id".to_string(), - client_secret: "secret".to_string(), - scopes: vec![], - resource: None, - }; - let err = mgr.configure_client_credentials(&config).unwrap_err(); - assert!(matches!(err, AuthError::NoAuthorizationSupport)); - } - - #[tokio::test] - async fn validate_client_credentials_metadata_rejects_unsupported_method() { - let mut additional_fields = HashMap::new(); - additional_fields.insert( - "token_endpoint_auth_methods_supported".to_string(), - // Neither client_secret_post nor client_secret_basic — should be rejected. - serde_json::json!(["tls_client_auth", "private_key_jwt"]), - ); - let meta = AuthorizationMetadata { - authorization_endpoint: "http://localhost/authorize".to_string(), - token_endpoint: "http://localhost/token".to_string(), - additional_fields, - ..Default::default() - }; - let mgr = manager_with_metadata(Some(meta)).await; - let config = super::ClientCredentialsConfig::ClientSecret { - client_id: "id".to_string(), - client_secret: "secret".to_string(), - scopes: vec![], - resource: None, - }; - let err = mgr - .validate_client_credentials_metadata(&config) - .unwrap_err(); - assert!( - err.to_string().contains("tls_client_auth"), - "expected error to mention unsupported method, got: {err}" - ); - } - - fn client_secret_credentials_config() -> super::ClientCredentialsConfig { - super::ClientCredentialsConfig::ClientSecret { - client_id: "id".to_string(), - client_secret: "secret".to_string(), - scopes: vec![], - resource: None, - } - } - - fn metadata_with_auth_methods(methods: serde_json::Value) -> AuthorizationMetadata { - let mut additional_fields = HashMap::new(); - additional_fields.insert("token_endpoint_auth_methods_supported".to_string(), methods); - AuthorizationMetadata { - authorization_endpoint: "http://localhost/authorize".to_string(), - token_endpoint: "http://localhost/token".to_string(), - additional_fields, - ..Default::default() - } - } - - #[rstest] - #[case::supported_methods(Some(serde_json::json!([ - "client_secret_post", - "client_secret_basic" - ])))] - #[case::field_absent(None)] - #[case::client_secret_basic_only(Some(serde_json::json!(["client_secret_basic"])))] - #[tokio::test] - async fn validate_client_credentials_metadata_accepts_supported_configurations( - #[case] auth_methods: Option, - ) { - let mgr = manager_with_metadata(auth_methods.map(metadata_with_auth_methods)).await; - let config = client_secret_credentials_config(); - - mgr.validate_client_credentials_metadata(&config).unwrap(); - } - - #[tokio::test] - async fn configure_client_credentials_uses_basic_auth_when_server_only_supports_basic() { - let mut additional_fields = HashMap::new(); - additional_fields.insert( - "token_endpoint_auth_methods_supported".to_string(), - serde_json::json!(["client_secret_basic"]), - ); - let meta = AuthorizationMetadata { - authorization_endpoint: "http://localhost/authorize".to_string(), - token_endpoint: "http://localhost/token".to_string(), - additional_fields, - ..Default::default() - }; - let mut mgr = manager_with_metadata(Some(meta)).await; - let config = super::ClientCredentialsConfig::ClientSecret { - client_id: "id".to_string(), - client_secret: "secret".to_string(), - scopes: vec![], - resource: None, - }; - mgr.configure_client_credentials(&config).unwrap(); - let oauth_client = mgr.oauth_client.as_ref().unwrap(); - assert!( - !matches!(oauth_client.auth_type(), AuthType::RequestBody), - "expected HTTP Basic auth when server only supports client_secret_basic" - ); - } - - #[test] - fn client_credentials_config_returns_correct_accessor_values() { - let config = super::ClientCredentialsConfig::ClientSecret { - client_id: "test-id".to_string(), - client_secret: "test-secret".to_string(), - scopes: vec!["scope1".to_string(), "scope2".to_string()], - resource: Some("https://example.com".to_string()), - }; - assert_eq!(config.client_id(), "test-id"); - assert_eq!(config.scopes(), &["scope1", "scope2"]); - assert_eq!(config.resource(), Some("https://example.com")); - assert_eq!(config.auth_method(), "client_secret_post"); - } - - #[test] - fn extension_constant_matches_spec() { - assert_eq!( - super::EXTENSION_OAUTH_CLIENT_CREDENTIALS, - "io.modelcontextprotocol/oauth-client-credentials" - ); - } - - // -- refresh_token -- - - fn make_token_response_with_refresh( - access_token: &str, - refresh_token: &str, - ) -> OAuthTokenResponse { - use oauth2::RefreshToken; - let mut resp = make_token_response(access_token, Some(3600)); - resp.set_refresh_token(Some(RefreshToken::new(refresh_token.to_string()))); - resp - } - - async fn manager_with_refresh_error(error: &'static str) -> AuthorizationManager { - use axum::{Router, body::Body, http::Response, routing::post}; - - let app = Router::new().route( - "/token", - post(move || async move { - Response::builder() - .status(400) - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({ - "error": error, - "error_description": "refresh failed", - }) - .to_string(), - )) - .unwrap() - }), - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - - let mut manager = manager_with_metadata(Some(AuthorizationMetadata { - authorization_endpoint: format!("http://{addr}/authorize"), - token_endpoint: format!("http://{addr}/token"), - ..Default::default() - })) - .await; - manager.configure_client(test_client_config()).unwrap(); - manager - .credential_store - .save(StoredCredentials { - client_id: "my-client".to_string(), - token_response: Some(make_token_response_with_refresh( - "old-token", - "my-refresh-token", - )), - granted_scopes: vec![], - token_received_at: Some(AuthorizationManager::now_epoch_secs()), - issuer: None, - }) - .await - .unwrap(); - manager - } - - #[tokio::test] - async fn refresh_token_returns_error_when_no_stored_credentials() { - let mut manager = manager_with_metadata(None).await; - manager.configure_client(test_client_config()).unwrap(); - - let err = manager.refresh_token().await.unwrap_err(); - assert!( - matches!(err, AuthError::AuthorizationRequired), - "expected AuthorizationRequired when no credentials stored, got: {err:?}" - ); - } - - #[tokio::test] - async fn refresh_token_returns_error_when_no_token_response() { - let mut manager = manager_with_metadata(None).await; - manager.configure_client(test_client_config()).unwrap(); - - let stored = StoredCredentials { - client_id: "my-client".to_string(), - token_response: None, - granted_scopes: vec![], - token_received_at: None, - issuer: None, - }; - manager.credential_store.save(stored).await.unwrap(); - - let err = manager.refresh_token().await.unwrap_err(); - assert!( - matches!(err, AuthError::AuthorizationRequired), - "expected AuthorizationRequired when token_response is None, got: {err:?}" - ); - } - - #[tokio::test] - async fn refresh_token_returns_error_when_no_refresh_token() { - let mut manager = manager_with_metadata(None).await; - manager.configure_client(test_client_config()).unwrap(); - - let stored = StoredCredentials { - client_id: "my-client".to_string(), - token_response: Some(make_token_response("old-token", Some(3600))), - granted_scopes: vec![], - token_received_at: Some(AuthorizationManager::now_epoch_secs()), - issuer: None, - }; - manager.credential_store.save(stored).await.unwrap(); - - let err = manager.refresh_token().await.unwrap_err(); - assert!( - matches!(err, AuthError::AuthorizationRequired), - "expected AuthorizationRequired when no refresh token, got: {err:?}" - ); - } - - #[tokio::test] - async fn invalid_grant_refresh_requires_reauthorization() { - let manager = manager_with_refresh_error("invalid_grant").await; - - let err = manager.try_refresh_or_reauth().await.unwrap_err(); - - assert!( - matches!(err, AuthError::AuthorizationRequired), - "expected AuthorizationRequired when the refresh token is rejected, got: {err:?}" - ); - } - - #[tokio::test] - async fn temporary_refresh_failure_does_not_require_reauthorization() { - let manager = manager_with_refresh_error("temporarily_unavailable").await; - - let err = manager.try_refresh_or_reauth().await.unwrap_err(); - - assert!( - matches!(err, AuthError::TokenRefreshFailed(_)), - "expected TokenRefreshFailed for a temporary provider failure, got: {err:?}" - ); - } - - #[tokio::test] - async fn refresh_token_uses_client_configured_by_with_client() { - use axum::{Router, body::Body, http::Response, routing::post}; - - let received_header = Arc::new(std::sync::Mutex::new(None)); - let received_header_clone = Arc::clone(&received_header); - let app = Router::new().route( - "/token", - post(move |headers: axum::http::HeaderMap| { - let received_header = Arc::clone(&received_header_clone); - async move { - *received_header.lock().unwrap() = headers - .get("x-custom-client") - .and_then(|value| value.to_str().ok()) - .map(str::to_string); - Response::builder() - .status(200) - .header("content-type", "application/json") - .body(Body::from( - r#"{"access_token":"new-token","token_type":"Bearer","expires_in":3600}"#, - )) - .unwrap() - } - }), - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - - let mut manager = manager_with_metadata(Some(AuthorizationMetadata { - authorization_endpoint: format!("http://{addr}/authorize"), - token_endpoint: format!("http://{addr}/token"), - ..Default::default() - })) - .await; - let mut default_headers = reqwest::header::HeaderMap::new(); - default_headers.insert("x-custom-client", "configured".parse().unwrap()); - manager - .with_client( - reqwest::Client::builder() - .default_headers(default_headers) - .build() - .unwrap(), - ) - .unwrap(); - manager.configure_client(test_client_config()).unwrap(); - manager - .credential_store - .save(StoredCredentials { - client_id: "my-client".to_string(), - token_response: Some(make_token_response_with_refresh( - "old-token", - "my-refresh-token", - )), - granted_scopes: vec![], - token_received_at: Some(AuthorizationManager::now_epoch_secs()), - issuer: None, - }) - .await - .unwrap(); - - manager.refresh_token().await.unwrap(); - - assert_eq!( - received_header.lock().unwrap().as_deref(), - Some("configured") - ); - } - - #[tokio::test] - async fn exchange_code_uses_client_configured_by_with_client() { - use axum::{Router, body::Body, http::Response, routing::post}; - - let received_header = Arc::new(std::sync::Mutex::new(None)); - let received_header_clone = Arc::clone(&received_header); - let app = Router::new().route( - "/token", - post(move |headers: axum::http::HeaderMap| { - let received_header = Arc::clone(&received_header_clone); - async move { - *received_header.lock().unwrap() = headers - .get("x-custom-client") - .and_then(|value| value.to_str().ok()) - .map(str::to_string); - Response::builder() - .status(200) - .header("content-type", "application/json") - .body(Body::from( - r#"{"access_token":"new-token","token_type":"Bearer","expires_in":3600}"#, - )) - .unwrap() - } - }), - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - - let mut manager = manager_with_metadata(Some(AuthorizationMetadata { - authorization_endpoint: format!("http://{addr}/authorize"), - token_endpoint: format!("http://{addr}/token"), - ..Default::default() - })) - .await; - let mut default_headers = reqwest::header::HeaderMap::new(); - default_headers.insert("x-custom-client", "configured".parse().unwrap()); - manager - .with_client( - reqwest::Client::builder() - .default_headers(default_headers) - .build() - .unwrap(), - ) - .unwrap(); - manager.configure_client(test_client_config()).unwrap(); - let authorization_url = manager.get_authorization_url(&[]).await.unwrap(); - let state = Url::parse(&authorization_url) - .unwrap() - .query_pairs() - .find(|(name, _)| name == "state") - .unwrap() - .1 - .into_owned(); - - manager - .exchange_code_for_token("authorization-code", &state) - .await - .unwrap(); - - assert_eq!( - received_header.lock().unwrap().as_deref(), - Some("configured") - ); - } - - #[tokio::test] - async fn exchange_code_follows_redirects_with_with_client() { - use std::sync::atomic::{AtomicBool, Ordering}; - - use axum::{ - Router, - body::Body, - http::{Response, StatusCode}, - routing::post, - }; - - // The token endpoint replies with a 307 redirect; the with_client path reuses - // the caller's redirect-following client, so the request is expected to follow - // it to the final endpoint that returns the token. - let final_endpoint_hit = Arc::new(AtomicBool::new(false)); - let final_endpoint_hit_clone = Arc::clone(&final_endpoint_hit); - let app = Router::new() - .route( - "/token", - post(|| async { - Response::builder() - .status(StatusCode::TEMPORARY_REDIRECT) - .header("location", "/token-final") - .body(Body::empty()) - .unwrap() - }), - ) - .route( - "/token-final", - post(move || { - let final_endpoint_hit = Arc::clone(&final_endpoint_hit_clone); - async move { - final_endpoint_hit.store(true, Ordering::SeqCst); - Response::builder() - .status(200) - .header("content-type", "application/json") - .body(Body::from( - r#"{"access_token":"redirected-token","token_type":"Bearer","expires_in":3600}"#, - )) - .unwrap() - } - }), - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - - let mut manager = manager_with_metadata(Some(AuthorizationMetadata { - authorization_endpoint: format!("http://{addr}/authorize"), - token_endpoint: format!("http://{addr}/token"), - ..Default::default() - })) - .await; - manager - .with_client(reqwest::Client::builder().build().unwrap()) - .unwrap(); - manager.configure_client(test_client_config()).unwrap(); - let authorization_url = manager.get_authorization_url(&[]).await.unwrap(); - let state = Url::parse(&authorization_url) - .unwrap() - .query_pairs() - .find(|(name, _)| name == "state") - .unwrap() - .1 - .into_owned(); - - manager - .exchange_code_for_token("authorization-code", &state) - .await - .unwrap(); - - assert!( - final_endpoint_hit.load(Ordering::SeqCst), - "with_client path should follow redirects on token exchange" - ); - } - - async fn start_token_server() -> (String, Arc>>) { - use axum::{Router, body::Body, http::Response, routing::post}; - let captured: Arc>> = Arc::new(std::sync::Mutex::new(None)); - let captured_clone = Arc::clone(&captured); - - let app = Router::new().route( - "/token", - post(move |body: axum::body::Bytes| { - let cap = Arc::clone(&captured_clone); - async move { - *cap.lock().unwrap() = - Some(String::from_utf8(body.to_vec()).unwrap()); - Response::builder() - .status(200) - .header("content-type", "application/json") - .body(Body::from( - r#"{"access_token":"new-token","token_type":"Bearer","expires_in":3600}"#, - )) - .unwrap() - } - }), - ); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - - (format!("http://{}", addr), captured) - } - - #[tokio::test] - async fn refresh_token_sends_granted_scopes_in_request() { - let (base_url, captured) = start_token_server().await; - - let mut manager = manager_with_metadata(Some(AuthorizationMetadata { - authorization_endpoint: format!("{}/authorize", base_url), - token_endpoint: format!("{}/token", base_url), - ..Default::default() - })) - .await; - manager.configure_client(test_client_config()).unwrap(); - - let stored = StoredCredentials { - client_id: "my-client".to_string(), - token_response: Some(make_token_response_with_refresh( - "old-token", - "my-refresh-token", - )), - granted_scopes: vec!["read".to_string(), "write".to_string()], - token_received_at: Some(AuthorizationManager::now_epoch_secs()), - issuer: None, - }; - manager.credential_store.save(stored).await.unwrap(); - - manager.refresh_token().await.unwrap(); - - let body = captured.lock().unwrap().take().unwrap(); - let params: std::collections::HashMap<_, _> = url::form_urlencoded::parse(body.as_bytes()) - .into_owned() - .collect(); - let scope = params - .get("scope") - .expect("scope should be present in refresh request"); - let mut scope_parts: Vec<&str> = scope.split_whitespace().collect(); - scope_parts.sort_unstable(); - assert_eq!(scope_parts, vec!["read", "write"]); - } - - #[tokio::test] - async fn refresh_token_includes_resource_parameter() { - let (base_url, captured) = start_token_server().await; - - let mut manager = manager_with_metadata(Some(AuthorizationMetadata { - authorization_endpoint: format!("{}/authorize", base_url), - token_endpoint: format!("{}/token", base_url), - ..Default::default() - })) - .await; - manager.configure_client(test_client_config()).unwrap(); - - let stored = StoredCredentials { - client_id: "my-client".to_string(), - token_response: Some(make_token_response_with_refresh( - "old-token", - "my-refresh-token", - )), - granted_scopes: vec![], - token_received_at: Some(AuthorizationManager::now_epoch_secs()), - issuer: None, - }; - manager.credential_store.save(stored).await.unwrap(); - - manager.refresh_token().await.unwrap(); - - let body = captured.lock().unwrap().take().unwrap(); - let params: std::collections::HashMap<_, _> = url::form_urlencoded::parse(body.as_bytes()) - .into_owned() - .collect(); - assert_eq!( - params.get("resource").map(String::as_str), - Some("http://localhost/"), - "refresh requests must carry the RFC 8707 resource parameter, got body: {body}" - ); - } - - #[tokio::test] - async fn refresh_token_adds_offline_access_when_as_supports_it() { - let (base_url, captured) = start_token_server().await; - - let mut manager = manager_with_metadata(Some(AuthorizationMetadata { - authorization_endpoint: format!("{}/authorize", base_url), - token_endpoint: format!("{}/token", base_url), - scopes_supported: Some(vec!["read".to_string(), "offline_access".to_string()]), - ..Default::default() - })) - .await; - manager.configure_client(test_client_config()).unwrap(); - - let stored = StoredCredentials { - client_id: "my-client".to_string(), - token_response: Some(make_token_response_with_refresh( - "old-token", - "my-refresh-token", - )), - granted_scopes: vec!["read".to_string()], - token_received_at: Some(AuthorizationManager::now_epoch_secs()), - issuer: None, - }; - manager.credential_store.save(stored).await.unwrap(); - - manager.refresh_token().await.unwrap(); - - let body = captured.lock().unwrap().take().unwrap(); - let params: std::collections::HashMap<_, _> = url::form_urlencoded::parse(body.as_bytes()) - .into_owned() - .collect(); - let scope = params - .get("scope") - .expect("scope should be present in refresh request"); - let mut scope_parts: Vec<&str> = scope.split_whitespace().collect(); - scope_parts.sort_unstable(); - assert_eq!(scope_parts, vec!["offline_access", "read"]); - } - - #[tokio::test] - async fn refresh_token_omits_scope_when_granted_scopes_is_empty() { - let (base_url, captured) = start_token_server().await; - - let mut manager = manager_with_metadata(Some(AuthorizationMetadata { - authorization_endpoint: format!("{}/authorize", base_url), - token_endpoint: format!("{}/token", base_url), - ..Default::default() - })) - .await; - manager.configure_client(test_client_config()).unwrap(); - - let stored = StoredCredentials { - client_id: "my-client".to_string(), - token_response: Some(make_token_response_with_refresh( - "old-token", - "my-refresh-token", - )), - granted_scopes: vec![], - token_received_at: Some(AuthorizationManager::now_epoch_secs()), - issuer: None, - }; - manager.credential_store.save(stored).await.unwrap(); - - manager.refresh_token().await.unwrap(); - - let body = captured.lock().unwrap().take().unwrap(); - let params: std::collections::HashMap<_, _> = url::form_urlencoded::parse(body.as_bytes()) - .into_owned() - .collect(); - assert!( - !params.contains_key("scope"), - "scope should be absent when granted_scopes is empty, body: {body}" - ); - } - - #[tokio::test] - async fn refresh_token_preserves_existing_refresh_token_when_response_omits_it() { - use oauth2::TokenResponse; - // start_token_server returns a response without a refresh_token, matching - // an authorization server that does not rotate refresh tokens on refresh. - let (base_url, _captured) = start_token_server().await; - - let mut manager = manager_with_metadata(Some(AuthorizationMetadata { - authorization_endpoint: format!("{}/authorize", base_url), - token_endpoint: format!("{}/token", base_url), - ..Default::default() - })) - .await; - manager.configure_client(test_client_config()).unwrap(); - - let stored = StoredCredentials { - client_id: "my-client".to_string(), - token_response: Some(make_token_response_with_refresh( - "old-token", - "my-refresh-token", - )), - granted_scopes: vec![], - token_received_at: Some(AuthorizationManager::now_epoch_secs()), - issuer: None, - }; - manager.credential_store.save(stored).await.unwrap(); - - let result = manager.refresh_token().await.unwrap(); - assert_eq!( - result.refresh_token().map(|t| t.secret().as_str()), - Some("my-refresh-token"), - "returned response should keep the previous refresh token" - ); - - let reloaded = manager - .credential_store - .load() - .await - .unwrap() - .unwrap() - .token_response - .unwrap(); - assert_eq!( - reloaded.refresh_token().map(|t| t.secret().as_str()), - Some("my-refresh-token"), - "stored credentials should keep the previous refresh token" - ); - } - - #[tokio::test] - async fn refresh_token_replaces_refresh_token_when_response_includes_new_one() { - use axum::{Router, body::Body, http::Response, routing::post}; - use oauth2::TokenResponse; - - let app = Router::new().route( - "/token", - post(|| async { - Response::builder() - .status(200) - .header("content-type", "application/json") - .body(Body::from( - r#"{"access_token":"new-token","token_type":"Bearer","expires_in":3600,"refresh_token":"rotated-refresh-token"}"#, - )) - .unwrap() - }), - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - let base_url = format!("http://{}", addr); - - let mut manager = manager_with_metadata(Some(AuthorizationMetadata { - authorization_endpoint: format!("{}/authorize", base_url), - token_endpoint: format!("{}/token", base_url), - ..Default::default() - })) - .await; - manager.configure_client(test_client_config()).unwrap(); - - let stored = StoredCredentials { - client_id: "my-client".to_string(), - token_response: Some(make_token_response_with_refresh( - "old-token", - "my-refresh-token", - )), - granted_scopes: vec![], - token_received_at: Some(AuthorizationManager::now_epoch_secs()), - issuer: None, - }; - manager.credential_store.save(stored).await.unwrap(); - - let result = manager.refresh_token().await.unwrap(); - assert_eq!( - result.refresh_token().map(|t| t.secret().as_str()), - Some("rotated-refresh-token"), - "a rotated refresh token from the response should replace the old one" - ); - } -} diff --git a/vendor/rmcp/src/transport/child_process.rs b/vendor/rmcp/src/transport/child_process.rs deleted file mode 100644 index 6e19a0c3b..000000000 --- a/vendor/rmcp/src/transport/child_process.rs +++ /dev/null @@ -1,321 +0,0 @@ -use std::process::Stdio; - -use futures::future::Future; -use process_wrap::tokio::{ChildWrapper, CommandWrap}; -use tokio::process::{ChildStderr, ChildStdin, ChildStdout}; - -use super::{RxJsonRpcMessage, Transport, TxJsonRpcMessage, async_rw::AsyncRwTransport}; -use crate::RoleClient; - -const MAX_WAIT_ON_DROP_SECS: u64 = 3; -/// The parts of a child process. -type ChildProcessParts = ( - Box, - ChildStdout, - ChildStdin, - Option, -); - -/// Extract the stdio handles from a spawned child. -/// Returns `(child, stdout, stdin, stderr)` where `stderr` is `Some` only -/// if the process was spawned with `Stdio::piped()`. -#[inline] -fn child_process(mut child: Box) -> std::io::Result { - let child_stdin = match child.inner_mut().stdin().take() { - Some(stdin) => stdin, - None => return Err(std::io::Error::other("stdin was already taken")), - }; - let child_stdout = match child.inner_mut().stdout().take() { - Some(stdout) => stdout, - None => return Err(std::io::Error::other("stdout was already taken")), - }; - let child_stderr = child.inner_mut().stderr().take(); - Ok((child, child_stdout, child_stdin, child_stderr)) -} - -pub struct TokioChildProcess { - child: ChildWithCleanup, - transport: AsyncRwTransport, -} - -pub struct ChildWithCleanup { - inner: Option>, -} - -impl Drop for ChildWithCleanup { - fn drop(&mut self) { - // We should not use start_kill(), instead we should use kill() to avoid zombies - if let Some(mut inner) = self.inner.take() { - // We don't care about the result, just try to kill it - tokio::spawn(async move { - if let Err(e) = Box::into_pin(inner.kill()).await { - tracing::warn!("Error killing child process: {}", e); - } - }); - } - } -} - -impl TokioChildProcess { - /// Convenience: spawn with default `piped` stdio - pub fn new(command: impl Into) -> std::io::Result { - let (proc, _ignored) = TokioChildProcessBuilder::new(command).spawn()?; - Ok(proc) - } - - /// Builder entry-point allowing fine-grained stdio control. - pub fn builder(command: impl Into) -> TokioChildProcessBuilder { - TokioChildProcessBuilder::new(command) - } - - /// Get the process ID of the child process. - pub fn id(&self) -> Option { - self.child.inner.as_ref()?.id() - } - - /// Gracefully shutdown the child process - /// - /// This will first close the transport to the child process (the server), - /// and wait for the child process to exit normally with a timeout. - /// If the child process doesn't exit within the timeout, it will be killed. - pub async fn graceful_shutdown(&mut self) -> std::io::Result<()> { - if let Some(mut child) = self.child.inner.take() { - self.transport.close().await?; - - let wait_fut = child.wait(); - tokio::select! { - _ = tokio::time::sleep(std::time::Duration::from_secs(MAX_WAIT_ON_DROP_SECS)) => { - if let Err(e) = Box::into_pin(child.kill()).await { - tracing::warn!("Error killing child: {e}"); - return Err(e); - } - }, - res = wait_fut => { - match res { - Ok(status) => { - tracing::info!("Child exited gracefully {}", status); - } - Err(e) => { - tracing::warn!("Error waiting for child: {e}"); - return Err(e); - } - } - } - } - } - Ok(()) - } - - /// Take ownership of the inner child process - pub fn into_inner(mut self) -> Option> { - self.child.inner.take() - } -} - -/// Builder for `TokioChildProcess` allowing custom `Stdio` configuration. -pub struct TokioChildProcessBuilder { - cmd: CommandWrap, - stdin: Stdio, - stdout: Stdio, - stderr: Stdio, -} - -impl TokioChildProcessBuilder { - fn new(cmd: impl Into) -> Self { - Self { - cmd: cmd.into(), - stdin: Stdio::piped(), - stdout: Stdio::piped(), - stderr: Stdio::inherit(), - } - } - - /// Override the child stdin configuration. - pub fn stdin(mut self, io: impl Into) -> Self { - self.stdin = io.into(); - self - } - /// Override the child stdout configuration. - pub fn stdout(mut self, io: impl Into) -> Self { - self.stdout = io.into(); - self - } - /// Override the child stderr configuration. - pub fn stderr(mut self, io: impl Into) -> Self { - self.stderr = io.into(); - self - } - - /// Spawn the child process. Returns the transport plus an optional captured stderr handle. - pub fn spawn(mut self) -> std::io::Result<(TokioChildProcess, Option)> { - self.cmd - .command_mut() - .stdin(self.stdin) - .stdout(self.stdout) - .stderr(self.stderr); - - let (child, stdout, stdin, stderr_opt) = child_process(self.cmd.spawn()?)?; - - let transport = AsyncRwTransport::new(stdout, stdin); - let proc = TokioChildProcess { - child: ChildWithCleanup { inner: Some(child) }, - transport, - }; - Ok((proc, stderr_opt)) - } -} - -impl Transport for TokioChildProcess { - type Error = std::io::Error; - - fn send( - &mut self, - item: TxJsonRpcMessage, - ) -> impl Future> + Send + 'static { - self.transport.send(item) - } - - fn receive(&mut self) -> impl Future>> + Send { - self.transport.receive() - } - - fn close(&mut self) -> impl Future> + Send { - self.graceful_shutdown() - } -} - -pub trait ConfigureCommandExt { - fn configure(self, f: impl FnOnce(&mut Self)) -> Self; -} - -impl ConfigureCommandExt for tokio::process::Command { - fn configure(mut self, f: impl FnOnce(&mut Self)) -> Self { - f(&mut self); - self - } -} - -/// Resolve the absolute path to an executable using the system `PATH`, -/// then return a [`tokio::process::Command`] pointing at it. -/// -/// This is especially useful on Windows where `.cmd` / `.exe` shim scripts -/// (e.g. `npx.cmd`) are not reliably found by [`tokio::process::Command`] -/// without a fully-qualified path. -/// -/// # Example -/// ```rust,no_run -/// use rmcp::transport::{which_command, ConfigureCommandExt}; -/// -/// # fn example() -> std::io::Result<()> { -/// let cmd = which_command("npx")? -/// .configure(|cmd| { -/// cmd.arg("-y").arg("@modelcontextprotocol/server-everything"); -/// }); -/// # Ok(()) -/// # } -/// ``` -#[cfg(feature = "which-command")] -pub fn which_command( - name: impl AsRef, -) -> std::io::Result { - let resolved = which::which(name.as_ref()) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, e))?; - Ok(tokio::process::Command::new(resolved)) -} - -#[cfg(feature = "which-command")] -#[cfg(test)] -mod tests_which { - #[test] - fn which_command_resolves_known_binary() { - // `ls` exists on every Unix system, `cmd` on Windows - #[cfg(unix)] - let result = super::which_command("ls"); - #[cfg(windows)] - let result = super::which_command("cmd"); - - assert!(result.is_ok()); - } - - #[test] - fn which_command_fails_for_nonexistent() { - let result = super::which_command("this_binary_definitely_does_not_exist_12345"); - assert!(result.is_err()); - assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::NotFound); - } -} - -#[cfg(unix)] -#[cfg(test)] -mod tests { - use tokio::process::Command; - - use super::*; - - #[tokio::test] - async fn test_tokio_child_process_drop() { - let r = TokioChildProcess::new(Command::new("sleep").configure(|cmd| { - cmd.arg("30"); - })); - assert!(r.is_ok()); - let child_process = r.unwrap(); - let id = child_process.id(); - assert!(id.is_some()); - let id = id.unwrap(); - // Drop the child process - drop(child_process); - // Wait a moment to allow the cleanup task to run - tokio::time::sleep(std::time::Duration::from_secs(MAX_WAIT_ON_DROP_SECS + 1)).await; - // Check if the process is still running - let status = Command::new("ps") - .arg("-p") - .arg(id.to_string()) - .status() - .await; - match status { - Ok(status) => { - assert!( - !status.success(), - "Process with PID {} is still running", - id - ); - } - Err(e) => { - panic!("Failed to check process status: {}", e); - } - } - } - - #[tokio::test] - async fn test_tokio_child_process_graceful_shutdown() { - let r = TokioChildProcess::new(Command::new("sleep").configure(|cmd| { - cmd.arg("30"); - })); - assert!(r.is_ok()); - let mut child_process = r.unwrap(); - let id = child_process.id(); - assert!(id.is_some()); - let id = id.unwrap(); - child_process.graceful_shutdown().await.unwrap(); - // Wait a moment to allow the cleanup task to run - tokio::time::sleep(std::time::Duration::from_secs(MAX_WAIT_ON_DROP_SECS + 1)).await; - // Check if the process is still running - let status = Command::new("ps") - .arg("-p") - .arg(id.to_string()) - .status() - .await; - match status { - Ok(status) => { - assert!( - !status.success(), - "Process with PID {} is still running", - id - ); - } - Err(e) => { - panic!("Failed to check process status: {}", e); - } - } - } -} diff --git a/vendor/rmcp/src/transport/common.rs b/vendor/rmcp/src/transport/common.rs deleted file mode 100644 index 8cf00c02f..000000000 --- a/vendor/rmcp/src/transport/common.rs +++ /dev/null @@ -1,22 +0,0 @@ -#[cfg(feature = "transport-streamable-http-server")] -pub mod server_side_http; - -pub mod http_header; - -#[cfg(any(feature = "client-side-sse", feature = "server-side-http"))] -pub mod mcp_headers; - -#[cfg(feature = "__reqwest")] -mod reqwest; - -// Note: This module provides SSE stream parsing and auto-reconnect utilities. -// It's used by the streamable HTTP client (which receives SSE-formatted responses), -// not the removed SSE transport. The name is historical. -#[cfg(feature = "client-side-sse")] -pub mod client_side_sse; - -#[cfg(feature = "auth")] -pub mod auth; - -#[cfg(all(unix, feature = "transport-streamable-http-client-unix-socket"))] -pub mod unix_socket; diff --git a/vendor/rmcp/src/transport/common/auth.rs b/vendor/rmcp/src/transport/common/auth.rs deleted file mode 100644 index 6068f4e95..000000000 --- a/vendor/rmcp/src/transport/common/auth.rs +++ /dev/null @@ -1,2 +0,0 @@ -#[cfg(feature = "transport-streamable-http-client")] -mod streamable_http_client; diff --git a/vendor/rmcp/src/transport/common/auth/streamable_http_client.rs b/vendor/rmcp/src/transport/common/auth/streamable_http_client.rs deleted file mode 100644 index 2053920d2..000000000 --- a/vendor/rmcp/src/transport/common/auth/streamable_http_client.rs +++ /dev/null @@ -1,210 +0,0 @@ -use std::collections::HashMap; - -use http::{HeaderName, HeaderValue}; -use tracing::debug; - -use crate::transport::{ - auth::{AuthClient, AuthError}, - streamable_http_client::{StreamableHttpClient, StreamableHttpError}, -}; - -impl AuthClient -where - C: StreamableHttpClient + Send + Sync, -{ - /// Run `call` with a token when one is available, reacting to the - /// server's auth verdict instead of requiring credentials up front: - /// - /// - no usable credentials → the request goes out unauthenticated, and a - /// 401 propagates as [`StreamableHttpError::AuthRequired`] carrying the - /// `WWW-Authenticate` challenge for the caller to authorize with; - /// - a token the server rejects (e.g. revoked) → one silent refresh, one - /// retry, then the challenge propagates. - async fn call_reacting_to_challenges( - &self, - auth_token: Option, - call: F, - ) -> Result> - where - F: Fn(Option) -> Fut, - Fut: Future>>, - { - // Missing credentials are not an error in the reactive model: the - // request goes out unauthenticated and the server's 401 challenge - // drives authorization. - let auth_token = match auth_token { - None => match self.get_access_token().await { - Ok(token) => Some(token), - Err(AuthError::AuthorizationRequired) => None, - Err(error) => return Err(error.into()), - }, - token => token, - }; - match call(auth_token.clone()).await { - Err(StreamableHttpError::AuthRequired(challenge)) => { - // One silent recovery attempt: refresh the rejected token and - // retry only when the refresh actually produced a new one. - let Some(sent_token) = auth_token else { - return Err(StreamableHttpError::AuthRequired(challenge)); - }; - let refreshed = { - let manager = self.auth_manager.lock().await; - manager.try_refresh_or_reauth().await - }; - match refreshed { - Ok(fresh_token) if fresh_token != sent_token => call(Some(fresh_token)).await, - Ok(_) => Err(StreamableHttpError::AuthRequired(challenge)), - Err(error) => { - debug!("token refresh after server rejection failed: {error}"); - Err(StreamableHttpError::AuthRequired(challenge)) - } - } - } - result => result, - } - } -} - -impl StreamableHttpClient for AuthClient -where - C: StreamableHttpClient + Send + Sync, -{ - type Error = C::Error; - - async fn delete_session( - &self, - uri: std::sync::Arc, - session_id: std::sync::Arc, - auth_token: Option, - custom_headers: HashMap, - ) -> Result<(), crate::transport::streamable_http_client::StreamableHttpError> - { - self.call_reacting_to_challenges(auth_token, |token| { - let uri = uri.clone(); - let session_id = session_id.clone(); - let custom_headers = custom_headers.clone(); - async move { - self.http_client - .delete_session(uri, session_id, token, custom_headers) - .await - } - }) - .await - } - - async fn get_stream( - &self, - uri: std::sync::Arc, - session_id: Option>, - last_event_id: Option, - auth_token: Option, - custom_headers: HashMap, - ) -> Result< - futures::stream::BoxStream<'static, Result>, - crate::transport::streamable_http_client::StreamableHttpError, - > { - self.call_reacting_to_challenges(auth_token, |token| { - let uri = uri.clone(); - let session_id = session_id.clone(); - let last_event_id = last_event_id.clone(); - let custom_headers = custom_headers.clone(); - async move { - self.http_client - .get_stream(uri, session_id, last_event_id, token, custom_headers) - .await - } - }) - .await - } - - async fn get_stream_with_max_sse_event_size( - &self, - uri: std::sync::Arc, - session_id: Option>, - last_event_id: Option, - auth_token: Option, - custom_headers: HashMap, - max_sse_event_size: usize, - ) -> Result< - futures::stream::BoxStream<'static, Result>, - crate::transport::streamable_http_client::StreamableHttpError, - > { - self.call_reacting_to_challenges(auth_token, |token| { - let uri = uri.clone(); - let session_id = session_id.clone(); - let last_event_id = last_event_id.clone(); - let custom_headers = custom_headers.clone(); - async move { - self.http_client - .get_stream_with_max_sse_event_size( - uri, - session_id, - last_event_id, - token, - custom_headers, - max_sse_event_size, - ) - .await - } - }) - .await - } - - async fn post_message( - &self, - uri: std::sync::Arc, - message: crate::model::ClientJsonRpcMessage, - session_id: Option>, - auth_token: Option, - custom_headers: HashMap, - ) -> Result< - crate::transport::streamable_http_client::StreamableHttpPostResponse, - StreamableHttpError, - > { - self.call_reacting_to_challenges(auth_token, |token| { - let uri = uri.clone(); - let message = message.clone(); - let session_id = session_id.clone(); - let custom_headers = custom_headers.clone(); - async move { - self.http_client - .post_message(uri, message, session_id, token, custom_headers) - .await - } - }) - .await - } - - async fn post_message_with_max_sse_event_size( - &self, - uri: std::sync::Arc, - message: crate::model::ClientJsonRpcMessage, - session_id: Option>, - auth_token: Option, - custom_headers: HashMap, - max_sse_event_size: usize, - ) -> Result< - crate::transport::streamable_http_client::StreamableHttpPostResponse, - StreamableHttpError, - > { - self.call_reacting_to_challenges(auth_token, |token| { - let uri = uri.clone(); - let message = message.clone(); - let session_id = session_id.clone(); - let custom_headers = custom_headers.clone(); - async move { - self.http_client - .post_message_with_max_sse_event_size( - uri, - message, - session_id, - token, - custom_headers, - max_sse_event_size, - ) - .await - } - }) - .await - } -} diff --git a/vendor/rmcp/src/transport/common/client_side_sse.rs b/vendor/rmcp/src/transport/common/client_side_sse.rs deleted file mode 100644 index 4ed05b44f..000000000 --- a/vendor/rmcp/src/transport/common/client_side_sse.rs +++ /dev/null @@ -1,738 +0,0 @@ -use std::{ - pin::Pin, - sync::Arc, - task::{Poll, ready}, - time::Duration, -}; - -use bytes::Bytes; -use futures::{Stream, StreamExt, stream::BoxStream}; -use sse_stream::{Error as SseError, Sse, SseStream}; -use thiserror::Error; - -use crate::model::ServerJsonRpcMessage; - -pub type BoxedSseResponse = BoxStream<'static, Result>; - -/// Maximum raw size of one SSE event accepted from a remote server. -pub(crate) const DEFAULT_MAX_SSE_EVENT_SIZE: usize = 16 * 1024 * 1024; - -#[derive(Debug, Error)] -enum BoundedSseStreamError { - #[error(transparent)] - Source(Box), - #[error("SSE event exceeded the maximum size of {max_size} bytes")] - EventTooLarge { max_size: usize }, -} - -#[derive(Debug)] -struct SseEventSizeLimiter { - max_size: usize, - retained_size: usize, - line_size: usize, - line_is_comment: bool, - previous_was_cr: bool, -} - -impl SseEventSizeLimiter { - fn new(max_size: usize) -> Self { - Self { - max_size, - retained_size: 0, - line_size: 0, - line_is_comment: false, - previous_was_cr: false, - } - } - - fn observe(&mut self, chunk: &[u8]) -> Result<(), ()> { - for &byte in chunk { - if self.previous_was_cr { - self.previous_was_cr = false; - if byte == b'\n' { - continue; - } - } - - match byte { - b'\r' => { - self.finish_line()?; - self.previous_was_cr = true; - } - b'\n' => self.finish_line()?, - _ => { - if self.line_size == 0 { - self.line_is_comment = byte == b':'; - } - self.line_size = self.line_size.saturating_add(1); - self.check_limit()?; - } - } - } - Ok(()) - } - - fn finish_line(&mut self) -> Result<(), ()> { - if self.line_size == 0 { - self.retained_size = 0; - } else if !self.line_is_comment { - // The SSE parser inserts a newline when joining multiple data fields. - self.retained_size = self - .retained_size - .saturating_add(self.line_size) - .saturating_add(1); - } - self.line_size = 0; - self.line_is_comment = false; - self.check_limit() - } - - fn check_limit(&self) -> Result<(), ()> { - if self.retained_size.saturating_add(self.line_size) > self.max_size { - Err(()) - } else { - Ok(()) - } - } -} - -pin_project_lite::pin_project! { - struct BoundedSseByteStream { - #[pin] - inner: S, - limiter: SseEventSizeLimiter, - failed: bool, - } -} - -impl Stream for BoundedSseByteStream -where - S: Stream>, - E: std::error::Error + Send + Sync + 'static, -{ - type Item = Result; - - fn poll_next( - self: Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> Poll> { - let mut this = self.project(); - if *this.failed { - return Poll::Ready(None); - } - - match ready!(this.inner.as_mut().poll_next(cx)) { - Some(Ok(chunk)) => { - if this.limiter.observe(&chunk).is_err() { - *this.failed = true; - Poll::Ready(Some(Err(BoundedSseStreamError::EventTooLarge { - max_size: this.limiter.max_size, - }))) - } else { - Poll::Ready(Some(Ok(chunk))) - } - } - Some(Err(error)) => { - *this.failed = true; - Poll::Ready(Some(Err(BoundedSseStreamError::Source(Box::new(error))))) - } - None => Poll::Ready(None), - } - } -} - -pub(crate) fn bounded_sse_stream(stream: S, max_event_size: usize) -> BoxedSseResponse -where - S: Stream> + Send + 'static, - E: std::error::Error + Send + Sync + 'static, -{ - let stream = BoundedSseByteStream { - inner: stream, - limiter: SseEventSizeLimiter::new(max_event_size), - failed: false, - }; - SseStream::from_bytes_stream(stream).boxed() -} - -fn is_event_too_large_error(error: &SseError) -> bool { - matches!( - error, - SseError::Body(error) - if matches!( - error.downcast_ref::(), - Some(BoundedSseStreamError::EventTooLarge { .. }) - ) - ) -} - -pub trait SseRetryPolicy: std::fmt::Debug + Send + Sync { - fn retry(&self, current_times: usize) -> Option; -} - -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct FixedInterval { - pub max_times: Option, - pub duration: Duration, -} - -impl SseRetryPolicy for FixedInterval { - fn retry(&self, current_times: usize) -> Option { - if let Some(max_times) = self.max_times - && current_times >= max_times - { - return None; - } - Some(self.duration) - } -} - -impl FixedInterval { - pub const DEFAULT_MIN_DURATION: Duration = Duration::from_millis(1000); -} - -impl Default for FixedInterval { - fn default() -> Self { - Self { - max_times: None, - duration: Self::DEFAULT_MIN_DURATION, - } - } -} - -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct ExponentialBackoff { - pub max_times: Option, - pub base_duration: Duration, -} - -impl ExponentialBackoff { - pub const DEFAULT_DURATION: Duration = Duration::from_millis(1000); -} - -impl Default for ExponentialBackoff { - fn default() -> Self { - Self { - max_times: None, - base_duration: Self::DEFAULT_DURATION, - } - } -} - -impl SseRetryPolicy for ExponentialBackoff { - fn retry(&self, current_times: usize) -> Option { - if let Some(max_times) = self.max_times - && current_times >= max_times - { - return None; - } - Some(self.base_duration * (2u32.pow(current_times as u32))) - } -} - -#[derive(Debug, Clone, Copy, Default)] -#[non_exhaustive] -pub struct NeverRetry; - -impl SseRetryPolicy for NeverRetry { - fn retry(&self, _current_times: usize) -> Option { - None - } -} - -#[derive(Debug, Default)] -pub struct NeverReconnect { - error: Option, -} - -impl SseStreamReconnect for NeverReconnect { - type Error = E; - type Future = futures::future::Ready>; - fn retry_connection(&mut self, _last_event_id: Option<&str>) -> Self::Future { - futures::future::ready(Err(self.error.take().expect("should not be called again"))) - } -} - -/// Abstraction for SSE reconnection logic. Implementors can hook into -/// [`handle_control_event`](Self::handle_control_event) to consume control -/// frames (e.g. `event: endpoint`) that arrive when a server restarts an SSE -/// stream. The default implementation is a no-op, keeping existing behaviour -/// intact. -pub(crate) trait SseStreamReconnect { - type Error: std::error::Error; - type Future: Future> + Send; - fn retry_connection(&mut self, last_event_id: Option<&str>) -> Self::Future; - fn handle_control_event(&mut self, _event: &Sse) -> Result<(), Self::Error> { - Ok(()) - } - fn handle_stream_error( - &mut self, - error: &(dyn std::error::Error + 'static), - last_event_id: Option<&str>, - ) { - if let Some(id) = last_event_id { - tracing::warn!(%id, "sse stream error: {error}"); - } else { - tracing::warn!("sse stream error: {error}"); - } - } - fn map_fatal_stream_error(&mut self, error: SseError) -> Option { - tracing::warn!("fatal sse stream error: {error}"); - None - } -} - -pin_project_lite::pin_project! { - pub(crate) struct SseAutoReconnectStream - where R: SseStreamReconnect - { - retry_policy: Arc, - reconnect_only_after_event_id: bool, - last_event_id: Option, - server_retry_interval: Option, - connector: R, - #[pin] - state: SseAutoReconnectStreamState, - } -} - -impl SseAutoReconnectStream { - pub fn new( - stream: BoxedSseResponse, - connector: R, - retry_policy: Arc, - ) -> Self { - Self { - retry_policy, - reconnect_only_after_event_id: false, - last_event_id: None, - server_retry_interval: None, - connector, - state: SseAutoReconnectStreamState::Connected { stream }, - } - } - - pub fn new_after_event_id( - stream: BoxedSseResponse, - connector: R, - retry_policy: Arc, - ) -> Self { - Self { - retry_policy, - reconnect_only_after_event_id: true, - last_event_id: None, - server_retry_interval: None, - connector, - state: SseAutoReconnectStreamState::Connected { stream }, - } - } -} - -impl SseAutoReconnectStream> { - #[allow(dead_code)] - pub(crate) fn never_reconnect(stream: BoxedSseResponse, error_when_reconnect: E) -> Self { - Self { - retry_policy: Arc::new(NeverRetry), - reconnect_only_after_event_id: false, - last_event_id: None, - server_retry_interval: None, - connector: NeverReconnect { - error: Some(error_when_reconnect), - }, - state: SseAutoReconnectStreamState::Connected { stream }, - } - } -} - -pin_project_lite::pin_project! { - #[project = SseAutoReconnectStreamStateProj] - #[non_exhaustive] - pub enum SseAutoReconnectStreamState { - Connected { - #[pin] - stream: BoxedSseResponse, - }, - Retrying { - retry_times: usize, - #[pin] - retrying: F, - }, - WaitingNextRetry { - #[pin] - sleep: tokio::time::Sleep, - retry_times: usize, - }, - Terminated, - } -} - -impl Stream for SseAutoReconnectStream -where - R: SseStreamReconnect, -{ - type Item = Result; - fn poll_next( - mut self: Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> Poll> { - let mut this = self.as_mut().project(); - // let this_state = this.state.as_mut().project() - let state = this.state.as_mut().project(); - let next_state = match state { - SseAutoReconnectStreamStateProj::Connected { stream } => { - match ready!(stream.poll_next(cx)) { - Some(Ok(sse)) => { - if let Some(new_server_retry) = sse.retry { - *this.server_retry_interval = - Some(Duration::from_millis(new_server_retry)); - } - if let Some(ref event_id) = sse.id { - *this.last_event_id = Some(event_id.clone()); - } - // Only treat blank/`message` events as JSON-RPC payloads. - // Other control frames (endpoint, ping, etc.) are passed to - // the reconnection handler. - let is_message_event = - matches!(sse.event.as_deref(), None | Some("") | Some("message")); - if !is_message_event { - match this.connector.handle_control_event(&sse) { - Ok(()) => return self.poll_next(cx), - Err(e) => { - this.state.set(SseAutoReconnectStreamState::Terminated); - return Poll::Ready(Some(Err(e))); - } - } - } - if let Some(data) = sse.data { - match serde_json::from_str::(&data) { - Err(e) => { - // Downgrade to debug to avoid noisy logs when servers emit - // non-JSON payloads as message frames. Include last_event_id - // to aid troubleshooting while keeping default behaviour. - let last_id = this.last_event_id.as_deref().unwrap_or(""); - tracing::debug!(last_event_id=%last_id, "failed to deserialize server message: {e}"); - return self.poll_next(cx); - } - Ok(message) => { - return Poll::Ready(Some(Ok(message))); - } - }; - } else { - return self.poll_next(cx); - } - } - Some(Err(e)) => { - if is_event_too_large_error(&e) { - this.state.set(SseAutoReconnectStreamState::Terminated); - return Poll::Ready(this.connector.map_fatal_stream_error(e).map(Err)); - } - if *this.reconnect_only_after_event_id && this.last_event_id.is_none() { - this.state.set(SseAutoReconnectStreamState::Terminated); - return Poll::Ready(this.connector.map_fatal_stream_error(e).map(Err)); - } - this.connector - .handle_stream_error(&e, this.last_event_id.as_deref()); - let retrying = this - .connector - .retry_connection(this.last_event_id.as_deref()); - SseAutoReconnectStreamState::Retrying { - retry_times: 0, - retrying, - } - } - None => { - if *this.reconnect_only_after_event_id && this.last_event_id.is_none() { - tracing::debug!( - "sse response ended before an event ID was received; cannot resume" - ); - this.state.set(SseAutoReconnectStreamState::Terminated); - return Poll::Ready(None); - } - // Per SEP-1699, a graceful stream close is - // reconnectable. If the server sent a `retry` field - // we MUST wait that long before reconnecting. - let interval = this - .server_retry_interval - .take() - .or_else(|| this.retry_policy.retry(0)); - if let Some(interval) = interval { - tracing::debug!(?interval, "sse stream ended gracefully, reconnecting"); - SseAutoReconnectStreamState::WaitingNextRetry { - sleep: tokio::time::sleep(interval), - retry_times: 0, - } - } else { - tracing::debug!("sse stream terminated, no reconnect policy"); - return Poll::Ready(None); - } - } - } - } - SseAutoReconnectStreamStateProj::Retrying { - retry_times, - retrying, - } => { - let retry_result = ready!(retrying.poll(cx)); - match retry_result { - Ok(new_stream) => SseAutoReconnectStreamState::Connected { stream: new_stream }, - Err(e) => { - tracing::debug!("retry sse stream error: {e}"); - *retry_times += 1; - if let Some(interval) = this.retry_policy.retry(*retry_times) { - let interval = this - .server_retry_interval - .map(|server_retry_interval| server_retry_interval.max(interval)) - .unwrap_or(interval); - let sleep = tokio::time::sleep(interval); - SseAutoReconnectStreamState::WaitingNextRetry { - sleep, - retry_times: *retry_times, - } - } else { - tracing::error!("sse stream error: {e}, max retry times reached"); - this.state.set(SseAutoReconnectStreamState::Terminated); - return Poll::Ready(Some(Err(e))); - } - } - } - } - SseAutoReconnectStreamStateProj::WaitingNextRetry { sleep, retry_times } => { - ready!(sleep.poll(cx)); - let retrying = this - .connector - .retry_connection(this.last_event_id.as_deref()); - let retry_times = *retry_times; - SseAutoReconnectStreamState::Retrying { - retry_times, - retrying, - } - } - SseAutoReconnectStreamStateProj::Terminated => { - return Poll::Ready(None); - } - }; - // update the state - this.state.set(next_state); - self.poll_next(cx) - } -} - -#[cfg(test)] -mod tests { - use std::sync::atomic::{AtomicUsize, Ordering}; - - use super::*; - - #[derive(Debug, Error)] - enum TestReconnectError { - #[error("SSE stream error: {0}")] - Sse(SseError), - #[error("unexpected reconnect")] - Reconnect, - } - - struct CountingReconnect { - attempts: Arc, - } - - impl SseStreamReconnect for CountingReconnect { - type Error = TestReconnectError; - type Future = futures::future::Ready>; - - fn retry_connection(&mut self, _last_event_id: Option<&str>) -> Self::Future { - self.attempts.fetch_add(1, Ordering::Relaxed); - futures::future::ready(Err(TestReconnectError::Reconnect)) - } - - fn map_fatal_stream_error(&mut self, error: SseError) -> Option { - Some(TestReconnectError::Sse(error)) - } - } - - #[tokio::test] - async fn bounded_sse_stream_rejects_unterminated_event_over_limit() { - let source = futures::stream::iter([ - Ok::<_, std::io::Error>(Bytes::from_static(b"data: aaaaa")), - Ok(Bytes::from_static(b"aaaaaa")), - ]); - let mut stream = bounded_sse_stream(source, 16); - - let error = stream.next().await.unwrap().unwrap_err(); - - assert!( - error.to_string().contains("maximum size of 16 bytes"), - "unexpected error: {error}" - ); - } - - #[tokio::test] - async fn bounded_sse_stream_resets_limit_after_event_terminator() { - let source = futures::stream::iter([ - Ok::<_, std::io::Error>(Bytes::from_static(b"data: a\r")), - Ok(Bytes::from_static(b"\n\r\ndata: b\n\n")), - ]); - let mut stream = bounded_sse_stream(source, 8); - - let first = stream.next().await.unwrap().unwrap(); - let second = stream.next().await.unwrap().unwrap(); - - assert_eq!( - (first.data.as_deref(), second.data.as_deref()), - (Some("a"), Some("b")) - ); - } - - #[tokio::test] - async fn bounded_sse_stream_passes_event_at_exact_limit() { - let source = - futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static(b"data: ab\n\n"))]); - let mut stream = bounded_sse_stream(source, 9); - - let event = stream.next().await.unwrap().unwrap(); - assert_eq!(event.data.as_deref(), Some("ab")); - } - - #[tokio::test] - async fn bounded_sse_stream_rejects_oversize_split_across_many_chunks() { - let source = futures::stream::iter( - std::iter::repeat_with(|| Ok::<_, std::io::Error>(Bytes::from_static(b"data: x"))) - .take(20), - ); - let mut stream = bounded_sse_stream(source, 32); - - let mut found_error = false; - while let Some(item) = stream.next().await { - if item.is_err() { - found_error = true; - break; - } - } - assert!(found_error, "expected oversize error"); - } - - #[tokio::test] - async fn bounded_sse_stream_handles_crlf_split_across_chunks() { - let source = futures::stream::iter([ - Ok::<_, std::io::Error>(Bytes::from_static(b"data: hello\r")), - Ok(Bytes::from_static(b"\n\ndata: world\n\n")), - ]); - let mut stream = bounded_sse_stream(source, 64); - - let first = stream.next().await.unwrap().unwrap(); - let second = stream.next().await.unwrap().unwrap(); - - assert_eq!(first.data.as_deref(), Some("hello")); - assert_eq!(second.data.as_deref(), Some("world")); - } - - #[tokio::test] - async fn bounded_sse_stream_discards_completed_comment_lines_from_limit() { - let source = futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static( - b": ping\n: ping\n: ping\n", - ))]); - let mut stream = bounded_sse_stream(source, 6); - - assert!(stream.next().await.is_none()); - } - - #[tokio::test] - async fn bounded_sse_stream_comments_do_not_reset_accumulated_data() { - let source = futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static( - b"data: a\n: ping\ndata: b\n", - ))]); - let mut stream = bounded_sse_stream(source, 14); - - assert!(stream.next().await.unwrap().is_err()); - } - - #[tokio::test] - async fn bounded_sse_stream_counts_multiline_data_join_newlines() { - let source = futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static( - b"data: aaa\ndata: bbb\n\n", - ))]); - let mut stream = bounded_sse_stream(source, 18); - - let error = stream.next().await.unwrap().unwrap_err(); - assert!( - error.to_string().contains("maximum size"), - "unexpected error: {error}" - ); - } - - #[tokio::test] - async fn bounded_sse_stream_propagates_source_error() { - let source = futures::stream::iter([Err::(std::io::Error::new( - std::io::ErrorKind::ConnectionReset, - "connection reset", - ))]); - let mut stream = bounded_sse_stream(source, 1024); - - let error = stream.next().await.unwrap().unwrap_err(); - assert!( - error.to_string().contains("connection reset"), - "unexpected error: {error}" - ); - } - - #[tokio::test] - async fn is_event_too_large_error_detects_oversize() { - let source = futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from(vec![b'A'; 100]))]); - let mut stream = bounded_sse_stream(source, 8); - - let error = stream.next().await.unwrap().unwrap_err(); - assert!(is_event_too_large_error(&error)); - } - - #[tokio::test] - async fn is_event_too_large_error_rejects_other_errors() { - let source = - futures::stream::iter([Err::(std::io::Error::other("something else"))]); - let mut stream = bounded_sse_stream(source, 1024); - - let error = stream.next().await.unwrap().unwrap_err(); - assert!(!is_event_too_large_error(&error)); - } - - #[tokio::test] - async fn oversized_event_returns_error_without_reconnecting() { - let source = futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from(vec![b'A'; 100]))]); - let attempts = Arc::new(AtomicUsize::new(0)); - let connector = CountingReconnect { - attempts: attempts.clone(), - }; - let stream = SseAutoReconnectStream::new( - bounded_sse_stream(source, 8), - connector, - Arc::new(NeverRetry), - ); - let mut stream = std::pin::pin!(stream); - - let result = stream.next().await; - - assert!( - matches!(result, Some(Err(TestReconnectError::Sse(_)))) - && attempts.load(Ordering::Relaxed) == 0 - ); - } - - #[tokio::test] - async fn response_without_event_id_does_not_reconnect() { - let attempts = Arc::new(AtomicUsize::new(0)); - let connector = CountingReconnect { - attempts: attempts.clone(), - }; - let stream = SseAutoReconnectStream::new_after_event_id( - futures::stream::empty().boxed(), - connector, - Arc::new(FixedInterval { - max_times: Some(1), - duration: Duration::ZERO, - }), - ); - let mut stream = std::pin::pin!(stream); - - assert!(stream.next().await.is_none()); - assert_eq!(attempts.load(Ordering::Relaxed), 0); - } -} diff --git a/vendor/rmcp/src/transport/common/http_header.rs b/vendor/rmcp/src/transport/common/http_header.rs deleted file mode 100644 index 6c2abbc8d..000000000 --- a/vendor/rmcp/src/transport/common/http_header.rs +++ /dev/null @@ -1,140 +0,0 @@ -pub const HEADER_SESSION_ID: &str = "Mcp-Session-Id"; -pub const HEADER_LAST_EVENT_ID: &str = "Last-Event-Id"; -pub const HEADER_MCP_PROTOCOL_VERSION: &str = "MCP-Protocol-Version"; -pub const EVENT_STREAM_MIME_TYPE: &str = "text/event-stream"; -pub const JSON_MIME_TYPE: &str = "application/json"; - -// SEP-2243 standard headers, gated on protocol version >= 2026-07-28. -pub const HEADER_MCP_METHOD: &str = "Mcp-Method"; -pub const HEADER_MCP_NAME: &str = "Mcp-Name"; -pub const HEADER_MCP_PARAM_PREFIX: &str = "Mcp-Param-"; - -/// Sentinel wrapping a Base64-encoded SEP-2243 header value (`=?base64??=`). -pub const BASE64_HEADER_PREFIX: &str = "=?base64?"; -pub const BASE64_HEADER_SUFFIX: &str = "?="; - -/// Reserved headers that must not be overridden by user-supplied custom headers. -/// `MCP-Protocol-Version` is in this list but is allowed through because the worker -/// injects it after initialization. -#[allow(dead_code)] -pub(crate) const RESERVED_HEADERS: &[&str] = &[ - "accept", - HEADER_SESSION_ID, - HEADER_MCP_PROTOCOL_VERSION, // allowed through by validate_custom_header; worker injects it post-init - HEADER_LAST_EVENT_ID, -]; - -/// Checks whether a custom header name is allowed. -/// Returns `Ok(())` if allowed, `Err(name)` if rejected as reserved. -/// `MCP-Protocol-Version` is reserved but allowed through (the worker injects it post-init). -#[cfg(feature = "client-side-sse")] -pub(crate) fn validate_custom_header(name: &http::HeaderName) -> Result<(), String> { - if RESERVED_HEADERS - .iter() - .any(|&r| name.as_str().eq_ignore_ascii_case(r)) - { - if name - .as_str() - .eq_ignore_ascii_case(HEADER_MCP_PROTOCOL_VERSION) - { - return Ok(()); - } - return Err(name.to_string()); - } - Ok(()) -} - -/// Extracts the `scope=` parameter from a `WWW-Authenticate` header value. -/// Handles both quoted (`scope="files:read files:write"`) and unquoted (`scope=read:data`) forms. -#[cfg(feature = "client-side-sse")] -pub(crate) fn extract_scope_from_header(header: &str) -> Option { - let header_lowercase = header.to_ascii_lowercase(); - let scope_key = "scope="; - - if let Some(pos) = header_lowercase.find(scope_key) { - let start = pos + scope_key.len(); - let value_slice = &header[start..]; - - if let Some(stripped) = value_slice.strip_prefix('"') { - if let Some(end_quote) = stripped.find('"') { - return Some(stripped[..end_quote].to_string()); - } - } else { - let end = value_slice - .find(|c: char| c == ',' || c == ';' || c.is_whitespace()) - .unwrap_or(value_slice.len()); - if end > 0 { - return Some(value_slice[..end].to_string()); - } - } - } - - None -} - -#[cfg(test)] -mod tests { - #[cfg(feature = "client-side-sse")] - use super::*; - - #[cfg(feature = "client-side-sse")] - #[test] - fn extract_scope_quoted() { - let header = r#"Bearer error="insufficient_scope", scope="files:read files:write""#; - assert_eq!( - extract_scope_from_header(header), - Some("files:read files:write".to_string()) - ); - } - - #[cfg(feature = "client-side-sse")] - #[test] - fn extract_scope_unquoted() { - let header = r#"Bearer scope=read:data, error="insufficient_scope""#; - assert_eq!( - extract_scope_from_header(header), - Some("read:data".to_string()) - ); - } - - #[cfg(feature = "client-side-sse")] - #[test] - fn extract_scope_missing() { - let header = r#"Bearer error="invalid_token""#; - assert_eq!(extract_scope_from_header(header), None); - } - - #[cfg(feature = "client-side-sse")] - #[test] - fn extract_scope_empty_header() { - assert_eq!(extract_scope_from_header("Bearer"), None); - } - - #[cfg(feature = "client-side-sse")] - #[test] - fn validate_rejects_reserved_accept() { - let name = http::HeaderName::from_static("accept"); - assert!(validate_custom_header(&name).is_err()); - } - - #[cfg(feature = "client-side-sse")] - #[test] - fn validate_rejects_reserved_session_id() { - let name = http::HeaderName::from_static("mcp-session-id"); - assert!(validate_custom_header(&name).is_err()); - } - - #[cfg(feature = "client-side-sse")] - #[test] - fn validate_allows_mcp_protocol_version() { - let name = http::HeaderName::from_static("mcp-protocol-version"); - assert!(validate_custom_header(&name).is_ok()); - } - - #[cfg(feature = "client-side-sse")] - #[test] - fn validate_allows_custom_header() { - let name = http::HeaderName::from_static("x-custom"); - assert!(validate_custom_header(&name).is_ok()); - } -} diff --git a/vendor/rmcp/src/transport/common/mcp_headers.rs b/vendor/rmcp/src/transport/common/mcp_headers.rs deleted file mode 100644 index cc5506857..000000000 --- a/vendor/rmcp/src/transport/common/mcp_headers.rs +++ /dev/null @@ -1,658 +0,0 @@ -//! SEP-2243 HTTP header standardization. -//! -//! Builds and validates the `Mcp-Method`, `Mcp-Name`, and `Mcp-Param-*` headers -//! so middle boxes can route Streamable HTTP traffic without parsing the body. -//! All emission/validation is gated by the negotiated protocol version -//! (`>= ProtocolVersion::STANDARD_HEADERS`) at the call sites. - -// Which helpers are reachable depends on the client/server feature combination, -// mirroring `server_side_http`. -#![allow(dead_code)] - -use serde_json::Value; - -use super::http_header::{ - BASE64_HEADER_PREFIX, BASE64_HEADER_SUFFIX, HEADER_MCP_METHOD, HEADER_MCP_NAME, - HEADER_MCP_PARAM_PREFIX, -}; -use crate::model::JsonObject; - -/// Methods whose `Mcp-Name` is sourced from `params.name`. -const NAME_FROM_NAME: &[&str] = &["tools/call", "prompts/get"]; -/// Methods whose `Mcp-Name` is sourced from `params.uri`. -const NAME_FROM_URI: &[&str] = &[ - "resources/read", - "resources/subscribe", - "resources/unsubscribe", -]; -/// Methods whose `Mcp-Name` is sourced from `params.taskId` (SEP-2663 Tasks -/// extension): allows intermediaries to route task polling to the server -/// instance holding the task's state. -const NAME_FROM_TASK_ID: &[&str] = &["tasks/get", "tasks/update", "tasks/cancel"]; - -/// Returns the `Mcp-Name` value for a request, if the method carries one. -fn extract_name(method: &str, params: Option<&Value>) -> Option { - let params = params?; - let key = if NAME_FROM_NAME.contains(&method) { - "name" - } else if NAME_FROM_URI.contains(&method) { - "uri" - } else if NAME_FROM_TASK_ID.contains(&method) { - "taskId" - } else { - return None; - }; - params.get(key)?.as_str().map(str::to_owned) -} - -/// Converts a JSON primitive to its SEP-2243 string form. Non-primitives yield `None`. -fn primitive_to_string(value: &Value) -> Option { - match value { - Value::String(s) => Some(s.clone()), - Value::Bool(b) => Some(b.to_string()), - Value::Number(n) => Some(n.to_string()), - _ => None, - } -} - -/// True if `value` must be Base64-wrapped to survive as an HTTP header value: -/// leading/trailing space or tab, control/non-ASCII characters, or a value that -/// already looks like the `=?base64?...?=` sentinel. -#[cfg(feature = "client-side-sse")] -fn requires_base64(value: &str) -> bool { - if value.is_empty() { - return false; - } - let bytes = value.as_bytes(); - if matches!(bytes.first(), Some(b' ' | b'\t')) || matches!(bytes.last(), Some(b' ' | b'\t')) { - return true; - } - if value - .chars() - .any(|c| (c as u32) < 0x20 || (c as u32) > 0x7E) - { - return true; - } - value.starts_with(BASE64_HEADER_PREFIX) && value.ends_with(BASE64_HEADER_SUFFIX) -} - -/// RFC 9110 §5.6.2 token character. -#[cfg(feature = "client-side-sse")] -fn is_tchar(c: char) -> bool { - c.is_ascii_alphanumeric() - || matches!( - c, - '!' | '#' - | '$' - | '%' - | '&' - | '\'' - | '*' - | '+' - | '-' - | '.' - | '^' - | '_' - | '`' - | '|' - | '~' - ) -} - -/// Top-level properties carrying an `x-mcp-header` annotation, as `(property, header)` pairs. -fn param_header_annotations(input_schema: &JsonObject) -> Vec<(String, String)> { - let mut out = Vec::new(); - if let Some(Value::Object(props)) = input_schema.get("properties") { - for (prop, schema) in props { - if let Some(Value::String(header)) = schema.get("x-mcp-header") - && !header.is_empty() - { - out.push((prop.clone(), header.clone())); - } - } - } - out -} - -/// Validates the `x-mcp-header` annotations in a tool input schema. -/// -/// Annotations must be non-empty RFC 9110 tokens, case-insensitively unique, -/// applied only to top-level primitive (`string`/`integer`/`boolean`) properties. -/// Returns the offending reason on the first violation. -#[cfg(feature = "client-side-sse")] -pub(crate) fn validate_param_header_annotations(input_schema: &JsonObject) -> Result<(), String> { - let Some(Value::Object(props)) = input_schema.get("properties") else { - return Ok(()); - }; - let mut seen = std::collections::HashSet::new(); - for (prop, schema) in props { - reject_nested_annotations(schema, prop)?; - let Some(raw) = schema.get("x-mcp-header") else { - continue; - }; - let Value::String(header) = raw else { - return Err(format!("property `{prop}`: x-mcp-header must be a string")); - }; - if header.is_empty() { - return Err(format!("property `{prop}`: x-mcp-header must not be empty")); - } - if !header.chars().all(is_tchar) { - return Err(format!( - "property `{prop}`: x-mcp-header `{header}` is not a valid HTTP token" - )); - } - if !seen.insert(header.to_ascii_lowercase()) { - return Err(format!( - "property `{prop}`: duplicate x-mcp-header `{header}` (case-insensitive)" - )); - } - match schema.get("type").and_then(Value::as_str) { - Some("string" | "integer" | "boolean") => {} - other => { - return Err(format!( - "property `{prop}`: x-mcp-header requires a primitive type \ - (string/integer/boolean), got {other:?}" - )); - } - } - } - Ok(()) -} - -/// Rejects `x-mcp-header` on nested properties (only top-level promotion is supported). -#[cfg(feature = "client-side-sse")] -fn reject_nested_annotations(schema: &Value, path: &str) -> Result<(), String> { - if let Some(Value::Object(nested)) = schema.get("properties") { - for (key, value) in nested { - if value.get("x-mcp-header").is_some() { - return Err(format!( - "property `{path}.{key}`: x-mcp-header is not supported on nested properties" - )); - } - reject_nested_annotations(value, &format!("{path}.{key}"))?; - } - } - Ok(()) -} - -/// Wraps a value as `=?base64??=` when it cannot travel as a bare header value. -#[cfg(feature = "client-side-sse")] -fn encode_header_value(value: &str) -> String { - use base64::{Engine, prelude::BASE64_STANDARD}; - if requires_base64(value) { - format!( - "{BASE64_HEADER_PREFIX}{}{BASE64_HEADER_SUFFIX}", - BASE64_STANDARD.encode(value) - ) - } else { - value.to_owned() - } -} - -/// Reverses [`encode_header_value`]. Returns `None` if the sentinel wraps invalid Base64/UTF-8. -#[cfg(feature = "server-side-http")] -fn decode_header_value(value: &str) -> Option { - use base64::{Engine, prelude::BASE64_STANDARD}; - match value - .strip_prefix(BASE64_HEADER_PREFIX) - .and_then(|inner| inner.strip_suffix(BASE64_HEADER_SUFFIX)) - { - Some(inner) => { - let bytes = BASE64_STANDARD.decode(inner).ok()?; - String::from_utf8(bytes).ok() - } - None => Some(value.to_owned()), - } -} - -/// Builds the SEP-2243 headers for an outgoing request from its JSON form. -/// -/// `tool_schema` is the cached input schema of the called tool, used to promote -/// annotated `tools/call` arguments to `Mcp-Param-*` headers. -#[cfg(feature = "client-side-sse")] -pub(crate) fn standard_request_headers( - request: &Value, - tool_schema: Option<&JsonObject>, -) -> Vec<(http::HeaderName, http::HeaderValue)> { - use http::{HeaderName, HeaderValue}; - - let mut out = Vec::new(); - let Some(method) = request.get("method").and_then(Value::as_str) else { - return out; - }; - let params = request.get("params"); - - let mut push = |name: &str, value: &str| { - if let (Ok(name), Ok(value)) = ( - HeaderName::from_bytes(name.as_bytes()), - HeaderValue::from_str(value), - ) { - out.push((name, value)); - } - }; - - push(HEADER_MCP_METHOD, method); - if let Some(name) = extract_name(method, params) { - push(HEADER_MCP_NAME, &encode_header_value(&name)); - } - - if method == "tools/call" - && let (Some(schema), Some(arguments)) = - (tool_schema, params.and_then(|p| p.get("arguments"))) - { - for (prop, header) in param_header_annotations(schema) { - let Some(arg) = arguments.get(&prop) else { - continue; - }; - let Some(encoded) = primitive_to_string(arg).map(|s| encode_header_value(&s)) else { - continue; - }; - push(&format!("{HEADER_MCP_PARAM_PREFIX}{header}"), &encoded); - } - } - out -} - -/// Validates incoming SEP-2243 headers against the request body. -/// -/// Returns `Err(reason)` when a required header is missing or its value does not -/// match the body; the caller maps this to a JSON-RPC `-32020` error (HTTP 400). -#[cfg(feature = "server-side-http")] -pub(crate) fn validate_request_headers( - headers: &http::HeaderMap, - request: &Value, - tool_schema: Option<&JsonObject>, -) -> Result<(), String> { - let Some(method) = request.get("method").and_then(Value::as_str) else { - return Ok(()); - }; - let params = request.get("params"); - - let header_method = header_str(headers, HEADER_MCP_METHOD); - match header_method { - None => return Err("missing required Mcp-Method header".to_owned()), - Some(value) if value != method => { - return Err(format!( - "Mcp-Method header `{value}` does not match body method `{method}`" - )); - } - Some(_) => {} - } - - if let Some(expected) = extract_name(method, params) { - match header_str(headers, HEADER_MCP_NAME) { - None => return Err(format!("missing required Mcp-Name header for `{method}`")), - Some(raw) => { - let decoded = decode_header_value(raw) - .ok_or_else(|| "Mcp-Name header is not valid Base64".to_owned())?; - if decoded != expected { - return Err(format!( - "Mcp-Name header `{decoded}` does not match body value `{expected}`" - )); - } - } - } - } - - if method == "tools/call" - && let Some(schema) = tool_schema - { - let arguments = params.and_then(|p| p.get("arguments")); - for (prop, header) in param_header_annotations(schema) { - let full = format!("{HEADER_MCP_PARAM_PREFIX}{header}"); - let header_value = header_str(headers, &full); - let arg = arguments.and_then(|a| a.get(&prop)); - let body_value = arg.filter(|v| !v.is_null()).and_then(primitive_to_string); - - match (header_value, body_value) { - (None, None) => {} - (Some(_), None) => { - return Err(format!( - "unexpected {full} header for absent or null `{prop}`" - )); - } - (None, Some(_)) => { - return Err(format!("missing {full} header for `{prop}`")); - } - (Some(raw), Some(expected)) => { - let decoded = decode_header_value(raw) - .ok_or_else(|| format!("{full} header is not valid Base64"))?; - if decoded != expected { - return Err(format!( - "{full} header `{decoded}` does not match body value `{expected}`" - )); - } - } - } - } - } - Ok(()) -} - -/// Case-insensitive header lookup returning the value as `&str`, if present and valid UTF-8. -#[cfg(feature = "server-side-http")] -fn header_str<'a>(headers: &'a http::HeaderMap, name: &str) -> Option<&'a str> { - headers.get(name).and_then(|value| value.to_str().ok()) -} - -#[cfg(all(test, feature = "client-side-sse", feature = "server-side-http"))] -mod tests { - use std::collections::HashMap; - - use http::{HeaderMap, HeaderName, HeaderValue}; - use serde_json::json; - - use super::*; - - fn schema_with(properties: serde_json::Value) -> JsonObject { - json!({ "type": "object", "properties": properties }) - .as_object() - .unwrap() - .clone() - } - - fn header_map(pairs: &[(&str, &str)]) -> HeaderMap { - let mut map = HeaderMap::new(); - for (name, value) in pairs { - map.insert( - HeaderName::from_bytes(name.as_bytes()).unwrap(), - HeaderValue::from_str(value).unwrap(), - ); - } - map - } - - fn assert_wrapped(value: &str) { - let encoded = encode_header_value(value); - assert!( - encoded.starts_with(BASE64_HEADER_PREFIX), - "expected {value:?} to be Base64-wrapped, got {encoded:?}" - ); - } - - mod encode_header_value { - use super::*; - - #[test] - fn passes_plain_ascii_through() { - assert_eq!(encode_header_value("us-west1"), "us-west1"); - } - - #[test] - fn passes_internal_spaces_through() { - assert_eq!(encode_header_value("a b c"), "a b c"); - } - - #[test] - fn wraps_non_ascii() { - assert_wrapped("café"); - } - - #[test] - fn wraps_leading_whitespace() { - assert_wrapped(" padded"); - } - - #[test] - fn wraps_trailing_whitespace() { - assert_wrapped("trailing "); - } - - #[test] - fn wraps_control_characters() { - assert_wrapped("line1\nline2"); - } - - #[test] - fn wraps_crlf_injection_attempt() { - assert_wrapped("a\r\nEvil: 1"); - } - - #[test] - fn wraps_sentinel_collision() { - assert_wrapped(&format!("{BASE64_HEADER_PREFIX}x{BASE64_HEADER_SUFFIX}")); - } - } - - mod decode_header_value { - use super::*; - - #[test] - fn round_trips_with_encode() { - for value in ["us-west1", "café", " padded ", "line1\nline2", "true", "42"] { - let encoded = encode_header_value(value); - assert_eq!( - decode_header_value(&encoded).as_deref(), - Some(value), - "round-trip failed for {value:?}" - ); - } - } - - #[test] - fn rejects_invalid_base64() { - let bad = format!("{BASE64_HEADER_PREFIX}!!!not-base64!!!{BASE64_HEADER_SUFFIX}"); - assert_eq!(decode_header_value(&bad), None); - } - } - - mod extract_name { - use super::*; - - #[test] - fn from_name_for_tools_call() { - let params = json!({ "name": "my_tool" }); - assert_eq!( - extract_name("tools/call", Some(¶ms)).as_deref(), - Some("my_tool") - ); - } - - #[test] - fn from_name_for_prompts_get() { - let params = json!({ "name": "my_prompt" }); - assert_eq!( - extract_name("prompts/get", Some(¶ms)).as_deref(), - Some("my_prompt") - ); - } - - #[test] - fn from_uri_for_resources_read() { - let params = json!({ "uri": "file:///x" }); - assert_eq!( - extract_name("resources/read", Some(¶ms)).as_deref(), - Some("file:///x") - ); - } - - #[test] - fn none_for_unrelated_method() { - let params = json!({ "name": "my_tool" }); - assert_eq!(extract_name("ping", Some(¶ms)), None); - } - - #[test] - fn none_when_params_absent() { - assert_eq!(extract_name("tools/call", None), None); - } - } - - mod validate_param_header_annotations { - use super::*; - - #[test] - fn accepts_primitive_types() { - let schema = schema_with(json!({ - "region": { "type": "string", "x-mcp-header": "Region" }, - "count": { "type": "integer", "x-mcp-header": "Count" }, - "flag": { "type": "boolean", "x-mcp-header": "Flag" }, - })); - assert!(validate_param_header_annotations(&schema).is_ok()); - } - - #[test] - fn rejects_number_type() { - let schema = schema_with(json!({ "n": { "type": "number", "x-mcp-header": "N" } })); - assert!(validate_param_header_annotations(&schema).is_err()); - } - - #[test] - fn rejects_complex_type() { - let schema = schema_with(json!({ "a": { "type": "array", "x-mcp-header": "A" } })); - assert!(validate_param_header_annotations(&schema).is_err()); - } - - #[test] - fn rejects_empty_header_name() { - let schema = schema_with(json!({ "r": { "type": "string", "x-mcp-header": "" } })); - assert!(validate_param_header_annotations(&schema).is_err()); - } - - #[test] - fn rejects_non_token_header_name() { - let schema = - schema_with(json!({ "r": { "type": "string", "x-mcp-header": "bad:name" } })); - assert!(validate_param_header_annotations(&schema).is_err()); - } - - #[test] - fn rejects_case_insensitive_duplicate() { - let schema = schema_with(json!({ - "a": { "type": "string", "x-mcp-header": "Region" }, - "b": { "type": "string", "x-mcp-header": "region" }, - })); - assert!(validate_param_header_annotations(&schema).is_err()); - } - - #[test] - fn rejects_nested_annotation() { - let schema = schema_with(json!({ - "outer": { - "type": "object", - "properties": { "inner": { "type": "string", "x-mcp-header": "Inner" } } - } - })); - assert!(validate_param_header_annotations(&schema).is_err()); - } - } - - mod standard_request_headers { - use super::*; - - fn tools_call_headers() -> HashMap { - let schema = schema_with(json!({ - "region": { "type": "string", "x-mcp-header": "Region" }, - })); - let request = json!({ - "jsonrpc": "2.0", "id": 1, "method": "tools/call", - "params": { "name": "deploy", "arguments": { "region": "us-west1" } } - }); - super::super::standard_request_headers(&request, Some(&schema)) - .into_iter() - .map(|(name, value)| (name.as_str().to_owned(), value.to_str().unwrap().to_owned())) - .collect() - } - - #[test] - fn sets_method_header() { - assert_eq!( - tools_call_headers().get("mcp-method").map(String::as_str), - Some("tools/call") - ); - } - - #[test] - fn sets_name_header() { - assert_eq!( - tools_call_headers().get("mcp-name").map(String::as_str), - Some("deploy") - ); - } - - #[test] - fn sets_annotated_param_header() { - assert_eq!( - tools_call_headers() - .get("mcp-param-region") - .map(String::as_str), - Some("us-west1") - ); - } - } - - mod validate_request_headers { - use super::*; - - fn tools_call_request() -> Value { - json!({ - "jsonrpc": "2.0", "id": 1, "method": "tools/call", - "params": { "name": "deploy" } - }) - } - - #[test] - fn accepts_matching_method_and_name() { - let headers = header_map(&[("Mcp-Method", "tools/call"), ("Mcp-Name", "deploy")]); - assert!(validate_request_headers(&headers, &tools_call_request(), None).is_ok()); - } - - #[test] - fn rejects_method_mismatch() { - let headers = header_map(&[("Mcp-Method", "tools/list"), ("Mcp-Name", "deploy")]); - assert!(validate_request_headers(&headers, &tools_call_request(), None).is_err()); - } - - #[test] - fn rejects_missing_method() { - let headers = header_map(&[("Mcp-Name", "deploy")]); - assert!(validate_request_headers(&headers, &tools_call_request(), None).is_err()); - } - - #[test] - fn rejects_name_mismatch() { - let headers = header_map(&[("Mcp-Method", "tools/call"), ("Mcp-Name", "other")]); - assert!(validate_request_headers(&headers, &tools_call_request(), None).is_err()); - } - - #[test] - fn rejects_missing_name() { - let headers = header_map(&[("Mcp-Method", "tools/call")]); - assert!(validate_request_headers(&headers, &tools_call_request(), None).is_err()); - } - - #[test] - fn accepts_matching_param() { - let schema = schema_with(json!({ - "region": { "type": "string", "x-mcp-header": "Region" }, - })); - let request = json!({ - "jsonrpc": "2.0", "id": 1, "method": "tools/call", - "params": { "name": "deploy", "arguments": { "region": "us-west1" } } - }); - let headers = header_map(&[ - ("Mcp-Method", "tools/call"), - ("Mcp-Name", "deploy"), - ("Mcp-Param-Region", "us-west1"), - ]); - assert!(validate_request_headers(&headers, &request, Some(&schema)).is_ok()); - } - - #[test] - fn rejects_param_mismatch() { - let schema = schema_with(json!({ - "region": { "type": "string", "x-mcp-header": "Region" }, - })); - let request = json!({ - "jsonrpc": "2.0", "id": 1, "method": "tools/call", - "params": { "name": "deploy", "arguments": { "region": "us-west1" } } - }); - let headers = header_map(&[ - ("Mcp-Method", "tools/call"), - ("Mcp-Name", "deploy"), - ("Mcp-Param-Region", "eu-central1"), - ]); - assert!(validate_request_headers(&headers, &request, Some(&schema)).is_err()); - } - } -} diff --git a/vendor/rmcp/src/transport/common/reqwest.rs b/vendor/rmcp/src/transport/common/reqwest.rs deleted file mode 100644 index 420759219..000000000 --- a/vendor/rmcp/src/transport/common/reqwest.rs +++ /dev/null @@ -1,2 +0,0 @@ -#[cfg(feature = "transport-streamable-http-client-reqwest")] -mod streamable_http_client; diff --git a/vendor/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/vendor/rmcp/src/transport/common/reqwest/streamable_http_client.rs deleted file mode 100644 index e2eeebb4a..000000000 --- a/vendor/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ /dev/null @@ -1,593 +0,0 @@ -use std::{borrow::Cow, collections::HashMap, sync::Arc}; - -use futures::stream::BoxStream; -use http::{HeaderName, HeaderValue, header::WWW_AUTHENTICATE}; -use reqwest::header::ACCEPT; -use sse_stream::Sse; - -use crate::{ - model::{ClientJsonRpcMessage, JsonRpcMessage, ServerJsonRpcMessage}, - transport::{ - common::{ - client_side_sse::{DEFAULT_MAX_SSE_EVENT_SIZE, bounded_sse_stream}, - http_header::{ - EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_SESSION_ID, JSON_MIME_TYPE, - extract_scope_from_header, validate_custom_header, - }, - }, - streamable_http_client::*, - }, -}; - -impl From for StreamableHttpError { - fn from(e: reqwest::Error) -> Self { - StreamableHttpError::Client(e) - } -} - -/// Applies custom headers to a request builder, rejecting reserved headers. -fn apply_custom_headers( - mut builder: reqwest::RequestBuilder, - custom_headers: HashMap, -) -> Result> { - for (name, value) in custom_headers { - validate_custom_header(&name).map_err(StreamableHttpError::ReservedHeaderConflict)?; - builder = builder.header(name, value); - } - Ok(builder) -} - -/// Attempts to parse `body` as a JSON-RPC error message. -/// Returns `None` if the body is not parseable or is not a `JsonRpcMessage::Error`. -fn parse_json_rpc_error(body: &str) -> Option { - match serde_json::from_str::(body) { - Ok(message @ JsonRpcMessage::Error(_)) => Some(message), - _ => None, - } -} - -impl StreamableHttpClient for reqwest::Client { - type Error = reqwest::Error; - - async fn get_stream( - &self, - uri: Arc, - session_id: Option>, - last_event_id: Option, - auth_token: Option, - custom_headers: HashMap, - ) -> Result>, StreamableHttpError> { - self.get_stream_with_max_sse_event_size( - uri, - session_id, - last_event_id, - auth_token, - custom_headers, - DEFAULT_MAX_SSE_EVENT_SIZE, - ) - .await - } - - async fn get_stream_with_max_sse_event_size( - &self, - uri: Arc, - session_id: Option>, - last_event_id: Option, - auth_token: Option, - custom_headers: HashMap, - max_sse_event_size: usize, - ) -> Result>, StreamableHttpError> { - let mut request_builder = self - .get(uri.as_ref()) - .header(ACCEPT, [EVENT_STREAM_MIME_TYPE, JSON_MIME_TYPE].join(", ")); - if let Some(session_id) = session_id { - request_builder = request_builder.header(HEADER_SESSION_ID, session_id.as_ref()); - } - if let Some(last_event_id) = last_event_id { - request_builder = request_builder.header(HEADER_LAST_EVENT_ID, last_event_id); - } - if let Some(auth_header) = auth_token { - request_builder = request_builder.bearer_auth(auth_header); - } - request_builder = apply_custom_headers(request_builder, custom_headers)?; - let response = request_builder.send().await?; - if response.status() == reqwest::StatusCode::METHOD_NOT_ALLOWED { - return Err(StreamableHttpError::ServerDoesNotSupportSse); - } - let response = response.error_for_status()?; - match response.headers().get(reqwest::header::CONTENT_TYPE) { - Some(ct) => { - if !ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) - && !ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) - { - return Err(StreamableHttpError::UnexpectedContentType(Some( - String::from_utf8_lossy(ct.as_bytes()).to_string(), - ))); - } - } - None => { - return Err(StreamableHttpError::UnexpectedContentType(None)); - } - } - let event_stream = bounded_sse_stream(response.bytes_stream(), max_sse_event_size); - Ok(event_stream) - } - - async fn delete_session( - &self, - uri: Arc, - session: Arc, - auth_token: Option, - custom_headers: HashMap, - ) -> Result<(), StreamableHttpError> { - let mut request_builder = self.delete(uri.as_ref()); - if let Some(auth_header) = auth_token { - request_builder = request_builder.bearer_auth(auth_header); - } - request_builder = request_builder.header(HEADER_SESSION_ID, session.as_ref()); - request_builder = apply_custom_headers(request_builder, custom_headers)?; - let response = request_builder.send().await?; - - // if method no allowed - if response.status() == reqwest::StatusCode::METHOD_NOT_ALLOWED { - tracing::debug!("this server doesn't support deleting session"); - return Ok(()); - } - let _response = response.error_for_status()?; - Ok(()) - } - - async fn post_message( - &self, - uri: Arc, - message: ClientJsonRpcMessage, - session_id: Option>, - auth_token: Option, - custom_headers: HashMap, - ) -> Result> { - self.post_message_with_max_sse_event_size( - uri, - message, - session_id, - auth_token, - custom_headers, - DEFAULT_MAX_SSE_EVENT_SIZE, - ) - .await - } - - async fn post_message_with_max_sse_event_size( - &self, - uri: Arc, - message: ClientJsonRpcMessage, - session_id: Option>, - auth_token: Option, - custom_headers: HashMap, - max_sse_event_size: usize, - ) -> Result> { - let mut request = self - .post(uri.as_ref()) - .header(ACCEPT, [EVENT_STREAM_MIME_TYPE, JSON_MIME_TYPE].join(", ")); - if let Some(auth_header) = auth_token { - request = request.bearer_auth(auth_header); - } - - request = apply_custom_headers(request, custom_headers)?; - let session_was_attached = session_id.is_some(); - if let Some(session_id) = session_id { - request = request.header(HEADER_SESSION_ID, session_id.as_ref()); - } - let response = request.json(&message).send().await?; - if response.status() == reqwest::StatusCode::UNAUTHORIZED - && let Some(header) = response.headers().get(WWW_AUTHENTICATE) - { - let header = header - .to_str() - .map_err(|_| { - StreamableHttpError::UnexpectedServerResponse(Cow::from( - "invalid www-authenticate header value", - )) - })? - .to_string(); - return Err(StreamableHttpError::AuthRequired(AuthRequiredError { - www_authenticate_header: header, - })); - } - if response.status() == reqwest::StatusCode::FORBIDDEN - && let Some(header) = response.headers().get(WWW_AUTHENTICATE) - { - let header_str = header.to_str().map_err(|_| { - StreamableHttpError::UnexpectedServerResponse(Cow::from( - "invalid www-authenticate header value", - )) - })?; - let scope = extract_scope_from_header(header_str); - return Err(StreamableHttpError::InsufficientScope( - InsufficientScopeError { - www_authenticate_header: header_str.to_string(), - required_scope: scope, - }, - )); - } - let status = response.status(); - if matches!( - status, - reqwest::StatusCode::ACCEPTED | reqwest::StatusCode::NO_CONTENT - ) { - return Ok(StreamableHttpPostResponse::Accepted); - } - if status == reqwest::StatusCode::NOT_FOUND && session_was_attached { - return Err(StreamableHttpError::SessionExpired); - } - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .map(|ct| String::from_utf8_lossy(ct.as_bytes()).to_string()); - let content_length = response.content_length(); - let session_id = response - .headers() - .get(HEADER_SESSION_ID) - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - // Spec requires 202 Accepted for these, but some servers return an empty 200. - // Treat empty success responses as equivalent to Accepted. - if status.is_success() - && content_length == Some(0) - && matches!( - message, - ClientJsonRpcMessage::Notification(_) - | ClientJsonRpcMessage::Response(_) - | ClientJsonRpcMessage::Error(_) - ) - { - return Ok(StreamableHttpPostResponse::Accepted); - } - // Non-success responses may carry valid JSON-RPC error payloads that - // should be surfaced as McpError rather than lost in TransportSend. - if !status.is_success() { - let body = response - .text() - .await - .unwrap_or_else(|_| "".to_owned()); - if content_type - .as_deref() - .is_some_and(|ct| ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes())) - { - match parse_json_rpc_error(&body) { - Some(message) => { - return Ok(StreamableHttpPostResponse::Json(message, session_id)); - } - None => tracing::warn!( - "HTTP {status}: could not parse JSON body as a JSON-RPC error" - ), - } - } - return Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned( - format!("HTTP {status}: {body}"), - ))); - } - match content_type.as_deref() { - Some(ct) if ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) => { - let event_stream = bounded_sse_stream(response.bytes_stream(), max_sse_event_size); - Ok(StreamableHttpPostResponse::Sse(event_stream, session_id)) - } - Some(ct) if ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) => { - // Try to parse as a valid JSON-RPC message. If the body is - // malformed (e.g. a 200 response to a notification that lacks - // an `id` field), treat it as accepted rather than failing. - match response.json::().await { - Ok(message) => Ok(StreamableHttpPostResponse::Json(message, session_id)), - Err(e) => { - tracing::warn!( - "could not parse JSON response as ServerJsonRpcMessage, treating as accepted: {e}" - ); - Ok(StreamableHttpPostResponse::Accepted) - } - } - } - _ => { - // unexpected content type - tracing::error!("unexpected content type: {:?}", content_type); - Err(StreamableHttpError::UnexpectedContentType(content_type)) - } - } - } -} - -impl StreamableHttpClientTransport { - /// Creates a new transport using reqwest with the specified URI. - /// - /// This is a convenience method that creates a transport using the default - /// reqwest client. This method is only available when the - /// `transport-streamable-http-client-reqwest` feature is enabled. - /// - /// # Arguments - /// - /// * `uri` - The server URI to connect to - /// - /// # Example - /// - /// ```rust,no_run - /// use rmcp::transport::StreamableHttpClientTransport; - /// - /// // Enable the reqwest feature in Cargo.toml: - /// // rmcp = { version = "0.5", features = ["transport-streamable-http-client-reqwest"] } - /// - /// let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/mcp"); - /// ``` - /// - /// # Feature requirement - /// - /// This method requires the `transport-streamable-http-client-reqwest` feature. - pub fn from_uri(uri: impl Into>) -> Self { - StreamableHttpClientTransport::with_client( - Self::default_http_client(), - StreamableHttpClientTransportConfig { - uri: uri.into(), - auth_header: None, - ..Default::default() - }, - ) - } - - /// Build this transport form a config - /// - /// # Arguments - /// - /// * `config` - The config to use with this transport - pub fn from_config(config: StreamableHttpClientTransportConfig) -> Self { - StreamableHttpClientTransport::with_client(Self::default_http_client(), config) - } - - /// Build the default reqwest client for this transport. - /// - /// Disables idle connection pooling to avoid ~40 ms stalls caused by - /// TCP Delayed ACK on Linux when the previous response body was not - /// fully consumed before the pool attempts to reuse the connection. - /// - /// Automatic redirects are disabled so caller-supplied custom headers - /// cannot be replayed to a redirect target. - fn default_http_client() -> reqwest::Client { - reqwest::Client::builder() - .pool_max_idle_per_host(0) - .redirect(reqwest::redirect::Policy::none()) - .build() - .expect("failed to build default reqwest client") - } -} - -#[cfg(test)] -mod tests { - use rstest::rstest; - - use super::parse_json_rpc_error; - use crate::{ - model::{ClientJsonRpcMessage, ClientRequest, JsonRpcMessage, PingRequest, RequestId}, - transport::streamable_http_client::{AuthRequiredError, InsufficientScopeError}, - }; - - #[test] - fn auth_required_error_new() { - let err = AuthRequiredError::new("Bearer realm=\"test\"".to_string()); - assert_eq!(err.www_authenticate_header, "Bearer realm=\"test\""); - } - - #[test] - fn insufficient_scope_error_can_upgrade() { - let with_scope = InsufficientScopeError::new( - "Bearer scope=\"admin\"".to_string(), - Some("admin".to_string()), - ); - assert!(with_scope.can_upgrade()); - assert_eq!(with_scope.get_required_scope(), Some("admin")); - - let without_scope = - InsufficientScopeError::new("Bearer error=\"insufficient_scope\"".to_string(), None); - assert!(!without_scope.can_upgrade()); - assert_eq!(without_scope.get_required_scope(), None); - } - - #[test] - fn parse_json_rpc_error_returns_error_variant() { - let body = - r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Invalid Request"}}"#; - assert!(matches!( - parse_json_rpc_error(body), - Some(JsonRpcMessage::Error(_)) - )); - } - - #[rstest] - #[case::non_error_request(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#)] - #[case::notification( - r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}"# - )] - #[case::plain_text("not json at all")] - #[case::empty("")] - #[case::truncated_json(r#"{"broken":"#)] - fn parse_json_rpc_error_rejects_non_error_bodies(#[case] body: &str) { - assert!(parse_json_rpc_error(body).is_none()); - } - - #[tokio::test] - async fn post_sse_response_honors_configured_event_limit() -> anyhow::Result<()> { - use std::{collections::HashMap, net::SocketAddr, sync::Arc}; - - use axum::{Router, routing::post}; - use futures::StreamExt; - - use crate::transport::streamable_http_client::{ - StreamableHttpClient, StreamableHttpPostResponse, - }; - - let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await?; - let addr = listener.local_addr()?; - let server = tokio::spawn(async move { - let app = Router::new().route( - "/mcp", - post(|| async { - ( - [(http::header::CONTENT_TYPE, "text/event-stream")], - "data: this event is too large\n", - ) - }), - ); - axum::serve(listener, app).await - }); - let message = ClientJsonRpcMessage::request( - ClientRequest::PingRequest(PingRequest::default()), - RequestId::Number(1), - ); - let client = reqwest::Client::new(); - - let response = client - .post_message_with_max_sse_event_size( - Arc::::from(format!("http://{addr}/mcp")), - message, - None, - None, - HashMap::new(), - 16, - ) - .await?; - let StreamableHttpPostResponse::Sse(mut stream, _) = response else { - anyhow::bail!("expected SSE response"); - }; - let error = stream.next().await.unwrap().unwrap_err(); - - server.abort(); - assert!(error.to_string().contains("maximum size of 16 bytes")); - Ok(()) - } - - #[tokio::test] - async fn default_http_client_does_not_leak_custom_headers_to_redirect_target() - -> anyhow::Result<()> { - use std::{collections::HashMap, net::SocketAddr, sync::Arc}; - - use axum::{ - Router, extract::State, http::StatusCode, response::IntoResponse, routing::post, - }; - use http::{HeaderMap, HeaderName, HeaderValue, header::LOCATION}; - use tokio::sync::Mutex; - - use super::StreamableHttpClientTransport; - use crate::transport::streamable_http_client::{StreamableHttpClient, StreamableHttpError}; - - const API_KEY_HEADER: &str = "x-api-key"; - const API_KEY_VALUE: &str = "secret"; - - type CapturedHeader = Arc>>; - - #[derive(Clone)] - struct RedirectState { - location: String, - captured_header: CapturedHeader, - } - - async fn capture_api_key_header(headers: &HeaderMap, captured_header: &CapturedHeader) { - if let Some(value) = headers - .get(API_KEY_HEADER) - .and_then(|value| value.to_str().ok()) - { - *captured_header.lock().await = Some(value.to_owned()); - } - } - - async fn redirect_handler( - State(state): State, - headers: HeaderMap, - ) -> impl IntoResponse { - capture_api_key_header(&headers, &state.captured_header).await; - - ( - StatusCode::TEMPORARY_REDIRECT, - [(LOCATION, state.location)], - "", - ) - } - - async fn redirected_handler( - State(captured_header): State, - headers: HeaderMap, - ) -> impl IntoResponse { - capture_api_key_header(&headers, &captured_header).await; - - ( - StatusCode::OK, - [(http::header::CONTENT_TYPE, "application/json")], - r#"{"jsonrpc":"2.0","id":1,"result":{}}"#, - ) - } - - let redirected_header = Arc::new(Mutex::new(None)); - let redirected_listener = - tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await?; - let redirected_addr = redirected_listener.local_addr()?; - let redirected_server = tokio::spawn({ - let redirected_header = redirected_header.clone(); - async move { - let app = Router::new() - .route("/capture", post(redirected_handler)) - .with_state(redirected_header); - axum::serve(redirected_listener, app).await - } - }); - - let original_header = Arc::new(Mutex::new(None)); - let redirect_listener = - tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await?; - let redirect_addr = redirect_listener.local_addr()?; - let redirect_server = tokio::spawn({ - let state = RedirectState { - location: format!("http://{redirected_addr}/capture"), - captured_header: original_header.clone(), - }; - async move { - let app = Router::new() - .route("/mcp", post(redirect_handler)) - .with_state(state); - axum::serve(redirect_listener, app).await - } - }); - - let mut custom_headers = HashMap::new(); - custom_headers.insert( - HeaderName::from_static(API_KEY_HEADER), - HeaderValue::from_static(API_KEY_VALUE), - ); - let message = ClientJsonRpcMessage::request( - ClientRequest::PingRequest(PingRequest::default()), - RequestId::Number(1), - ); - - let client = StreamableHttpClientTransport::::default_http_client(); - let result = client - .post_message( - Arc::::from(format!("http://{redirect_addr}/mcp")), - message, - None, - None, - custom_headers, - ) - .await; - - assert!( - matches!( - result, - Err(StreamableHttpError::UnexpectedServerResponse(_)) - ), - "redirect response should be returned to the transport, got {result:?}" - ); - assert_eq!(original_header.lock().await.as_deref(), Some(API_KEY_VALUE)); - assert!( - redirected_header.lock().await.is_none(), - "custom headers should not be sent to redirect targets" - ); - - redirect_server.abort(); - redirected_server.abort(); - - Ok(()) - } -} diff --git a/vendor/rmcp/src/transport/common/server_side_http.rs b/vendor/rmcp/src/transport/common/server_side_http.rs deleted file mode 100644 index 09df6d285..000000000 --- a/vendor/rmcp/src/transport/common/server_side_http.rs +++ /dev/null @@ -1,378 +0,0 @@ -#![allow(dead_code)] -use std::{convert::Infallible, fmt::Display, sync::Arc, time::Duration}; - -use bytes::{Buf, Bytes}; -use http::Response; -use http_body::Body; -use http_body_util::{BodyExt, Empty, Full, combinators::BoxBody}; -use sse_stream::{KeepAlive, Sse, SseBody}; -use tokio_util::sync::{CancellationToken, DropGuard}; - -use super::http_header::EVENT_STREAM_MIME_TYPE; -use crate::model::{ClientJsonRpcMessage, ServerJsonRpcMessage}; - -pub type SessionId = Arc; - -pub fn session_id() -> SessionId { - uuid::Uuid::new_v4().to_string().into() -} - -pub const DEFAULT_AUTO_PING_INTERVAL: Duration = Duration::from_secs(15); - -pub(crate) type BoxResponse = Response>; - -pub(crate) fn accepted_response() -> Response> { - Response::builder() - .status(http::StatusCode::ACCEPTED) - .body(Empty::new().boxed()) - .expect("valid response") -} -pin_project_lite::pin_project! { - struct TokioTimer { - #[pin] - sleep: tokio::time::Sleep, - } -} -impl Future for TokioTimer { - type Output = (); - - fn poll( - self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll { - let this = self.project(); - this.sleep.poll(cx) - } -} -impl sse_stream::Timer for TokioTimer { - fn from_duration(duration: Duration) -> Self { - Self { - sleep: tokio::time::sleep(duration), - } - } - - fn reset(self: std::pin::Pin<&mut Self>, when: std::time::Instant) { - let this = self.project(); - this.sleep.reset(tokio::time::Instant::from_std(when)); - } -} - -pin_project_lite::pin_project! { - struct CancelOnDropStream { - #[pin] - inner: S, - _drop_guard: DropGuard, - } -} - -impl futures::Stream for CancelOnDropStream { - type Item = S::Item; - - fn poll_next( - self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - self.project().inner.poll_next(cx) - } -} - -#[derive(Debug, Clone, Default)] -#[non_exhaustive] -pub struct ServerSseMessage { - /// The event ID for this message. When set, clients can use this ID - /// with the `Last-Event-ID` header to resume the stream from this point. - pub event_id: Option, - /// The JSON-RPC message content. Set to `None` for priming events. - /// See [SEP-1699](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699) - pub message: Option>, - /// The retry interval hint for clients. Clients should wait this duration - /// before attempting to reconnect. This maps to the SSE `retry:` field. - pub retry: Option, -} - -impl ServerSseMessage { - /// Create a message carrying a JSON-RPC response/notification with an event ID. - pub fn new(event_id: impl Into, message: ServerJsonRpcMessage) -> Self { - Self { - event_id: Some(event_id.into()), - message: Some(Arc::new(message)), - retry: None, - } - } - - /// Wrap a JSON-RPC message without an event ID or retry hint. - pub fn from_message(message: ServerJsonRpcMessage) -> Self { - Self { - event_id: None, - message: Some(Arc::new(message)), - retry: None, - } - } - - /// Create a priming event that tells the client to reconnect after `retry` - /// if the connection drops. - /// See [SEP-1699](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699). - pub fn priming(event_id: impl Into, retry: Duration) -> Self { - Self { - event_id: Some(event_id.into()), - message: None, - retry: Some(retry), - } - } - - /// Create a retry hint without changing the client's last event ID. - pub fn retry(retry: Duration) -> Self { - Self { - event_id: None, - message: None, - retry: Some(retry), - } - } -} - -pub(crate) fn sse_stream_response( - stream: impl futures::Stream + Send + Sync + 'static, - keep_alive: Option, - ct: CancellationToken, -) -> Response> { - use futures::StreamExt; - let cancelled = ct.clone(); - let stream = stream - .map(|message| { - let mut sse = if let Some(ref msg) = message.message { - let data = serde_json::to_string(msg.as_ref()).expect("valid message"); - Sse::default().data(data) - } else { - // Priming event: empty data per SEP-1699 (just "data:\n") - Sse::default().data("") - }; - - sse.id = message.event_id; - - if let Some(retry) = message.retry { - sse.retry = Some(retry.as_millis() as u64); - } - - Result::::Ok(sse) - }) - .take_until(async move { cancelled.cancelled().await }); - let stream = CancelOnDropStream { - inner: stream, - _drop_guard: ct.drop_guard(), - }; - let stream = SseBody::new(stream); - - let stream = match keep_alive { - Some(duration) => stream - .with_keep_alive::(KeepAlive::new().interval(duration)) - .boxed(), - None => stream.boxed(), - }; - - Response::builder() - .status(http::StatusCode::OK) - .header(http::header::CONTENT_TYPE, EVENT_STREAM_MIME_TYPE) - .header(http::header::CACHE_CONTROL, "no-cache") - .header("X-Accel-Buffering", "no") - .body(stream) - .expect("valid response") -} - -pub(crate) const fn internal_error_response( - context: &str, -) -> impl FnOnce(E) -> Response> { - move |error| { - tracing::error!("Internal server error when {context}: {error}"); - Response::builder() - .status(http::StatusCode::INTERNAL_SERVER_ERROR) - .body( - Full::new(Bytes::from(format!( - "Encounter an error when {context}: {error}" - ))) - .boxed(), - ) - .expect("valid response") - } -} - -pub(crate) fn unexpected_message_response(expect: &str) -> Response> { - Response::builder() - .status(http::StatusCode::UNPROCESSABLE_ENTITY) - .body(Full::new(Bytes::from(format!("Unexpected message, expect {expect}"))).boxed()) - .expect("valid response") -} - -pub(crate) async fn expect_json( - body: B, - max_bytes: usize, -) -> Result>> -where - B: Body + Send + 'static, - B::Error: Display, -{ - let mut collected = bytes::BytesMut::new(); - let mut body = std::pin::pin!(body); - loop { - let frame = futures::future::poll_fn(|cx| body.as_mut().poll_frame(cx)).await; - match frame { - None => break, - Some(Ok(frame)) => { - let Ok(mut data) = frame.into_data() else { - continue; - }; - if data.remaining() > max_bytes.saturating_sub(collected.len()) { - let response = Response::builder() - .status(http::StatusCode::PAYLOAD_TOO_LARGE) - .body( - Full::new(Bytes::from(format!( - "Payload Too Large: request body exceeds {max_bytes} bytes" - ))) - .boxed(), - ) - .expect("valid response"); - return Err(response); - } - while data.has_remaining() { - let chunk = data.chunk(); - let chunk_len = chunk.len(); - collected.extend_from_slice(chunk); - data.advance(chunk_len); - } - } - Some(Err(e)) => { - let response = Response::builder() - .status(http::StatusCode::INTERNAL_SERVER_ERROR) - .body( - Full::new(Bytes::from(format!("Failed to read request body: {e}"))).boxed(), - ) - .expect("valid response"); - return Err(response); - } - } - } - - match serde_json::from_slice::(&collected) { - Ok(message) => Ok(message), - Err(e) => { - let response = Response::builder() - .status(http::StatusCode::UNSUPPORTED_MEDIA_TYPE) - .body( - Full::new(Bytes::from(format!("fail to deserialize request body {e}"))).boxed(), - ) - .expect("valid response"); - Err(response) - } - } -} - -#[cfg(test)] -mod tests { - use std::convert::Infallible; - - use futures::stream; - use http_body::Frame; - use http_body_util::StreamBody; - - use super::*; - use crate::model::{EmptyResult, JsonRpcResponse, JsonRpcVersion2_0, RequestId, ServerResult}; - - const INITIALIZE_REQUEST: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}"#; - - fn dummy_message() -> ServerJsonRpcMessage { - ServerJsonRpcMessage::Response(JsonRpcResponse { - jsonrpc: JsonRpcVersion2_0, - id: RequestId::Number(1), - result: ServerResult::EmptyResult(EmptyResult {}), - }) - } - - #[test] - fn default_has_all_none() { - let msg = ServerSseMessage::default(); - assert!(msg.event_id.is_none()); - assert!(msg.message.is_none()); - assert!(msg.retry.is_none()); - } - - #[test] - fn new_sets_event_id_and_message() { - let msg = ServerSseMessage::new("42", dummy_message()); - assert_eq!(msg.event_id.as_deref(), Some("42")); - assert!(msg.message.is_some()); - assert!(msg.retry.is_none()); - } - - #[test] - fn from_message_has_no_event_id() { - let msg = ServerSseMessage::from_message(dummy_message()); - assert!(msg.event_id.is_none()); - assert!(msg.message.is_some()); - assert!(msg.retry.is_none()); - } - - #[test] - fn priming_sets_event_id_and_retry() { - let msg = ServerSseMessage::priming("0", Duration::from_secs(5)); - assert_eq!(msg.event_id.as_deref(), Some("0")); - assert!(msg.message.is_none()); - assert_eq!(msg.retry, Some(Duration::from_secs(5))); - } - - #[tokio::test] - async fn expect_json_accepts_body_under_limit() { - let body = Full::new(Bytes::from_static(INITIALIZE_REQUEST.as_bytes())); - let result = expect_json(body, 4 * 1024 * 1024).await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn expect_json_accepts_non_contiguous_body_data() { - let split = INITIALIZE_REQUEST.len() / 2; - let data = Bytes::copy_from_slice(&INITIALIZE_REQUEST.as_bytes()[..split]).chain( - Bytes::copy_from_slice(&INITIALIZE_REQUEST.as_bytes()[split..]), - ); - let result = expect_json(Full::new(data), 4 * 1024 * 1024).await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn expect_json_accepts_multi_frame_body() { - let split = INITIALIZE_REQUEST.len() / 2; - let body = StreamBody::new(stream::iter([ - Ok::<_, Infallible>(Frame::data(Bytes::copy_from_slice( - &INITIALIZE_REQUEST.as_bytes()[..split], - ))), - Ok(Frame::data(Bytes::copy_from_slice( - &INITIALIZE_REQUEST.as_bytes()[split..], - ))), - ])); - let result = expect_json(body, 4 * 1024 * 1024).await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn expect_json_rejects_oversized_body() { - let big_body = Full::new(Bytes::from(vec![b'x'; 128])); - let result = expect_json(big_body, 64).await; - let response = result.unwrap_err(); - assert_eq!(response.status(), http::StatusCode::PAYLOAD_TOO_LARGE); - } - - #[tokio::test] - async fn expect_json_rejects_oversized_multi_frame_body() { - let body = StreamBody::new(stream::iter([ - Ok::<_, Infallible>(Frame::data(Bytes::from_static(b"12345678"))), - Ok(Frame::data(Bytes::from_static(b"9"))), - ])); - let response = expect_json(body, 8).await.unwrap_err(); - assert_eq!(response.status(), http::StatusCode::PAYLOAD_TOO_LARGE); - } - - #[tokio::test] - async fn expect_json_returns_415_for_invalid_json_under_limit() { - let body = Full::new(Bytes::from("not valid json")); - let result = expect_json(body, 4 * 1024 * 1024).await; - let response = result.unwrap_err(); - assert_eq!(response.status(), http::StatusCode::UNSUPPORTED_MEDIA_TYPE); - } -} diff --git a/vendor/rmcp/src/transport/common/unix_socket.rs b/vendor/rmcp/src/transport/common/unix_socket.rs deleted file mode 100644 index 5f995db2b..000000000 --- a/vendor/rmcp/src/transport/common/unix_socket.rs +++ /dev/null @@ -1,614 +0,0 @@ -use std::{borrow::Cow, collections::HashMap, sync::Arc}; - -use bytes::Bytes; -use futures::stream::BoxStream; -use http::{HeaderName, HeaderValue, Method, Request, StatusCode, header::WWW_AUTHENTICATE}; -use http_body_util::{BodyExt, Full}; -use hyper::body::Incoming; -use hyper_util::rt::TokioIo; -use sse_stream::Sse; -use tokio::net::UnixStream; - -use crate::{ - model::{ClientJsonRpcMessage, ServerJsonRpcMessage}, - transport::{ - common::{ - client_side_sse::{DEFAULT_MAX_SSE_EVENT_SIZE, bounded_sse_stream}, - http_header::{ - EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_SESSION_ID, JSON_MIME_TYPE, - extract_scope_from_header, validate_custom_header, - }, - }, - streamable_http_client::*, - }, -}; - -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum UnixSocketError { - #[error("hyper error: {0}")] - Hyper(#[from] hyper::Error), - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - #[error("HTTP error: {0}")] - Http(#[from] http::Error), - #[error("JSON error: {0}")] - Json(#[from] serde_json::Error), -} - -impl From for StreamableHttpError { - fn from(e: UnixSocketError) -> Self { - StreamableHttpError::Client(e) - } -} - -/// HTTP client that routes requests through a Unix domain socket. -/// -/// Implements [`StreamableHttpClient`] using `hyper` over `tokio::net::UnixStream`, -/// enabling MCP hosts in Kubernetes environments to connect through Envoy sidecars -/// or other Unix socket-based proxies. -/// -/// Each request opens a new Unix socket connection (no connection pooling). -/// This is appropriate when connecting through a sidecar proxy that manages -/// its own upstream connection pool. -/// -/// # Example -/// -/// ```rust,no_run -/// use rmcp::transport::{StreamableHttpClientTransport, UnixSocketHttpClient}; -/// use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; -/// -/// let client = UnixSocketHttpClient::new("/var/run/envoy.sock", "http://mcp-server.internal/mcp"); -/// let config = StreamableHttpClientTransportConfig::with_uri("http://mcp-server.internal/mcp"); -/// let transport = StreamableHttpClientTransport::with_client(client, config); -/// ``` -#[derive(Clone, Debug)] -pub struct UnixSocketHttpClient { - socket_path: Arc, - host_header: HeaderValue, -} - -impl UnixSocketHttpClient { - /// Creates a new Unix socket HTTP client. - /// - /// # Arguments - /// - /// * `socket_path` - Path to the Unix domain socket. Use `@name` syntax for Linux - /// abstract sockets (e.g., `@egress.sock` becomes `\0egress.sock`). - /// * `uri` - The MCP server URI. The authority (host:port) is extracted for the - /// HTTP `Host` header, since hyper does not auto-set it for Unix socket connections. - /// - /// # Panics - /// - /// Panics if `socket_path` is empty or is `@` with no name (empty abstract socket). - pub fn new(socket_path: &str, uri: &str) -> Self { - assert!( - !socket_path.is_empty() && socket_path != "@", - "socket_path must not be empty or a bare '@' (empty abstract socket name)" - ); - - let host_header = uri - .parse::() - .ok() - .and_then(|u| u.authority().cloned()) - .and_then(|a| HeaderValue::from_str(a.as_str()).ok()) - .unwrap_or_else(|| HeaderValue::from_static("localhost")); - - Self { - socket_path: resolve_socket_path(socket_path).into(), - host_header, - } - } -} - -/// Converts the `@`-prefixed abstract socket notation to the null-byte prefix -/// expected by the Linux kernel. Filesystem socket paths are returned unchanged. -fn resolve_socket_path(raw: &str) -> String { - if let Some(name) = raw.strip_prefix('@') { - format!("\0{name}") - } else { - raw.to_string() - } -} - -async fn connect_unix(socket_path: &str) -> Result { - #[cfg(target_os = "linux")] - if let Some(abstract_name) = socket_path.strip_prefix('\0') { - let abstract_name = abstract_name.to_string(); - let std_stream = tokio::task::spawn_blocking(move || { - use std::os::linux::net::SocketAddrExt; - let addr = std::os::unix::net::SocketAddr::from_abstract_name(&abstract_name)?; - let stream = std::os::unix::net::UnixStream::connect_addr(&addr)?; - stream.set_nonblocking(true)?; - Ok::<_, std::io::Error>(stream) - }) - .await - .map_err(std::io::Error::other)??; - return UnixStream::from_std(std_stream); - } - - UnixStream::connect(socket_path).await -} - -/// Opens a new Unix socket connection and sends the HTTP request. -/// One connection per request — the sidecar proxy handles connection pooling. -async fn send_http_request( - socket_path: &str, - request: Request>, -) -> Result, UnixSocketError> { - let stream = connect_unix(socket_path).await?; - let io = TokioIo::new(stream); - let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await?; - - tokio::spawn(async move { - if let Err(e) = conn.await { - tracing::warn!("unix socket HTTP/1.1 connection error: {e}"); - } - }); - - Ok(sender.send_request(request).await?) -} - -/// Applies custom headers to a request builder, rejecting reserved headers. -fn apply_custom_headers( - mut builder: http::request::Builder, - custom_headers: HashMap, -) -> Result> { - for (name, value) in custom_headers { - validate_custom_header(&name).map_err(StreamableHttpError::ReservedHeaderConflict)?; - builder = builder.header(name, value); - } - Ok(builder) -} - -impl StreamableHttpClient for UnixSocketHttpClient { - type Error = UnixSocketError; - - async fn post_message( - &self, - uri: Arc, - message: ClientJsonRpcMessage, - session_id: Option>, - auth_token: Option, - custom_headers: HashMap, - ) -> Result> { - self.post_message_with_max_sse_event_size( - uri, - message, - session_id, - auth_token, - custom_headers, - DEFAULT_MAX_SSE_EVENT_SIZE, - ) - .await - } - - async fn post_message_with_max_sse_event_size( - &self, - uri: Arc, - message: ClientJsonRpcMessage, - session_id: Option>, - auth_token: Option, - custom_headers: HashMap, - max_sse_event_size: usize, - ) -> Result> { - let json_body = serde_json::to_string(&message) - .map_err(|e| StreamableHttpError::Client(UnixSocketError::Json(e)))?; - - let mut builder = Request::builder() - .method(Method::POST) - .uri(uri.as_ref()) - .header(http::header::HOST, self.host_header.clone()) - .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) - .header( - http::header::ACCEPT, - format!("{EVENT_STREAM_MIME_TYPE}, {JSON_MIME_TYPE}"), - ); - - if let Some(auth) = auth_token { - builder = builder.header(http::header::AUTHORIZATION, format!("Bearer {auth}")); - } - - builder = apply_custom_headers(builder, custom_headers)?; - - let session_was_attached = session_id.is_some(); - if let Some(sid) = session_id { - builder = builder.header(HEADER_SESSION_ID, sid.as_ref()); - } - - let request = builder - .body(Full::new(Bytes::from(json_body))) - .map_err(|e| StreamableHttpError::Client(UnixSocketError::Http(e)))?; - - let response = send_http_request(&self.socket_path, request) - .await - .map_err(StreamableHttpError::Client)?; - - let status = response.status(); - - if status == StatusCode::UNAUTHORIZED - && let Some(header) = response.headers().get(WWW_AUTHENTICATE) - { - let www_authenticate_header = header - .to_str() - .map_err(|_| { - StreamableHttpError::UnexpectedServerResponse(Cow::from( - "invalid www-authenticate header value", - )) - })? - .to_string(); - return Err(StreamableHttpError::AuthRequired(AuthRequiredError { - www_authenticate_header, - })); - } - - if status == StatusCode::FORBIDDEN - && let Some(header) = response.headers().get(WWW_AUTHENTICATE) - { - let header_str = header.to_str().map_err(|_| { - StreamableHttpError::UnexpectedServerResponse(Cow::from( - "invalid www-authenticate header value", - )) - })?; - let scope = extract_scope_from_header(header_str); - return Err(StreamableHttpError::InsufficientScope( - InsufficientScopeError { - www_authenticate_header: header_str.to_string(), - required_scope: scope, - }, - )); - } - - if matches!(status, StatusCode::ACCEPTED | StatusCode::NO_CONTENT) { - return Ok(StreamableHttpPostResponse::Accepted); - } - - if status == StatusCode::NOT_FOUND && session_was_attached { - return Err(StreamableHttpError::SessionExpired); - } - - if !status.is_success() { - let body = response - .into_body() - .collect() - .await - .map(|c| String::from_utf8_lossy(&c.to_bytes()).into_owned()) - .unwrap_or_else(|_| "".to_owned()); - return Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned( - format!("HTTP {status}: {body}"), - ))); - } - - let content_type = response.headers().get(http::header::CONTENT_TYPE).cloned(); - let content_length = response - .headers() - .get(http::header::CONTENT_LENGTH) - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.parse::().ok()); - let session_id = response - .headers() - .get(HEADER_SESSION_ID) - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - - if status.is_success() - && content_length == Some(0) - && matches!( - message, - ClientJsonRpcMessage::Notification(_) - | ClientJsonRpcMessage::Response(_) - | ClientJsonRpcMessage::Error(_) - ) - { - return Ok(StreamableHttpPostResponse::Accepted); - } - - match content_type { - Some(ref ct) if ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) => { - let sse_stream = - bounded_sse_stream(response.into_body().into_data_stream(), max_sse_event_size); - Ok(StreamableHttpPostResponse::Sse(sse_stream, session_id)) - } - Some(ref ct) if ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) => { - let body = response - .into_body() - .collect() - .await - .map_err(|e| StreamableHttpError::Client(UnixSocketError::Hyper(e)))? - .to_bytes(); - match serde_json::from_slice::(&body) { - Ok(message) => Ok(StreamableHttpPostResponse::Json(message, session_id)), - Err(e) => { - tracing::warn!( - "could not parse JSON response as ServerJsonRpcMessage, treating as accepted: {e}" - ); - Ok(StreamableHttpPostResponse::Accepted) - } - } - } - _ => Err(StreamableHttpError::UnexpectedContentType( - content_type.map(|ct| String::from_utf8_lossy(ct.as_bytes()).into_owned()), - )), - } - } - - async fn delete_session( - &self, - uri: Arc, - session_id: Arc, - auth_token: Option, - custom_headers: HashMap, - ) -> Result<(), StreamableHttpError> { - let mut builder = Request::builder() - .method(Method::DELETE) - .uri(uri.as_ref()) - .header(http::header::HOST, self.host_header.clone()) - .header(HEADER_SESSION_ID, session_id.as_ref()); - - if let Some(auth) = auth_token { - builder = builder.header(http::header::AUTHORIZATION, format!("Bearer {auth}")); - } - - builder = apply_custom_headers(builder, custom_headers)?; - - let request = builder - .body(Full::new(Bytes::new())) - .map_err(|e| StreamableHttpError::Client(UnixSocketError::Http(e)))?; - - let response = send_http_request(&self.socket_path, request) - .await - .map_err(StreamableHttpError::Client)?; - - if response.status() == StatusCode::METHOD_NOT_ALLOWED { - tracing::debug!("this server doesn't support deleting session"); - return Ok(()); - } - - if !response.status().is_success() { - return Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned( - format!("delete_session returned {}", response.status()), - ))); - } - - Ok(()) - } - - async fn get_stream( - &self, - uri: Arc, - session_id: Option>, - last_event_id: Option, - auth_token: Option, - custom_headers: HashMap, - ) -> Result>, StreamableHttpError> - { - self.get_stream_with_max_sse_event_size( - uri, - session_id, - last_event_id, - auth_token, - custom_headers, - DEFAULT_MAX_SSE_EVENT_SIZE, - ) - .await - } - - async fn get_stream_with_max_sse_event_size( - &self, - uri: Arc, - session_id: Option>, - last_event_id: Option, - auth_token: Option, - custom_headers: HashMap, - max_sse_event_size: usize, - ) -> Result>, StreamableHttpError> - { - let mut builder = Request::builder() - .method(Method::GET) - .uri(uri.as_ref()) - .header(http::header::HOST, self.host_header.clone()) - .header( - http::header::ACCEPT, - format!("{EVENT_STREAM_MIME_TYPE}, {JSON_MIME_TYPE}"), - ); - - if let Some(session_id) = session_id { - builder = builder.header(HEADER_SESSION_ID, session_id.as_ref()); - } - - if let Some(last_id) = last_event_id { - builder = builder.header(HEADER_LAST_EVENT_ID, last_id); - } - - if let Some(auth) = auth_token { - builder = builder.header(http::header::AUTHORIZATION, format!("Bearer {auth}")); - } - - builder = apply_custom_headers(builder, custom_headers)?; - - let request = builder - .body(Full::new(Bytes::new())) - .map_err(|e| StreamableHttpError::Client(UnixSocketError::Http(e)))?; - - let response = send_http_request(&self.socket_path, request) - .await - .map_err(StreamableHttpError::Client)?; - - if response.status() == StatusCode::METHOD_NOT_ALLOWED { - return Err(StreamableHttpError::ServerDoesNotSupportSse); - } - - if response.status() == StatusCode::UNAUTHORIZED - && let Some(header) = response.headers().get(WWW_AUTHENTICATE) - { - let www_authenticate_header = header - .to_str() - .map_err(|_| { - StreamableHttpError::UnexpectedServerResponse(Cow::from( - "invalid www-authenticate header value", - )) - })? - .to_string(); - return Err(StreamableHttpError::AuthRequired(AuthRequiredError { - www_authenticate_header, - })); - } - - if response.status() == StatusCode::FORBIDDEN - && let Some(header) = response.headers().get(WWW_AUTHENTICATE) - { - let header_str = header.to_str().map_err(|_| { - StreamableHttpError::UnexpectedServerResponse(Cow::from( - "invalid www-authenticate header value", - )) - })?; - let scope = extract_scope_from_header(header_str); - return Err(StreamableHttpError::InsufficientScope( - InsufficientScopeError { - www_authenticate_header: header_str.to_string(), - required_scope: scope, - }, - )); - } - - if !response.status().is_success() { - return Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned( - format!("get_stream returned {}", response.status()), - ))); - } - - match response.headers().get(http::header::CONTENT_TYPE) { - Some(ct) => { - if !ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) - && !ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) - { - return Err(StreamableHttpError::UnexpectedContentType(Some( - String::from_utf8_lossy(ct.as_bytes()).to_string(), - ))); - } - } - None => { - return Err(StreamableHttpError::UnexpectedContentType(None)); - } - } - - Ok(bounded_sse_stream( - response.into_body().into_data_stream(), - max_sse_event_size, - )) - } -} - -impl StreamableHttpClientTransport { - /// Creates a new transport connecting through a Unix domain socket. - /// - /// # Arguments - /// - /// * `socket_path` - Path to the Unix domain socket. Use `@name` for Linux abstract sockets. - /// * `uri` - The MCP server URI (used for HTTP Host header and request path). - pub fn from_unix_socket(socket_path: &str, uri: impl Into>) -> Self { - let uri: Arc = uri.into(); - let client = UnixSocketHttpClient::new(socket_path, &uri); - let config = StreamableHttpClientTransportConfig { - uri, - ..Default::default() - }; - StreamableHttpClientTransport::with_client(client, config) - } - - /// Creates a new transport connecting through a Unix domain socket with custom config. - /// - /// # Arguments - /// - /// * `socket_path` - Path to the Unix domain socket. Use `@name` for Linux abstract sockets. - /// * `config` - Transport configuration (URI, retry policy, custom headers, etc.). - pub fn from_unix_socket_with_config( - socket_path: &str, - config: StreamableHttpClientTransportConfig, - ) -> Self { - let client = UnixSocketHttpClient::new(socket_path, &config.uri); - StreamableHttpClientTransport::with_client(client, config) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_abstract_socket() { - assert_eq!(resolve_socket_path("@egress.sock"), "\0egress.sock"); - } - - #[test] - fn resolve_filesystem_socket() { - assert_eq!( - resolve_socket_path("/var/run/envoy.sock"), - "/var/run/envoy.sock" - ); - } - - #[test] - fn resolve_empty_abstract() { - assert_eq!(resolve_socket_path("@"), "\0"); - } - - #[test] - #[should_panic(expected = "socket_path must not be empty")] - fn rejects_bare_at_symbol() { - UnixSocketHttpClient::new("@", "http://localhost/mcp"); - } - - #[test] - #[should_panic(expected = "socket_path must not be empty")] - fn rejects_empty_path() { - UnixSocketHttpClient::new("", "http://localhost/mcp"); - } - - #[test] - fn host_header_auto_derived() { - let client = - UnixSocketHttpClient::new("/var/run/envoy.sock", "http://mcp-server.internal/mcp"); - assert_eq!(client.host_header, "mcp-server.internal"); - } - - #[test] - fn host_header_with_port() { - let client = - UnixSocketHttpClient::new("/var/run/envoy.sock", "http://mcp-server.internal:8080/mcp"); - assert_eq!(client.host_header, "mcp-server.internal:8080"); - } - - #[test] - fn host_header_fallback_on_path_only_uri() { - let client = UnixSocketHttpClient::new("/var/run/envoy.sock", "/mcp"); - assert_eq!(client.host_header, "localhost"); - } - - #[test] - fn reserved_header_rejected() { - let mut headers = HashMap::new(); - headers.insert( - HeaderName::from_static("accept"), - HeaderValue::from_static("text/plain"), - ); - let builder = Request::builder(); - let result = apply_custom_headers(builder, headers); - assert!(matches!( - result, - Err(StreamableHttpError::ReservedHeaderConflict(_)) - )); - } - - #[test] - fn mcp_protocol_version_allowed_through() { - let mut headers = HashMap::new(); - headers.insert( - HeaderName::from_static("mcp-protocol-version"), - HeaderValue::from_static("2025-03-26"), - ); - let builder = Request::builder().uri("http://localhost/mcp").method("GET"); - let result = apply_custom_headers(builder, headers); - assert!(result.is_ok()); - } -} diff --git a/vendor/rmcp/src/transport/io.rs b/vendor/rmcp/src/transport/io.rs deleted file mode 100644 index adc2dba0e..000000000 --- a/vendor/rmcp/src/transport/io.rs +++ /dev/null @@ -1,6 +0,0 @@ -/// # StdIO Transport -/// -/// Create a pair of [`tokio::io::Stdin`] and [`tokio::io::Stdout`]. -pub fn stdio() -> (tokio::io::Stdin, tokio::io::Stdout) { - (tokio::io::stdin(), tokio::io::stdout()) -} diff --git a/vendor/rmcp/src/transport/sink_stream.rs b/vendor/rmcp/src/transport/sink_stream.rs deleted file mode 100644 index 6286b53b3..000000000 --- a/vendor/rmcp/src/transport/sink_stream.rs +++ /dev/null @@ -1,80 +0,0 @@ -use std::sync::Arc; - -use futures::{Sink, Stream}; -use tokio::sync::Mutex; - -use super::{IntoTransport, Transport}; -use crate::service::{RxJsonRpcMessage, ServiceRole, TxJsonRpcMessage}; - -pub struct SinkStreamTransport { - stream: St, - sink: Arc>, -} - -impl SinkStreamTransport { - pub fn new(sink: Si, stream: St) -> Self { - Self { - stream, - sink: Arc::new(Mutex::new(sink)), - } - } -} - -impl Transport for SinkStreamTransport -where - St: Send + Stream> + Unpin, - Si: Send + Sink> + Unpin + 'static, - Si::Error: std::error::Error + Send + Sync + 'static, -{ - type Error = Si::Error; - - fn send( - &mut self, - item: TxJsonRpcMessage, - ) -> impl Future> + Send + 'static { - use futures::SinkExt; - let lock = self.sink.clone(); - async move { - let mut write = lock.lock().await; - write.send(item).await - } - } - - fn receive(&mut self) -> impl Future>> { - use futures::StreamExt; - self.stream.next() - } - - async fn close(&mut self) -> Result<(), Self::Error> { - Ok(()) - } -} - -#[non_exhaustive] -pub enum TransportAdapterSinkStream {} - -impl IntoTransport for (Si, St) -where - Role: ServiceRole, - Si: Send + Sink> + Unpin + 'static, - St: Send + Stream> + Unpin + 'static, - Si::Error: std::error::Error + Send + Sync + 'static, -{ - fn into_transport(self) -> impl Transport + 'static { - SinkStreamTransport::new(self.0, self.1) - } -} - -#[non_exhaustive] -pub enum TransportAdapterAsyncCombinedRW {} -impl IntoTransport for S -where - Role: ServiceRole, - S: Sink> + Stream> + Send + 'static, - S::Error: std::error::Error + Send + Sync + 'static, -{ - fn into_transport(self) -> impl Transport + 'static { - use futures::StreamExt; - IntoTransport::::into_transport(self.split()) - } -} diff --git a/vendor/rmcp/src/transport/streamable_http_client.rs b/vendor/rmcp/src/transport/streamable_http_client.rs deleted file mode 100644 index fe563ee3f..000000000 --- a/vendor/rmcp/src/transport/streamable_http_client.rs +++ /dev/null @@ -1,2186 +0,0 @@ -use std::{ - borrow::Cow, - collections::{HashMap, HashSet}, - sync::Arc, - time::Duration, -}; - -use futures::{Stream, StreamExt, future::BoxFuture, stream::BoxStream}; -use http::{HeaderName, HeaderValue}; -pub use sse_stream::Error as SseError; -use sse_stream::Sse; -use thiserror::Error; -use tokio_util::sync::CancellationToken; -use tracing::debug; - -use super::common::client_side_sse::{ - DEFAULT_MAX_SSE_EVENT_SIZE, ExponentialBackoff, SseRetryPolicy, SseStreamReconnect, -}; -use crate::{ - RoleClient, - model::{ - ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorData, GetExtensions, GetMeta, - InitializedNotification, JsonObject, ProtocolVersion, RequestId, ServerJsonRpcMessage, - ServerResult, - }, - service::InboundStreamOrigin, - transport::{ - common::{client_side_sse::SseAutoReconnectStream, mcp_headers}, - worker::{Worker, WorkerQuitReason, WorkerSendRequest, WorkerTransport}, - }, -}; - -type BoxedSseStream = BoxStream<'static, Result>; -type SseTaskResult = (Option, Result<(), StreamableHttpError>); -const SESSION_CLEANUP_TIMEOUT: Duration = Duration::from_secs(5); - -fn build_request_headers( - base: &HashMap, - message: &ClientJsonRpcMessage, - tool_cache: &HashMap>, - version: &ProtocolVersion, -) -> HashMap { - use serde_json::Value; - - let mut headers = base.clone(); - if *version >= ProtocolVersion::STANDARD_HEADERS - && let Ok(value) = serde_json::to_value(message) - { - let schema = value - .get("method") - .and_then(Value::as_str) - .filter(|method| *method == "tools/call") - .and_then(|_| value.get("params")) - .and_then(|params| params.get("name")) - .and_then(Value::as_str) - .and_then(|name| tool_cache.get(name)) - .map(Arc::as_ref); - for (name, val) in mcp_headers::standard_request_headers(&value, schema) { - headers.insert(name, val); - } - } - headers -} - -fn request_version_headers( - base: &HashMap, - message: &ClientJsonRpcMessage, - fallback: &ProtocolVersion, - tool_cache: &HashMap>, -) -> (ProtocolVersion, HashMap) { - let version = match message { - ClientJsonRpcMessage::Request(request) => request - .request - .get_meta() - .protocol_version() - .unwrap_or_else(|| fallback.clone()), - _ => fallback.clone(), - }; - let mut headers = build_request_headers(base, message, tool_cache, &version); - if let Ok(value) = HeaderValue::from_str(version.as_str()) { - headers.insert(HeaderName::from_static("mcp-protocol-version"), value); - } - (version, headers) -} - -fn cache_tools_from_response( - cache: &mut HashMap>, - message: &mut ServerJsonRpcMessage, - protocol_version: &ProtocolVersion, -) { - if protocol_version < &ProtocolVersion::STANDARD_HEADERS { - return; - } - if let ServerJsonRpcMessage::Response(response) = message - && let ServerResult::ListToolsResult(list) = &mut response.result - { - list.tools.retain(|tool| { - let Err(reason) = - mcp_headers::validate_param_header_annotations(&tool.input_schema) - else { - cache.insert(tool.name.to_string(), tool.input_schema.clone()); - return true; - }; - tracing::warn!(tool = %tool.name, "rejecting invalid x-mcp-header annotations: {reason}"); - false - }); - } -} - -fn negotiate_version_headers( - init_response: &ServerJsonRpcMessage, - base: HashMap, -) -> (ProtocolVersion, HashMap) { - let mut version = ProtocolVersion::default(); - let mut headers = base; - if let ServerJsonRpcMessage::Response(response) = init_response - && let ServerResult::InitializeResult(init_result) = &response.result - { - version = init_result.protocol_version.clone(); - // HeaderName::from_static requires lowercase - if let Ok(hv) = HeaderValue::from_str(init_result.protocol_version.as_str()) { - headers.insert(HeaderName::from_static("mcp-protocol-version"), hv); - } - } - (version, headers) -} - -#[derive(Debug, Error)] -#[error("authorization required: {www_authenticate_header}")] -#[non_exhaustive] -pub struct AuthRequiredError { - pub www_authenticate_header: String, -} - -impl AuthRequiredError { - /// Create a new `AuthRequiredError` instance. - pub fn new(www_authenticate_header: String) -> Self { - Self { - www_authenticate_header, - } - } -} - -#[derive(Debug, Error)] -#[error("insufficient scope: {www_authenticate_header}")] -#[non_exhaustive] -pub struct InsufficientScopeError { - pub www_authenticate_header: String, - pub required_scope: Option, -} - -impl InsufficientScopeError { - /// Create a new `InsufficientScopeError` instance. - pub fn new(www_authenticate_header: String, required_scope: Option) -> Self { - Self { - www_authenticate_header, - required_scope, - } - } - - /// check if scope upgrade is possible (i.e., we know what scope is required) - pub fn can_upgrade(&self) -> bool { - self.required_scope.is_some() - } - - /// get the required scope for upgrade - pub fn get_required_scope(&self) -> Option<&str> { - self.required_scope.as_deref() - } -} - -#[derive(Error, Debug)] -#[non_exhaustive] -pub enum StreamableHttpError { - #[error("SSE error: {0}")] - Sse(#[from] SseError), - #[error("Io error: {0}")] - Io(#[from] std::io::Error), - #[error("Client error: {0}")] - Client(E), - #[error("unexpected end of stream")] - UnexpectedEndOfStream, - #[error("unexpected server response: {0}")] - UnexpectedServerResponse(Cow<'static, str>), - #[error("Unexpected content type: {0:?}")] - UnexpectedContentType(Option), - #[error("Server does not support SSE")] - ServerDoesNotSupportSse, - #[error("Server does not support delete session")] - ServerDoesNotSupportDeleteSession, - #[error("Tokio join error: {0}")] - TokioJoinError(#[from] tokio::task::JoinError), - #[error("Deserialize error: {0}")] - Deserialize(#[from] serde_json::Error), - #[error("Transport channel closed")] - TransportChannelClosed, - #[error("Missing session id in HTTP response")] - MissingSessionIdInResponse, - #[cfg(feature = "auth")] - #[error("Auth error: {0}")] - Auth(#[from] crate::transport::auth::AuthError), - #[error("Auth required")] - AuthRequired(#[source] AuthRequiredError), - #[error("Insufficient scope")] - InsufficientScope(#[source] InsufficientScopeError), - #[error("Header name '{0}' is reserved and conflicts with default headers")] - ReservedHeaderConflict(String), - #[error("Session expired (HTTP 404)")] - SessionExpired, -} - -impl StreamableHttpError { - /// The `WWW-Authenticate` challenge carried by this error, when the - /// server answered 401 ([`AuthRequired`](Self::AuthRequired)) or 403 - /// ([`InsufficientScope`](Self::InsufficientScope)). Feed it to - /// [`AuthorizationRequest::with_challenge`](crate::transport::auth::AuthorizationRequest::with_challenge) - /// to authorize reactively. - #[cfg(feature = "auth")] - pub fn auth_challenge(&self) -> Option<&str> { - match self { - Self::AuthRequired(error) => Some(&error.www_authenticate_header), - Self::InsufficientScope(error) => Some(&error.www_authenticate_header), - _ => None, - } - } -} - -#[derive(Debug, Clone, Error)] -#[non_exhaustive] -pub enum StreamableHttpProtocolError { - #[error("Missing session id in response")] - MissingSessionIdInResponse, -} - -#[expect( - clippy::large_enum_variant, - reason = "boxing the streaming response would add an allocation to the common response path" -)] -#[non_exhaustive] -pub enum StreamableHttpPostResponse { - Accepted, - Json(ServerJsonRpcMessage, Option), - Sse(BoxedSseStream, Option), -} - -impl std::fmt::Debug for StreamableHttpPostResponse { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Accepted => write!(f, "Accepted"), - Self::Json(arg0, arg1) => f.debug_tuple("Json").field(arg0).field(arg1).finish(), - Self::Sse(_, arg1) => f.debug_tuple("Sse").field(arg1).finish(), - } - } -} - -impl StreamableHttpPostResponse { - pub async fn expect_initialized( - self, - ) -> Result<(ServerJsonRpcMessage, Option), StreamableHttpError> - where - E: std::error::Error + Send + Sync + 'static, - { - match self { - Self::Json(message, session_id) => Ok((message, session_id)), - Self::Sse(mut stream, session_id) => { - while let Some(event) = stream.next().await { - let event = event?; - let payload = event.data.unwrap_or_default(); - if payload.trim().is_empty() { - continue; - } - - let message: ServerJsonRpcMessage = serde_json::from_str(&payload)?; - - if matches!(message, ServerJsonRpcMessage::Response(_)) { - return Ok((message, session_id)); - } - - debug!( - ?message, - "received message before initialize response; continuing to drain stream" - ); - } - - Err(StreamableHttpError::UnexpectedServerResponse( - "empty sse stream".into(), - )) - } - _ => Err(StreamableHttpError::UnexpectedServerResponse( - "expect initialized, accepted".into(), - )), - } - } - - pub fn expect_json(self) -> Result> - where - E: std::error::Error + Send + Sync + 'static, - { - match self { - Self::Json(message, ..) => Ok(message), - got => Err(StreamableHttpError::UnexpectedServerResponse( - format!("expect json, got {got:?}").into(), - )), - } - } - - pub fn expect_accepted_or_json(self) -> Result<(), StreamableHttpError> - where - E: std::error::Error + Send + Sync + 'static, - { - match self { - Self::Accepted => Ok(()), - // Tolerate servers that return 200 with JSON for notifications - Self::Json(..) => Ok(()), - got => Err(StreamableHttpError::UnexpectedServerResponse( - format!("expect accepted or json, got {got:?}").into(), - )), - } - } -} - -/// HTTP backend used by [`StreamableHttpClientTransport`]. -/// -/// Custom implementations that parse SSE responses must override -/// [`Self::post_message_with_max_sse_event_size`] and -/// [`Self::get_stream_with_max_sse_event_size`] to enforce the transport's -/// configured event-size limit. -pub trait StreamableHttpClient: Clone + Send + 'static { - type Error: std::error::Error + Send + Sync + 'static; - fn post_message( - &self, - uri: Arc, - message: ClientJsonRpcMessage, - session_id: Option>, - auth_header: Option, - custom_headers: HashMap, - ) -> impl Future>> - + Send - + '_; - /// Send a message while enforcing a maximum raw SSE event size. - /// - /// `max_sse_event_size` is not a per-request option: it is the - /// transport-wide [`StreamableHttpClientTransportConfig::max_sse_event_size`] - /// value, passed identically on every call because the limit must be applied - /// inside the client (at the raw byte layer, before SSE parsing) rather than - /// by the caller. - /// - /// Custom clients that parse SSE responses should override this method. - /// The default implementation delegates to [`Self::post_message`]. - fn post_message_with_max_sse_event_size( - &self, - uri: Arc, - message: ClientJsonRpcMessage, - session_id: Option>, - auth_header: Option, - custom_headers: HashMap, - _max_sse_event_size: usize, - ) -> impl Future>> - + Send - + '_ { - self.post_message(uri, message, session_id, auth_header, custom_headers) - } - fn delete_session( - &self, - uri: Arc, - session_id: Arc, - auth_header: Option, - custom_headers: HashMap, - ) -> impl Future>> + Send + '_; - /// Open an SSE stream, optionally scoped to a legacy session. - /// - /// `session_id` is `None` when resuming a stateless response using only - /// `last_event_id`. - fn get_stream( - &self, - uri: Arc, - session_id: Option>, - last_event_id: Option, - auth_header: Option, - custom_headers: HashMap, - ) -> impl Future< - Output = Result< - BoxStream<'static, Result>, - StreamableHttpError, - >, - > + Send - + '_; - /// Open an SSE stream while enforcing a maximum raw event size. - /// - /// `max_sse_event_size` is not a per-request option: it is the - /// transport-wide [`StreamableHttpClientTransportConfig::max_sse_event_size`] - /// value, passed identically on every call because the limit must be applied - /// inside the client (at the raw byte layer, before SSE parsing) rather than - /// by the caller. - /// - /// Custom clients that parse SSE responses should override this method. - /// The default implementation delegates to [`Self::get_stream`]. - fn get_stream_with_max_sse_event_size( - &self, - uri: Arc, - session_id: Option>, - last_event_id: Option, - auth_header: Option, - custom_headers: HashMap, - _max_sse_event_size: usize, - ) -> impl Future< - Output = Result< - BoxStream<'static, Result>, - StreamableHttpError, - >, - > + Send - + '_ { - self.get_stream(uri, session_id, last_event_id, auth_header, custom_headers) - } -} - -#[non_exhaustive] -pub struct RetryConfig { - pub max_times: Option, - pub min_duration: Duration, -} - -struct StreamableHttpClientReconnect { - pub client: C, - pub session_id: Option>, - pub uri: Arc, - pub auth_header: Option, - pub custom_headers: HashMap, - pub max_sse_event_size: usize, -} - -impl SseStreamReconnect for StreamableHttpClientReconnect { - type Error = StreamableHttpError; - type Future = BoxFuture<'static, Result>; - fn retry_connection(&mut self, last_event_id: Option<&str>) -> Self::Future { - let client = self.client.clone(); - let uri = self.uri.clone(); - let session_id = self.session_id.clone(); - let auth_header = self.auth_header.clone(); - let custom_headers = self.custom_headers.clone(); - let max_sse_event_size = self.max_sse_event_size; - let last_event_id = last_event_id.map(|s| s.to_owned()); - Box::pin(async move { - client - .get_stream_with_max_sse_event_size( - uri, - session_id, - last_event_id, - auth_header, - custom_headers, - max_sse_event_size, - ) - .await - }) - } - - fn map_fatal_stream_error(&mut self, error: SseError) -> Option { - Some(StreamableHttpError::Sse(error)) - } -} - -/// Info retained for cleaning up the session when the worker exits. -struct SessionCleanupInfo { - client: C, - uri: Arc, - session_id: Arc, - auth_header: Option, - protocol_headers: HashMap, -} - -#[derive(Debug, Clone, Default)] -#[non_exhaustive] -pub struct StreamableHttpClientWorker { - pub client: C, - pub config: StreamableHttpClientTransportConfig, -} - -impl StreamableHttpClientWorker { - pub fn new_simple(url: impl Into>) -> Self { - Self { - client: C::default(), - config: StreamableHttpClientTransportConfig { - uri: url.into(), - ..Default::default() - }, - } - } -} - -impl StreamableHttpClientWorker { - pub fn new(client: C, config: StreamableHttpClientTransportConfig) -> Self { - Self { client, config } - } -} - -impl StreamableHttpClientWorker { - fn client_request_id(message: &ClientJsonRpcMessage) -> Option { - match message { - ClientJsonRpcMessage::Request(request) => Some(request.id.clone()), - _ => None, - } - } - - fn server_response_id(message: &ServerJsonRpcMessage) -> Option<&RequestId> { - match message { - ServerJsonRpcMessage::Response(response) => Some(&response.id), - ServerJsonRpcMessage::Error(error) => error.id.as_ref(), - _ => None, - } - } - - fn mark_stream_response_pending( - pending_stream_response_ids: &mut HashSet, - request_id: Option, - ) { - if let Some(request_id) = request_id { - pending_stream_response_ids.insert(request_id); - } - } - - fn clear_stream_response_pending( - pending_stream_response_ids: &mut HashSet, - message: &ServerJsonRpcMessage, - ) { - let Some(response_id) = Self::server_response_id(message) else { - return; - }; - if pending_stream_response_ids.remove(response_id) { - return; - } - if let Some(id) = response_id.numeric_string_value() { - pending_stream_response_ids.remove(&RequestId::Number(id)); - } - } - - async fn drain_queued_stream_messages( - sse_worker_rx: &mut tokio::sync::mpsc::Receiver, - context: &mut super::worker::WorkerContext, - pending_stream_response_ids: &mut HashSet, - ) -> Result<(), WorkerQuitReason>> { - loop { - match sse_worker_rx.try_recv() { - Ok(message) => { - Self::clear_stream_response_pending(pending_stream_response_ids, &message); - context.send_to_handler(message).await?; - } - Err(tokio::sync::mpsc::error::TryRecvError::Empty) => return Ok(()), - Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => return Ok(()), - } - } - } - - async fn fail_pending_stream_responses( - context: &mut super::worker::WorkerContext, - pending_stream_response_ids: &mut HashSet, - ) -> Result<(), WorkerQuitReason>> { - if pending_stream_response_ids.is_empty() { - return Ok(()); - } - - let pending_ids = std::mem::take(pending_stream_response_ids); - for id in pending_ids { - context - .send_to_handler(ServerJsonRpcMessage::error( - ErrorData::internal_error( - "streamable HTTP session was re-initialized before the response arrived", - None, - ), - Some(id), - )) - .await?; - } - Ok(()) - } - - /// Convert an SSE stream into JSON-RPC messages with reconnect semantics. - /// - /// This is used for request-scoped SSE responses as well as the standalone - /// GET stream. A request-scoped stream can close before its response arrives, - /// and SEP-1699 requires the client to honor `retry` and resume with - /// `Last-Event-ID` in that case. - fn reconnecting_sse_to_jsonrpc( - stream: BoxedSseStream, - client: C, - session_id: Option>, - uri: Arc, - auth_header: Option, - custom_headers: HashMap, - max_sse_event_size: usize, - retry_config: Arc, - ) -> impl Stream>> + Send + 'static - { - SseAutoReconnectStream::new_after_event_id( - stream, - StreamableHttpClientReconnect { - client, - session_id, - uri, - auth_header, - custom_headers, - max_sse_event_size, - }, - retry_config, - ) - } - - /// Convert a POST response SSE stream into JSON-RPC messages. - /// - /// Request-scoped streams resume via GET once the server has supplied an - /// event ID. The session header remains optional for stateless transports. - fn response_sse_to_jsonrpc( - stream: BoxedSseStream, - session_id: Option>, - client: C, - uri: Arc, - auth_header: Option, - custom_headers: HashMap, - max_sse_event_size: usize, - retry_config: Arc, - ) -> BoxStream<'static, Result>> { - Self::reconnecting_sse_to_jsonrpc( - stream, - client, - session_id, - uri, - auth_header, - custom_headers, - max_sse_event_size, - retry_config, - ) - .boxed() - } - - async fn execute_sse_stream( - sse_stream: impl Stream>> - + Send - + 'static, - sse_worker_tx: tokio::sync::mpsc::Sender, - origin: InboundStreamOrigin, - close_on_response: bool, - ct: CancellationToken, - ) -> Result<(), StreamableHttpError> { - let mut sse_stream = std::pin::pin!(sse_stream); - loop { - let message = tokio::select! { - event = sse_stream.next() => { - event - } - _ = ct.cancelled() => { - tracing::debug!("cancelled"); - break; - } - }; - let Some(mut message) = message.transpose()? else { - break; - }; - // SEP-2260: mark inbound requests with the stream they arrived on - // for the client receive-side association check. - if let ServerJsonRpcMessage::Request(request) = &mut message { - request.request.extensions_mut().insert(origin.clone()); - } - let is_response = matches!( - message, - ServerJsonRpcMessage::Response(_) | ServerJsonRpcMessage::Error(_) - ); - let yield_result = sse_worker_tx.send(message).await; - if yield_result.is_err() { - tracing::trace!("streamable http transport worker dropped, exiting"); - break; - } - if close_on_response && is_response { - tracing::debug!("got response, draining sse stream for connection reuse"); - // Consume the remaining stream so the HTTP/1.1 connection - // returns to the pool cleanly. - let _ = tokio::time::timeout(std::time::Duration::from_millis(50), async { - while sse_stream.next().await.is_some() {} - }) - .await; - break; - } - } - Ok(()) - } - - fn spawn_common_stream( - streams: &mut tokio::task::JoinSet>, - client: C, - session_id: Arc, - config: &StreamableHttpClientTransportConfig, - protocol_headers: HashMap, - sse_worker_tx: tokio::sync::mpsc::Sender, - transport_task_ct: CancellationToken, - ) { - let uri = config.uri.clone(); - let auth_header = config.auth_header.clone(); - let retry_config = config.retry_config.clone(); - let reconnect_uri = config.uri.clone(); - let reconnect_auth_header = config.auth_header.clone(); - let max_sse_event_size = config.max_sse_event_size; - - streams.spawn(async move { - let result = match client - .get_stream_with_max_sse_event_size( - uri, - Some(session_id.clone()), - None, - auth_header, - protocol_headers.clone(), - max_sse_event_size, - ) - .await - { - Ok(stream) => { - let sse_stream = SseAutoReconnectStream::new( - stream, - StreamableHttpClientReconnect { - client, - session_id: Some(session_id), - uri: reconnect_uri, - auth_header: reconnect_auth_header, - custom_headers: protocol_headers, - max_sse_event_size, - }, - retry_config, - ); - Self::execute_sse_stream( - sse_stream, - sse_worker_tx, - InboundStreamOrigin::Unassociated, - false, - transport_task_ct.child_token(), - ) - .await - } - Err(StreamableHttpError::ServerDoesNotSupportSse) => { - tracing::debug!("server doesn't support sse, skip common stream"); - Ok(()) - } - Err(error) => { - tracing::error!("fail to get common stream: {error}"); - Err(error) - } - }; - (None, result) - }); - } - - /// Performs a transparent re-initialization handshake after a session-expired 404. - /// - /// Takes an owned clone of the client (avoiding `&self` across `.await` so the - /// future remains `Send` without requiring `C: Sync`). POSTs the saved - /// initialize request without a session ID, extracts the new session ID and - /// protocol version, sends `notifications/initialized`, and returns the new - /// `(session_id, protocol_headers)` pair. The init result message is **not** - /// forwarded to the handler because the handler already processed the original - /// initialization. - async fn perform_reinitialization( - client: C, - saved_init_request: ClientJsonRpcMessage, - uri: Arc, - auth_header: Option, - custom_headers: HashMap, - max_sse_event_size: usize, - ) -> Result< - ( - Option>, - ProtocolVersion, - HashMap, - ), - StreamableHttpError, - > { - let (init_msg, new_session_id_str) = client - .post_message_with_max_sse_event_size( - uri.clone(), - saved_init_request, - None, - auth_header.clone(), - custom_headers.clone(), - max_sse_event_size, - ) - .await? - .expect_initialized::() - .await?; - - let new_session_id: Option> = new_session_id_str.map(|s| Arc::from(s.as_str())); - - let (negotiated_version, new_protocol_headers) = - negotiate_version_headers(&init_msg, custom_headers); - - let initialized_notification = ClientJsonRpcMessage::notification( - ClientNotification::InitializedNotification(InitializedNotification { - method: Default::default(), - extensions: Default::default(), - }), - ); - // SEP-2243: notifications carry no Mcp-Param-*, so an empty tool cache suffices. - let initialized_headers = build_request_headers( - &new_protocol_headers, - &initialized_notification, - &HashMap::new(), - &negotiated_version, - ); - client - .post_message_with_max_sse_event_size( - uri, - initialized_notification, - new_session_id.clone(), - auth_header, - initialized_headers, - max_sse_event_size, - ) - .await? - .expect_accepted_or_json::()?; - - Ok((new_session_id, negotiated_version, new_protocol_headers)) - } -} - -impl Worker for StreamableHttpClientWorker { - type Role = RoleClient; - type Error = StreamableHttpError; - fn err_closed() -> Self::Error { - StreamableHttpError::TransportChannelClosed - } - fn err_join(e: tokio::task::JoinError) -> Self::Error { - StreamableHttpError::TokioJoinError(e) - } - fn config(&self) -> super::worker::WorkerConfig { - super::worker::WorkerConfig { - name: Some("StreamableHttpClientWorker".into()), - channel_buffer_capacity: self.config.channel_buffer_capacity, - } - } - async fn run( - self, - mut context: super::worker::WorkerContext, - ) -> Result<(), WorkerQuitReason> { - let channel_buffer_capacity = self.config.channel_buffer_capacity; - let (sse_worker_tx, mut sse_worker_rx) = - tokio::sync::mpsc::channel::(channel_buffer_capacity); - let config = self.config.clone(); - let transport_task_ct = context.cancellation_token.clone(); - let _drop_guard = transport_task_ct.clone().drop_guard(); - let WorkerSendRequest { - responder, - message: startup_request, - } = context.recv_from_handler().await?; - let is_legacy_startup = matches!( - &startup_request, - ClientJsonRpcMessage::Request(request) - if matches!(&request.request, ClientRequest::InitializeRequest(_)) - ); - let mut saved_init_request = is_legacy_startup.then(|| startup_request.clone()); - let empty_tool_cache = HashMap::new(); - let (bootstrap_version, bootstrap_headers) = if is_legacy_startup { - (ProtocolVersion::default(), config.custom_headers.clone()) - } else { - request_version_headers( - &config.custom_headers, - &startup_request, - &ProtocolVersion::default(), - &empty_tool_cache, - ) - }; - let (message, session_id) = match self - .client - .post_message_with_max_sse_event_size( - config.uri.clone(), - startup_request, - None, - config.auth_header.clone(), - bootstrap_headers.clone(), - config.max_sse_event_size, - ) - .await - { - Ok(res) => { - let _ = responder.send(Ok(())); - res.expect_initialized::().await.map_err( - WorkerQuitReason::fatal_context("process initialize response"), - )? - } - Err(err) => { - let msg = format!("{:?}", err); - let _ = responder.send(Err(err)); - return Err(WorkerQuitReason::fatal( - StreamableHttpError::TransportChannelClosed, - msg, - )); - } - }; - let mut uses_modern_http = !is_legacy_startup; - let mut session_id: Option> = if uses_modern_http { - None - } else if let Some(session_id) = session_id { - Some(session_id.into()) - } else { - if !self.config.allow_stateless { - return Err(WorkerQuitReason::fatal( - StreamableHttpError::::MissingSessionIdInResponse, - "process initialize response", - )); - } - None - }; - let (mut negotiated_version, mut protocol_headers) = if is_legacy_startup { - negotiate_version_headers(&message, config.custom_headers.clone()) - } else { - (bootstrap_version, bootstrap_headers) - }; - // SEP-2243: tool input schemas (name -> schema) cached from tools/list responses, - // used to promote annotated tools/call arguments to Mcp-Param-* headers. - let mut tool_header_cache: HashMap> = HashMap::new(); - - // Store session info for cleanup when run() exits (not spawned, so cleanup completes before close() returns) - let mut session_cleanup_info = session_id.as_ref().map(|sid| SessionCleanupInfo { - client: self.client.clone(), - uri: config.uri.clone(), - session_id: sid.clone(), - auth_header: config.auth_header.clone(), - protocol_headers: protocol_headers.clone(), - }); - - context.send_to_handler(message).await?; - if is_legacy_startup { - let initialized_notification = context.recv_from_handler().await?; - let initialized_headers = build_request_headers( - &protocol_headers, - &initialized_notification.message, - &tool_header_cache, - &negotiated_version, - ); - self.client - .post_message_with_max_sse_event_size( - config.uri.clone(), - initialized_notification.message, - session_id.clone(), - config.auth_header.clone(), - initialized_headers, - config.max_sse_event_size, - ) - .await - .map_err(WorkerQuitReason::fatal_context( - "send initialized notification", - ))? - .expect_accepted_or_json::() - .map_err(WorkerQuitReason::fatal_context( - "process initialized notification response", - ))?; - let _ = initialized_notification.responder.send(Ok(())); - } - #[expect( - clippy::large_enum_variant, - reason = "the event is short-lived and boxing would add allocation in the event loop" - )] - enum Event { - ClientMessage(WorkerSendRequest), - ServerMessage(ServerJsonRpcMessage), - StreamResult { - request_id: Option, - result: Result<(), StreamableHttpError>, - }, - } - let mut streams = tokio::task::JoinSet::new(); - let mut pending_stream_response_ids = HashSet::new(); - let mut request_stream_cancellations = HashMap::::new(); - let mut awaiting_fallback_initialized = false; - if let Some(session_id) = &session_id { - Self::spawn_common_stream( - &mut streams, - self.client.clone(), - session_id.clone(), - &config, - protocol_headers.clone(), - sse_worker_tx.clone(), - transport_task_ct.clone(), - ); - } - // Main event loop - capture exit reason so we can do cleanup before returning - let loop_result: Result<(), WorkerQuitReason> = 'main_loop: loop { - let event = tokio::select! { - _ = transport_task_ct.cancelled() => { - tracing::debug!("cancelled"); - break 'main_loop Err(WorkerQuitReason::Cancelled); - } - message = context.recv_from_handler() => { - match message { - Ok(msg) => Event::ClientMessage(msg), - Err(e) => break 'main_loop Err(e), - } - }, - message = sse_worker_rx.recv() => { - let Some(message) = message else { - tracing::trace!("transport dropped, exiting"); - break 'main_loop Err(WorkerQuitReason::HandlerTerminated); - }; - Event::ServerMessage(message) - }, - terminated_stream = streams.join_next(), if !streams.is_empty() => { - match terminated_stream { - Some(result) => { - match result { - Ok((request_id, result)) => { - Event::StreamResult { request_id, result } - } - Err(error) => Event::StreamResult { - request_id: None, - result: Err(StreamableHttpError::TokioJoinError(error)), - }, - } - } - None => { - continue - } - } - } - }; - match event { - Event::ClientMessage(send_request) => { - let WorkerSendRequest { message, responder } = send_request; - let cancellation_request_id = match &message { - ClientJsonRpcMessage::Notification(notification) => { - match ¬ification.notification { - ClientNotification::CancelledNotification(cancelled) => { - cancelled.params.request_id.clone() - } - _ => None, - } - } - _ => None, - }; - if uses_modern_http && let Some(request_id) = cancellation_request_id { - if let Some(stream_ct) = request_stream_cancellations.remove(&request_id) { - stream_ct.cancel(); - } - pending_stream_response_ids.remove(&request_id); - let _ = responder.send(Ok(())); - continue; - } - let is_fallback_initialize = saved_init_request.is_none() - && matches!( - &message, - ClientJsonRpcMessage::Request(request) - if matches!( - &request.request, - ClientRequest::InitializeRequest(_) - ) - ); - if is_fallback_initialize { - saved_init_request = Some(message.clone()); - // Servers do not assign sessions to `server/discover`, so a - // fallback initialize starts from a clean slate: no session - // ID, no cleanup state, and no streams to tear down. - debug_assert!( - session_id.is_none() - && session_cleanup_info.is_none() - && streams.is_empty(), - "discover bootstrap must not create session state" - ); - uses_modern_http = false; - - let response = self - .client - .post_message_with_max_sse_event_size( - config.uri.clone(), - message, - None, - config.auth_header.clone(), - config.custom_headers.clone(), - config.max_sse_event_size, - ) - .await; - let response = match response { - Ok(response) => { - let _ = responder.send(Ok(())); - response - } - Err(error) => { - let _ = responder.send(Err(error)); - continue; - } - }; - let (initialize_response, new_session_id) = response - .expect_initialized::() - .await - .map_err(WorkerQuitReason::fatal_context( - "process fallback initialize response", - ))?; - session_id = new_session_id.map(Arc::from); - if session_id.is_none() && !config.allow_stateless { - return Err(WorkerQuitReason::fatal( - StreamableHttpError::::MissingSessionIdInResponse, - "process fallback initialize response", - )); - } - (negotiated_version, protocol_headers) = negotiate_version_headers( - &initialize_response, - config.custom_headers.clone(), - ); - session_cleanup_info = - session_id.as_ref().map(|session_id| SessionCleanupInfo { - client: self.client.clone(), - uri: config.uri.clone(), - session_id: session_id.clone(), - auth_header: config.auth_header.clone(), - protocol_headers: protocol_headers.clone(), - }); - context.send_to_handler(initialize_response).await?; - awaiting_fallback_initialized = true; - continue; - } - - let request_id = Self::client_request_id(&message); - let inline_version = match &message { - ClientJsonRpcMessage::Request(request) => { - request.request.get_meta().protocol_version() - } - _ => None, - }; - let is_initialized_notification = matches!( - &message, - ClientJsonRpcMessage::Notification(notification) - if matches!( - ¬ification.notification, - ClientNotification::InitializedNotification(_) - ) - ); - // Pass a clone to the first attempt so `message` is retained for a - // potential re-init retry. `post_message` takes ownership and the - // trait cannot be changed, so the clone is unavoidable. - let (request_version, request_headers) = request_version_headers( - &protocol_headers, - &message, - &negotiated_version, - &tool_header_cache, - ); - if inline_version.is_some() { - negotiated_version = request_version.clone(); - if let Ok(value) = HeaderValue::from_str(request_version.as_str()) { - protocol_headers - .insert(HeaderName::from_static("mcp-protocol-version"), value); - } - if let Some(cleanup) = &mut session_cleanup_info { - cleanup.protocol_headers = protocol_headers.clone(); - } - } - let response = self - .client - .post_message_with_max_sse_event_size( - config.uri.clone(), - message.clone(), - session_id.clone(), - config.auth_header.clone(), - request_headers, - config.max_sse_event_size, - ) - .await; - let send_result = match response { - Err(StreamableHttpError::SessionExpired) => { - if let Some(saved_init_request) = saved_init_request - .as_ref() - .filter(|_| config.reinit_on_expired_session) - { - // The server discarded the session (HTTP 404). Perform a - // fresh handshake once and replay the original message. - tracing::info!( - "session expired (HTTP 404), attempting transparent re-initialization" - ); - match Self::perform_reinitialization( - self.client.clone(), - saved_init_request.clone(), - config.uri.clone(), - config.auth_header.clone(), - config.custom_headers.clone(), - config.max_sse_event_size, - ) - .await - { - Ok(( - new_session_id, - new_negotiated_version, - new_protocol_headers, - )) => { - // Old streams hold the stale session ID. Stop them first - // so no late stale-session messages can arrive after the - // pending requests below are completed. - streams.abort_all(); - while streams.join_next().await.is_some() {} - - // Forward any already queued response messages and fail - // the remaining accepted requests so callers do not wait - // forever for responses that can no longer arrive. - Self::drain_queued_stream_messages( - &mut sse_worker_rx, - &mut context, - &mut pending_stream_response_ids, - ) - .await?; - Self::fail_pending_stream_responses( - &mut context, - &mut pending_stream_response_ids, - ) - .await?; - - session_id = new_session_id; - negotiated_version = new_negotiated_version; - protocol_headers = new_protocol_headers; - session_cleanup_info = - session_id.as_ref().map(|sid| SessionCleanupInfo { - client: self.client.clone(), - uri: config.uri.clone(), - session_id: sid.clone(), - auth_header: config.auth_header.clone(), - protocol_headers: protocol_headers.clone(), - }); - - if let Some(new_sid) = &session_id { - Self::spawn_common_stream( - &mut streams, - self.client.clone(), - new_sid.clone(), - &config, - protocol_headers.clone(), - sse_worker_tx.clone(), - transport_task_ct.clone(), - ); - } - - let (_, retry_headers) = request_version_headers( - &protocol_headers, - &message, - &negotiated_version, - &tool_header_cache, - ); - let retry_response = self - .client - .post_message_with_max_sse_event_size( - config.uri.clone(), - message, - session_id.clone(), - config.auth_header.clone(), - retry_headers, - config.max_sse_event_size, - ) - .await; - match retry_response { - Err(e) => Err(e), - Ok(StreamableHttpPostResponse::Accepted) => { - Self::mark_stream_response_pending( - &mut pending_stream_response_ids, - request_id, - ); - tracing::trace!( - "client message accepted after re-init" - ); - Ok(()) - } - Ok(StreamableHttpPostResponse::Json(mut msg, ..)) => { - cache_tools_from_response( - &mut tool_header_cache, - &mut msg, - &negotiated_version, - ); - context.send_to_handler(msg).await?; - Ok(()) - } - Ok(StreamableHttpPostResponse::Sse(stream, ..)) => { - let stream_request_id = request_id.clone(); - Self::mark_stream_response_pending( - &mut pending_stream_response_ids, - request_id, - ); - let sse_stream = Self::response_sse_to_jsonrpc( - stream, - session_id.clone(), - self.client.clone(), - config.uri.clone(), - config.auth_header.clone(), - protocol_headers.clone(), - config.max_sse_event_size, - self.config.retry_config.clone(), - ); - let stream_ct = transport_task_ct.child_token(); - if uses_modern_http - && let Some(request_id) = - stream_request_id.as_ref() - { - request_stream_cancellations.insert( - request_id.clone(), - stream_ct.clone(), - ); - } - let stream_tx = sse_worker_tx.clone(); - let origin = match &stream_request_id { - Some(id) => { - InboundStreamOrigin::OutboundRequest( - id.clone(), - ) - } - None => InboundStreamOrigin::Unassociated, - }; - streams.spawn(async move { - let result = Self::execute_sse_stream( - sse_stream, stream_tx, origin, true, - stream_ct, - ) - .await; - (stream_request_id, result) - }); - tracing::trace!("got new sse stream after re-init"); - Ok(()) - } - } - } - Err(reinit_err) => Err(reinit_err), - } - } else { - Err(StreamableHttpError::SessionExpired) - } - } - Err(e) => Err(e), - Ok(StreamableHttpPostResponse::Accepted) => { - Self::mark_stream_response_pending( - &mut pending_stream_response_ids, - request_id, - ); - tracing::trace!("client message accepted"); - Ok(()) - } - Ok(StreamableHttpPostResponse::Json(mut message, ..)) => { - cache_tools_from_response( - &mut tool_header_cache, - &mut message, - &negotiated_version, - ); - context.send_to_handler(message).await?; - Ok(()) - } - Ok(StreamableHttpPostResponse::Sse(stream, ..)) => { - let stream_request_id = request_id.clone(); - Self::mark_stream_response_pending( - &mut pending_stream_response_ids, - request_id, - ); - let sse_stream = Self::response_sse_to_jsonrpc( - stream, - session_id.clone(), - self.client.clone(), - config.uri.clone(), - config.auth_header.clone(), - protocol_headers.clone(), - config.max_sse_event_size, - self.config.retry_config.clone(), - ); - let stream_ct = transport_task_ct.child_token(); - if uses_modern_http && let Some(request_id) = stream_request_id.as_ref() - { - request_stream_cancellations - .insert(request_id.clone(), stream_ct.clone()); - } - let stream_tx = sse_worker_tx.clone(); - let origin = match &stream_request_id { - Some(id) => InboundStreamOrigin::OutboundRequest(id.clone()), - None => InboundStreamOrigin::Unassociated, - }; - streams.spawn(async move { - let result = Self::execute_sse_stream( - sse_stream, stream_tx, origin, true, stream_ct, - ) - .await; - (stream_request_id, result) - }); - tracing::trace!("got new sse stream"); - Ok(()) - } - }; - if send_result.is_ok() - && awaiting_fallback_initialized - && is_initialized_notification - { - if let Some(session_id) = &session_id { - Self::spawn_common_stream( - &mut streams, - self.client.clone(), - session_id.clone(), - &config, - protocol_headers.clone(), - sse_worker_tx.clone(), - transport_task_ct.clone(), - ); - } - awaiting_fallback_initialized = false; - } - let _ = responder.send(send_result); - } - Event::ServerMessage(mut json_rpc_message) => { - if let Some(response_id) = Self::server_response_id(&json_rpc_message) - && let Some(stream_ct) = crate::service::remove_pending_request( - &mut request_stream_cancellations, - response_id, - ) - { - stream_ct.cancel(); - } - Self::clear_stream_response_pending( - &mut pending_stream_response_ids, - &json_rpc_message, - ); - cache_tools_from_response( - &mut tool_header_cache, - &mut json_rpc_message, - &negotiated_version, - ); - // send the message to the handler - if let Err(e) = context.send_to_handler(json_rpc_message).await { - break 'main_loop Err(e); - } - } - Event::StreamResult { request_id, result } => { - if let Some(request_id) = request_id { - Self::drain_queued_stream_messages( - &mut sse_worker_rx, - &mut context, - &mut pending_stream_response_ids, - ) - .await?; - request_stream_cancellations.remove(&request_id); - if pending_stream_response_ids.remove(&request_id) { - context - .send_to_handler(ServerJsonRpcMessage::error( - ErrorData::transport_closed( - "streamable HTTP response stream closed before its final response", - ), - Some(request_id), - )) - .await?; - } - } - if result.is_err() { - tracing::warn!( - "sse client event stream terminated with error: {:?}", - result - ); - } - } - } - }; - - // Cleanup session before returning (ensures close() waits for session deletion) - // Use a timeout to prevent indefinite hangs if the server is unresponsive - if let Some(cleanup) = session_cleanup_info { - let cleanup_session_id = cleanup.session_id.clone(); - match tokio::time::timeout( - SESSION_CLEANUP_TIMEOUT, - cleanup.client.delete_session( - cleanup.uri, - cleanup.session_id, - cleanup.auth_header, - cleanup.protocol_headers, - ), - ) - .await - { - Ok(Ok(_)) => { - tracing::info!( - session_id = cleanup_session_id.as_ref(), - "delete session success" - ) - } - Ok(Err(StreamableHttpError::ServerDoesNotSupportDeleteSession)) => { - tracing::info!( - session_id = cleanup_session_id.as_ref(), - "server doesn't support delete session" - ) - } - Ok(Err(e)) => { - tracing::error!( - session_id = cleanup_session_id.as_ref(), - "fail to delete session: {e}" - ); - } - Err(_elapsed) => { - tracing::warn!( - session_id = cleanup_session_id.as_ref(), - "session cleanup timed out after {:?}", - SESSION_CLEANUP_TIMEOUT - ); - } - } - } - - loop_result - } -} - -/// A client-agnostic HTTP transport for RMCP that supports streaming responses. -/// -/// This transport allows you to choose your preferred HTTP client implementation -/// by implementing the [`StreamableHttpClient`] trait. The transport handles -/// session management, SSE streaming, and automatic reconnection. -/// -/// # Usage -/// -/// ## Using reqwest -/// -/// ```rust,no_run -/// use rmcp::transport::StreamableHttpClientTransport; -/// -/// // Enable the reqwest feature in Cargo.toml: -/// // rmcp = { version = "0.5", features = ["transport-streamable-http-client-reqwest"] } -/// -/// let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/mcp"); -/// ``` -/// -/// ## Using a custom HTTP client -/// -/// ```rust,no_run -/// use rmcp::transport::streamable_http_client::{ -/// StreamableHttpClient, -/// StreamableHttpClientTransport, -/// StreamableHttpClientTransportConfig -/// }; -/// use std::sync::Arc; -/// use std::collections::HashMap; -/// use futures::stream::BoxStream; -/// use rmcp::model::ClientJsonRpcMessage; -/// use http::{HeaderName, HeaderValue}; -/// use sse_stream::{Sse, Error as SseError}; -/// -/// #[derive(Clone)] -/// struct MyHttpClient; -/// -/// #[derive(Debug, thiserror::Error)] -/// struct MyError; -/// -/// impl std::fmt::Display for MyError { -/// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { -/// write!(f, "MyError") -/// } -/// } -/// -/// impl StreamableHttpClient for MyHttpClient { -/// type Error = MyError; -/// -/// async fn post_message( -/// &self, -/// _uri: Arc, -/// _message: ClientJsonRpcMessage, -/// _session_id: Option>, -/// _auth_header: Option, -/// _custom_headers: HashMap, -/// ) -> Result> { -/// todo!() -/// } -/// -/// async fn delete_session( -/// &self, -/// _uri: Arc, -/// _session_id: Arc, -/// _auth_header: Option, -/// _custom_headers: HashMap, -/// ) -> Result<(), rmcp::transport::streamable_http_client::StreamableHttpError> { -/// todo!() -/// } -/// -/// async fn get_stream( -/// &self, -/// _uri: Arc, -/// _session_id: Option>, -/// _last_event_id: Option, -/// _auth_header: Option, -/// _custom_headers: HashMap, -/// ) -> Result>, rmcp::transport::streamable_http_client::StreamableHttpError> { -/// todo!() -/// } -/// } -/// -/// let transport = StreamableHttpClientTransport::with_client( -/// MyHttpClient, -/// StreamableHttpClientTransportConfig::with_uri("http://localhost:8000/mcp") -/// ); -/// ``` -/// -/// # Feature Flags -/// -/// - `transport-streamable-http-client`: Base feature providing the generic transport infrastructure -/// - `transport-streamable-http-client-reqwest`: Includes reqwest HTTP client support with convenience methods -pub type StreamableHttpClientTransport = WorkerTransport>; - -impl StreamableHttpClientTransport { - /// Creates a new transport with a custom HTTP client implementation. - /// - /// This method allows you to use any HTTP client that implements the [`StreamableHttpClient`] trait. - /// Use this when you want to use a custom HTTP client or when the reqwest feature is not enabled. - /// - /// # Arguments - /// - /// * `client` - Your HTTP client implementation - /// * `config` - Transport configuration including the server URI - /// - /// # Example - /// - /// ```rust,no_run - /// use rmcp::transport::streamable_http_client::{ - /// StreamableHttpClient, - /// StreamableHttpClientTransport, - /// StreamableHttpClientTransportConfig - /// }; - /// use std::sync::Arc; - /// use std::collections::HashMap; - /// use futures::stream::BoxStream; - /// use rmcp::model::ClientJsonRpcMessage; - /// use http::{HeaderName, HeaderValue}; - /// use sse_stream::{Sse, Error as SseError}; - /// - /// // Define your custom client - /// #[derive(Clone)] - /// struct MyHttpClient; - /// - /// #[derive(Debug, thiserror::Error)] - /// struct MyError; - /// - /// impl std::fmt::Display for MyError { - /// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - /// write!(f, "MyError") - /// } - /// } - /// - /// impl StreamableHttpClient for MyHttpClient { - /// type Error = MyError; - /// - /// async fn post_message( - /// &self, - /// _uri: Arc, - /// _message: ClientJsonRpcMessage, - /// _session_id: Option>, - /// _auth_header: Option, - /// _custom_headers: HashMap, - /// ) -> Result> { - /// todo!() - /// } - /// - /// async fn delete_session( - /// &self, - /// _uri: Arc, - /// _session_id: Arc, - /// _auth_header: Option, - /// _custom_headers: HashMap, - /// ) -> Result<(), rmcp::transport::streamable_http_client::StreamableHttpError> { - /// todo!() - /// } - /// - /// async fn get_stream( - /// &self, - /// _uri: Arc, - /// _session_id: Option>, - /// _last_event_id: Option, - /// _auth_header: Option, - /// _custom_headers: HashMap, - /// ) -> Result>, rmcp::transport::streamable_http_client::StreamableHttpError> { - /// todo!() - /// } - /// } - /// - /// let transport = StreamableHttpClientTransport::with_client( - /// MyHttpClient, - /// StreamableHttpClientTransportConfig::with_uri("http://localhost:8000/mcp") - /// ); - /// ``` - pub fn with_client(client: C, config: StreamableHttpClientTransportConfig) -> Self { - let worker = StreamableHttpClientWorker::new(client, config); - WorkerTransport::spawn(worker) - } -} -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct StreamableHttpClientTransportConfig { - pub uri: Arc, - pub retry_config: Arc, - pub channel_buffer_capacity: usize, - /// if true, the transport will not require a session to be established - pub allow_stateless: bool, - /// The value to send in the authorization header - pub auth_header: Option, - /// Custom HTTP headers to include with every request - pub custom_headers: HashMap, - /// Maximum raw size of one SSE event accepted from the server. - /// - /// The built-in reqwest and Unix socket clients enforce this value. Custom - /// [`StreamableHttpClient`] implementations must override the corresponding - /// `*_with_max_sse_event_size` methods to enforce it. - pub max_sse_event_size: usize, - /// Enables transparent recovery when the server reports an expired session (`HTTP 404`). - /// - /// When enabled, the transport performs one automatic recovery attempt: - /// 1. Replays the original `initialize` handshake to create a new session. - /// 2. Re-establishes streaming state for that session. - /// 3. Retries the in-flight request that failed with `SessionExpired`. - /// - /// This recovery is best-effort and bounded to a single attempt. If recovery fails, - /// the original failure path is preserved and the error is returned to the caller. - pub reinit_on_expired_session: bool, -} - -impl StreamableHttpClientTransportConfig { - pub fn with_uri(uri: impl Into>) -> Self { - Self { - uri: uri.into(), - ..Default::default() - } - } - - /// Set the authorization header to send with requests - /// - /// # Arguments - /// - /// * `value` - A bearer token without the `Bearer ` prefix - pub fn auth_header>(mut self, value: T) -> Self { - // set our authorization header - self.auth_header = Some(value.into()); - self - } - - /// Set custom HTTP headers to include with every request - /// - /// # Arguments - /// - /// * `custom_headers` - A HashMap of header names to header values - /// - /// # Example - /// - /// ```rust,no_run - /// use std::collections::HashMap; - /// use http::{HeaderName, HeaderValue}; - /// use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; - /// - /// let mut headers = HashMap::new(); - /// headers.insert( - /// HeaderName::from_static("x-custom-header"), - /// HeaderValue::from_static("custom-value") - /// ); - /// - /// let config = StreamableHttpClientTransportConfig::with_uri("http://localhost:8000") - /// .custom_headers(headers); - /// ``` - pub fn custom_headers(mut self, custom_headers: HashMap) -> Self { - self.custom_headers = custom_headers; - self - } - - /// Set the maximum raw size of one SSE event accepted from the server. - pub fn max_sse_event_size(mut self, bytes: usize) -> Self { - self.max_sse_event_size = bytes; - self - } - - /// Set whether the transport should attempt transparent re-initialization on session expiration - /// See [`Self::reinit_on_expired_session`] for details. - /// # Example - /// ```rust,no_run - /// use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; - /// let config = StreamableHttpClientTransportConfig::with_uri("http://localhost:8000") - /// .reinit_on_expired_session(true); - /// ``` - pub fn reinit_on_expired_session(mut self, enable: bool) -> Self { - self.reinit_on_expired_session = enable; - self - } -} - -impl Default for StreamableHttpClientTransportConfig { - fn default() -> Self { - Self { - uri: "localhost".into(), - retry_config: Arc::new(ExponentialBackoff::default()), - channel_buffer_capacity: 16, - allow_stateless: true, - auth_header: None, - custom_headers: HashMap::new(), - max_sse_event_size: DEFAULT_MAX_SSE_EVENT_SIZE, - reinit_on_expired_session: true, - } - } -} - -#[cfg(test)] -mod tests { - use std::sync::Mutex; - - use serde_json::json; - - use super::*; - use crate::{ - model::{ - GetExtensions, ListToolsResult, NumberOrString, ServerRequest, ServerResult, Tool, - }, - service::InboundStreamOrigin, - }; - - #[expect( - deprecated, - reason = "Sampling is deprecated by SEP-2577 but remains the canonical restricted request" - )] - fn sampling_request_message(id: i64) -> ServerJsonRpcMessage { - use crate::model::{CreateMessageRequest, CreateMessageRequestParams, SamplingMessage}; - ServerJsonRpcMessage::request( - ServerRequest::CreateMessageRequest(CreateMessageRequest::new( - CreateMessageRequestParams::new(vec![SamplingMessage::user_text("hi")], 16), - )), - NumberOrString::Number(id), - ) - } - - #[tokio::test] - async fn execute_sse_stream_marks_inbound_requests_with_origin() { - for origin in [ - InboundStreamOrigin::Unassociated, - InboundStreamOrigin::OutboundRequest(RequestId::Number(3)), - ] { - let response = ServerJsonRpcMessage::response( - ServerResult::ListToolsResult(ListToolsResult::default()), - NumberOrString::Number(1), - ); - let stream = futures::stream::iter([Ok(sampling_request_message(9)), Ok(response)]); - let (tx, mut rx) = tokio::sync::mpsc::channel(4); - StreamableHttpClientWorker::::execute_sse_stream( - stream, - tx, - origin.clone(), - false, - CancellationToken::new(), - ) - .await - .expect("stream completes"); - - let ServerJsonRpcMessage::Request(request) = - rx.recv().await.expect("request forwarded") - else { - panic!("expected request first"); - }; - assert_eq!( - request.request.extensions().get::(), - Some(&origin), - "inbound requests must carry their stream origin" - ); - // Responses are correlated by JSON-RPC id; no marker needed or added. - assert!(matches!( - rx.recv().await.expect("response forwarded"), - ServerJsonRpcMessage::Response(_) - )); - } - } - - type ReconnectAttempt = (Option, Option); - - #[derive(Clone, Default)] - struct StatelessReconnectClient { - reconnects: Arc>>, - } - - impl StreamableHttpClient for StatelessReconnectClient { - type Error = std::io::Error; - - async fn post_message( - &self, - _uri: Arc, - _message: ClientJsonRpcMessage, - _session_id: Option>, - _auth_header: Option, - _custom_headers: HashMap, - ) -> Result> { - Err(StreamableHttpError::UnexpectedServerResponse( - "unexpected POST".into(), - )) - } - - async fn delete_session( - &self, - _uri: Arc, - _session_id: Arc, - _auth_header: Option, - _custom_headers: HashMap, - ) -> Result<(), StreamableHttpError> { - Ok(()) - } - - async fn get_stream( - &self, - _uri: Arc, - session_id: Option>, - last_event_id: Option, - _auth_header: Option, - _custom_headers: HashMap, - ) -> Result> { - self.reconnects - .lock() - .expect("lock reconnects") - .push((session_id.map(|id| id.to_string()), last_event_id)); - let response = ServerJsonRpcMessage::response( - ServerResult::ListToolsResult(ListToolsResult::default()), - NumberOrString::Number(1), - ); - Ok(futures::stream::once(async move { - Ok(Sse { - event: None, - data: Some(serde_json::to_string(&response).expect("serialize response")), - id: Some("event-1".into()), - retry: None, - }) - }) - .boxed()) - } - } - - #[tokio::test] - async fn stateless_response_reconnects_with_last_event_id() { - let initial = futures::stream::iter([Ok(Sse { - event: None, - data: None, - id: Some("event-0".into()), - retry: Some(0), - })]) - .boxed(); - let client = StatelessReconnectClient::default(); - let reconnects = client.reconnects.clone(); - let stream = - StreamableHttpClientWorker::::response_sse_to_jsonrpc( - initial, - None, - client, - Arc::from("http://localhost/mcp"), - None, - HashMap::new(), - DEFAULT_MAX_SSE_EVENT_SIZE, - Arc::new(ExponentialBackoff { - max_times: Some(1), - base_duration: Duration::ZERO, - }), - ); - let mut stream = std::pin::pin!(stream); - - let message = stream.next().await.expect("replayed response").unwrap(); - - assert!(matches!(message, ServerJsonRpcMessage::Response(_))); - assert_eq!( - reconnects.lock().expect("lock reconnects").as_slice(), - &[(None, Some("event-0".into()))] - ); - } - - #[derive(Clone, Default)] - struct ResumedRequestClient { - reconnects: Arc>>, - } - - impl StreamableHttpClient for ResumedRequestClient { - type Error = std::io::Error; - - async fn post_message( - &self, - _uri: Arc, - _message: ClientJsonRpcMessage, - _session_id: Option>, - _auth_header: Option, - _custom_headers: HashMap, - ) -> Result> { - Err(StreamableHttpError::UnexpectedServerResponse( - "unexpected POST".into(), - )) - } - - async fn delete_session( - &self, - _uri: Arc, - _session_id: Arc, - _auth_header: Option, - _custom_headers: HashMap, - ) -> Result<(), StreamableHttpError> { - Ok(()) - } - - async fn get_stream( - &self, - _uri: Arc, - session_id: Option>, - last_event_id: Option, - _auth_header: Option, - _custom_headers: HashMap, - ) -> Result> { - self.reconnects - .lock() - .expect("lock reconnects") - .push((session_id.map(|id| id.to_string()), last_event_id)); - let request = sampling_request_message(9); - let response = ServerJsonRpcMessage::response( - ServerResult::ListToolsResult(ListToolsResult::default()), - NumberOrString::Number(1), - ); - // Stay open after the response, like a live connection, so the - // post-response drain in `execute_sse_stream` doesn't trigger - // further reconnects. - Ok(futures::stream::iter([request, response].map(|message| { - Ok(Sse { - event: None, - data: Some(serde_json::to_string(&message).expect("serialize message")), - id: None, - retry: None, - }) - })) - .chain(futures::stream::pending()) - .boxed()) - } - } - - /// SEP-1699 resumes a broken POST SSE stream via GET + Last-Event-ID - /// beneath `execute_sse_stream`, so the SEP-2260 origin marker must span - /// resumes; if reconnection were hoisted above the marker attach point, - /// replayed associated requests would be wrongly rejected with -32602. - #[tokio::test] - async fn resumed_post_stream_requests_keep_outbound_origin() { - let initial = futures::stream::iter([Ok(Sse { - event: None, - data: None, - id: Some("e1".into()), - retry: Some(0), - })]) - .boxed(); - let client = ResumedRequestClient::default(); - let reconnects = client.reconnects.clone(); - let sse_stream = - StreamableHttpClientWorker::::response_sse_to_jsonrpc( - initial, - None, - client, - Arc::from("http://localhost/mcp"), - None, - HashMap::new(), - DEFAULT_MAX_SSE_EVENT_SIZE, - Arc::new(ExponentialBackoff { - max_times: Some(1), - base_duration: Duration::ZERO, - }), - ); - - let origin = InboundStreamOrigin::OutboundRequest(RequestId::Number(3)); - let (tx, mut rx) = tokio::sync::mpsc::channel(4); - StreamableHttpClientWorker::::execute_sse_stream( - sse_stream, - tx, - origin.clone(), - true, - CancellationToken::new(), - ) - .await - .expect("stream completes"); - - assert_eq!( - reconnects.lock().expect("lock reconnects").as_slice(), - &[(None, Some("e1".into()))], - "the request must arrive on the resumed connection" - ); - let ServerJsonRpcMessage::Request(request) = rx.recv().await.expect("request forwarded") - else { - panic!("expected request first"); - }; - assert_eq!( - request.request.extensions().get::(), - Some(&origin), - "origin marker must survive SSE resumption" - ); - assert!(matches!( - rx.recv().await.expect("response forwarded"), - ServerJsonRpcMessage::Response(_) - )); - } - - fn tool(name: &'static str, annotation: serde_json::Value) -> Tool { - let schema = json!({ - "type": "object", - "properties": { - "value": annotation, - }, - }); - Tool::new( - name, - name, - Arc::new(schema.as_object().expect("object schema").clone()), - ) - } - - #[test] - fn cache_tools_removes_invalid_header_annotations() { - let valid = tool( - "valid", - json!({ "type": "string", "x-mcp-header": "Value" }), - ); - let invalid = tool("invalid", json!({ "type": "string", "x-mcp-header": "" })); - let mut message = ServerJsonRpcMessage::response( - ServerResult::ListToolsResult(ListToolsResult::with_all_items(vec![valid, invalid])), - NumberOrString::Number(1), - ); - let mut cache = HashMap::new(); - - cache_tools_from_response(&mut cache, &mut message, &ProtocolVersion::V_2026_07_28); - - let ServerJsonRpcMessage::Response(response) = &mut message else { - panic!("expected tools/list response"); - }; - let ServerResult::ListToolsResult(result) = &mut response.result else { - panic!("expected tools/list result"); - }; - assert_eq!( - ( - result - .tools - .iter() - .map(|tool| tool.name.as_ref()) - .collect::>(), - cache.keys().map(String::as_str).collect::>(), - ), - (vec!["valid"], vec!["valid"]) - ); - } - - #[test] - fn cache_tools_preserves_pre_standard_header_results() { - let invalid = tool("legacy", json!({ "type": "string", "x-mcp-header": "" })); - let mut message = ServerJsonRpcMessage::response( - ServerResult::ListToolsResult(ListToolsResult::with_all_items(vec![invalid])), - NumberOrString::Number(1), - ); - let mut cache = HashMap::new(); - - cache_tools_from_response(&mut cache, &mut message, &ProtocolVersion::V_2025_11_25); - - let ServerJsonRpcMessage::Response(response) = message else { - panic!("expected tools/list response"); - }; - let ServerResult::ListToolsResult(result) = response.result else { - panic!("expected tools/list result"); - }; - assert_eq!( - result - .tools - .iter() - .map(|tool| tool.name.as_ref()) - .collect::>(), - vec!["legacy"] - ); - } - - #[cfg(feature = "transport-streamable-http-client-reqwest")] - #[test] - fn clear_stream_response_pending_accepts_stringified_numeric_id() { - let mut pending = HashSet::from([NumberOrString::Number(1)]); - let response = ServerJsonRpcMessage::response( - ServerResult::ListToolsResult(ListToolsResult::default()), - NumberOrString::String("1".into()), - ); - - StreamableHttpClientWorker::::clear_stream_response_pending( - &mut pending, - &response, - ); - - assert!(pending.is_empty()); - } - - #[cfg(feature = "transport-streamable-http-client-reqwest")] - #[test] - fn clear_stream_response_pending_prefers_exact_string_id() { - let string_id = NumberOrString::String("1".into()); - let mut pending = HashSet::from([NumberOrString::Number(1), string_id.clone()]); - let response = ServerJsonRpcMessage::response( - ServerResult::ListToolsResult(ListToolsResult::default()), - string_id, - ); - - StreamableHttpClientWorker::::clear_stream_response_pending( - &mut pending, - &response, - ); - - assert_eq!(pending, HashSet::from([NumberOrString::Number(1)])); - } -} diff --git a/vendor/rmcp/src/transport/streamable_http_server.rs b/vendor/rmcp/src/transport/streamable_http_server.rs deleted file mode 100644 index df1945ab2..000000000 --- a/vendor/rmcp/src/transport/streamable_http_server.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod session; -#[cfg(all(feature = "transport-streamable-http-server", not(feature = "local")))] -pub mod tower; -pub use session::{RestoreOutcome, SessionId, SessionManager, SessionRestoreMarker}; -#[cfg(all(feature = "transport-streamable-http-server", not(feature = "local")))] -pub use tower::{StreamableHttpServerConfig, StreamableHttpService}; diff --git a/vendor/rmcp/src/transport/streamable_http_server/session.rs b/vendor/rmcp/src/transport/streamable_http_server/session.rs deleted file mode 100644 index ea2e85b24..000000000 --- a/vendor/rmcp/src/transport/streamable_http_server/session.rs +++ /dev/null @@ -1,174 +0,0 @@ -//! Session management for the Streamable HTTP transport. -//! -//! A *session* groups the logically related interactions between a single MCP -//! client and the server, starting from the `initialize` handshake. The server -//! assigns each session a unique [`SessionId`] (returned to the client via the -//! `Mcp-Session-Id` response header) and the client includes that ID on every -//! subsequent request. -//! -//! Two tool calls carrying the same session ID come from the same logical -//! session; different IDs mean different clients or conversations. -//! -//! # Implementations -//! -//! * [`local::LocalSessionManager`] — in-memory session store (default). -//! * [`never::NeverSessionManager`] — rejects all session operations, used -//! when legacy session mode is disabled. -//! -//! # Custom session managers -//! -//! Implement the [`SessionManager`] trait to back sessions with a database, -//! Redis, or any other external store. - -use std::sync::Arc; - -use futures::Stream; - -pub use crate::transport::common::server_side_http::{ServerSseMessage, SessionId}; -use crate::{ - RoleServer, - model::{ClientJsonRpcMessage, ServerJsonRpcMessage}, -}; - -pub mod local; -pub mod never; -pub mod store; - -pub use store::{ - EventId, EventStore, EventStoreError, EventStream, SessionState, SessionStore, - SessionStoreError, StreamId, -}; - -/// Extension marker inserted into the `initialize` request extensions during a -/// session restore replay. Handlers can check for its presence to distinguish a -/// cross-instance restore from a genuine client-initiated `initialize` request. -/// -/// ```rust,ignore -/// if req.extensions().get::().is_some() { -/// // this is a restore replay, not a fresh client connection -/// } -/// ``` -#[non_exhaustive] -#[derive(Debug, Clone)] -pub struct SessionRestoreMarker { - pub id: SessionId, -} - -/// The outcome of a [`SessionManager::restore_session`] call. -#[non_exhaustive] -#[derive(Debug)] -pub enum RestoreOutcome { - /// The session was just re-created from external state; the caller must - /// spawn an MCP handler against the returned transport and replay the - /// `initialize` handshake. - Restored(T), - /// The session was already present in memory (e.g. a concurrent request - /// already restored it). The caller should proceed as if `has_session` - /// had returned `true` — no further action is required. - AlreadyPresent, - /// This session manager does not support external-store restore. - /// The caller should fall through to the normal 404 response. - NotSupported, -} - -/// Controls how MCP sessions are created, validated, and closed. -/// -/// The `StreamableHttpService` calls into this -/// trait for every HTTP request that carries (or should carry) a session ID. -/// -/// See the [module-level docs](self) for background on sessions. -/// -/// # SEP-2260 request association -/// -/// Server-initiated requests issued while handling a client request carry an -/// [`OriginatingRequestId`](crate::service::OriginatingRequestId) marker in -/// their non-serialized `Extensions`; the bundled local session manager uses -/// it to deliver such requests on the originating request's SSE stream. -/// Implementations that serialize messages between processes lose the marker -/// and fall back to the standalone stream, violating SEP-2260 for 2026-07-28+ -/// clients; they need their own association mechanism. -pub trait SessionManager: Send + Sync + 'static { - type Error: std::error::Error + Send + 'static; - type Transport: crate::transport::Transport; - - /// Create a new session and return its ID together with the transport - /// that will be used to exchange MCP messages within this session. - fn create_session( - &self, - ) -> impl Future> + Send; - - /// Forward the first message (the `initialize` request) to the session. - fn initialize_session( - &self, - id: &SessionId, - message: ClientJsonRpcMessage, - ) -> impl Future> + Send; - - /// Return `true` if a session with the given ID exists and is active. - fn has_session(&self, id: &SessionId) - -> impl Future> + Send; - - /// Close and remove the session. Corresponds to an HTTP DELETE request - /// with `Mcp-Session-Id`. - fn close_session(&self, id: &SessionId) - -> impl Future> + Send; - - /// Route a client request into the session and return an SSE stream - /// carrying the server's response(s). - fn create_stream( - &self, - id: &SessionId, - message: ClientJsonRpcMessage, - ) -> impl Future< - Output = Result + Send + Sync + 'static, Self::Error>, - > + Send; - - /// Accept a notification, response, or error message from the client - /// without producing a response stream. - fn accept_message( - &self, - id: &SessionId, - message: ClientJsonRpcMessage, - ) -> impl Future> + Send; - - /// Create an SSE stream not tied to a specific client request (HTTP GET). - fn create_standalone_stream( - &self, - id: &SessionId, - ) -> impl Future< - Output = Result + Send + Sync + 'static, Self::Error>, - > + Send; - - /// Resume an SSE stream from the given `Last-Event-ID`, replaying any - /// events the client missed. - fn resume( - &self, - id: &SessionId, - last_event_id: String, - ) -> impl Future< - Output = Result + Send + Sync + 'static, Self::Error>, - > + Send; - - /// Attempt to restore a previously-known session from external state, - /// creating a fresh in-memory session worker with the given `id`. - /// - /// See [`RestoreOutcome`] for the three possible results: - /// - [`RestoreOutcome::Restored`] — session re-created; caller must spawn - /// an MCP handler and replay the `initialize` handshake. - /// - [`RestoreOutcome::AlreadyPresent`] — session is already in memory - /// (e.g. a concurrent request restored it first); caller proceeds - /// normally. - /// - [`RestoreOutcome::NotSupported`] (default) — this session manager - /// does not support external-store restore; caller returns 404. - fn restore_session( - &self, - _id: SessionId, - ) -> impl Future, Self::Error>> + Send { - futures::future::ready(Ok(RestoreOutcome::NotSupported)) - } - - /// Return the shared event store used for resumable SSE streams. - fn event_store(&self) -> Option> { - None - } -} diff --git a/vendor/rmcp/src/transport/streamable_http_server/session/local.rs b/vendor/rmcp/src/transport/streamable_http_server/session/local.rs deleted file mode 100644 index cc9e14893..000000000 --- a/vendor/rmcp/src/transport/streamable_http_server/session/local.rs +++ /dev/null @@ -1,1374 +0,0 @@ -use std::{ - collections::{HashMap, HashSet, VecDeque}, - num::ParseIntError, - sync::Arc, - time::{Duration, Instant}, -}; - -use futures::{Stream, StreamExt}; -use thiserror::Error; -use tokio::sync::{ - mpsc::{Receiver, Sender}, - oneshot, -}; -use tokio_stream::wrappers::ReceiverStream; -use tracing::instrument; - -use crate::{ - RoleServer, - model::{ - CancelledNotificationParam, ClientJsonRpcMessage, ClientNotification, ClientRequest, - JsonRpcNotification, JsonRpcRequest, Notification, ProgressNotificationParam, - ProgressToken, RequestId, ServerJsonRpcMessage, ServerNotification, - }, - transport::{ - WorkerTransport, - common::server_side_http::{SessionId, session_id}, - worker::{Worker, WorkerContext, WorkerQuitReason, WorkerSendRequest}, - }, -}; - -#[derive(Debug, Default)] -#[non_exhaustive] -pub struct LocalSessionManager { - pub sessions: tokio::sync::RwLock>, - pub session_config: SessionConfig, - event_store: Option>, -} - -impl LocalSessionManager { - /// Configure this session manager to use a shared event store. - pub fn with_event_store(mut self, event_store: Arc) -> Self { - self.event_store = Some(event_store); - self - } -} - -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum LocalSessionManagerError { - #[error("Session not found: {0}")] - SessionNotFound(SessionId), - #[error("Session error: {0}")] - SessionError(#[from] SessionError), - #[error("Invalid event id: {0}")] - InvalidEventId(#[from] EventIdParseError), -} -impl SessionManager for LocalSessionManager { - type Error = LocalSessionManagerError; - type Transport = WorkerTransport; - async fn create_session(&self) -> Result<(SessionId, Self::Transport), Self::Error> { - let id = session_id(); - let (handle, worker) = create_local_session_with_event_store( - id.clone(), - self.session_config.clone(), - self.event_store.clone(), - ); - self.sessions.write().await.insert(id.clone(), handle); - Ok((id, WorkerTransport::spawn(worker))) - } - async fn initialize_session( - &self, - id: &SessionId, - message: ClientJsonRpcMessage, - ) -> Result { - let sessions = self.sessions.read().await; - let handle = sessions - .get(id) - .ok_or(LocalSessionManagerError::SessionNotFound(id.clone()))?; - let response = handle.initialize(message).await?; - Ok(response) - } - async fn close_session(&self, id: &SessionId) -> Result<(), Self::Error> { - let handle = { - let mut sessions = self.sessions.write().await; - sessions.remove(id) - }; - if let Some(handle) = handle { - match handle.close().await { - // Worker already exited — nothing left to clean up. - Ok(()) | Err(SessionError::SessionServiceTerminated) => {} - Err(e) => return Err(e.into()), - } - } - Ok(()) - } - async fn has_session(&self, id: &SessionId) -> Result { - let sessions = self.sessions.read().await; - Ok(sessions.contains_key(id)) - } - async fn create_stream( - &self, - id: &SessionId, - message: ClientJsonRpcMessage, - ) -> Result + Send + 'static, Self::Error> { - let sessions = self.sessions.read().await; - let handle = sessions - .get(id) - .ok_or(LocalSessionManagerError::SessionNotFound(id.clone()))?; - let receiver = handle.establish_request_wise_channel().await?; - let http_request_id = receiver.http_request_id; - handle.push_message(message, http_request_id).await?; - Ok(ReceiverStream::new(receiver.inner)) - } - - async fn create_standalone_stream( - &self, - id: &SessionId, - ) -> Result + Send + 'static, Self::Error> { - let sessions = self.sessions.read().await; - let handle = sessions - .get(id) - .ok_or(LocalSessionManagerError::SessionNotFound(id.clone()))?; - let receiver = handle.establish_common_channel().await?; - Ok(ReceiverStream::new(receiver.inner)) - } - - async fn resume( - &self, - id: &SessionId, - last_event_id: String, - ) -> Result + Send + 'static, Self::Error> { - if let Some(event_store) = &self.event_store { - let stream = event_store - .replay_events_after(&last_event_id) - .await - .map_err(SessionError::EventStore)?; - return Ok(stream.left_stream()); - } - let sessions = self.sessions.read().await; - let handle = sessions - .get(id) - .ok_or(LocalSessionManagerError::SessionNotFound(id.clone()))?; - let receiver = handle.resume(last_event_id.parse()?).await?; - Ok(ReceiverStream::new(receiver.inner).right_stream()) - } - - async fn accept_message( - &self, - id: &SessionId, - message: ClientJsonRpcMessage, - ) -> Result<(), Self::Error> { - let sessions = self.sessions.read().await; - let handle = sessions - .get(id) - .ok_or(LocalSessionManagerError::SessionNotFound(id.clone()))?; - handle.push_message(message, None).await?; - Ok(()) - } - - async fn restore_session( - &self, - id: SessionId, - ) -> Result, Self::Error> { - let mut sessions = self.sessions.write().await; - if sessions.contains_key(&id) { - // A concurrent request already restored this session. - return Ok(RestoreOutcome::AlreadyPresent); - } - let (handle, worker) = create_local_session_with_event_store( - id.clone(), - self.session_config.clone(), - self.event_store.clone(), - ); - sessions.insert(id, handle); - Ok(RestoreOutcome::Restored(WorkerTransport::spawn(worker))) - } - - fn event_store(&self) -> Option> { - self.event_store.clone() - } -} - -/// `/request_id>` -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct EventId { - http_request_id: Option, - index: usize, -} - -impl std::fmt::Display for EventId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.index)?; - match &self.http_request_id { - Some(http_request_id) => write!(f, "/{http_request_id}"), - None => write!(f, ""), - } - } -} - -#[derive(Debug, Clone, Error)] -#[non_exhaustive] -pub enum EventIdParseError { - #[error("Invalid index: {0}")] - InvalidIndex(ParseIntError), - #[error("Invalid numeric request id: {0}")] - InvalidNumericRequestId(ParseIntError), - #[error("Missing request id type")] - InvalidRequestIdType, - #[error("Missing request id")] - MissingRequestId, -} - -impl std::str::FromStr for EventId { - type Err = EventIdParseError; - fn from_str(s: &str) -> Result { - if let Some((index, request_id)) = s.split_once("/") { - let index = usize::from_str(index).map_err(EventIdParseError::InvalidIndex)?; - let request_id = u64::from_str(request_id).map_err(EventIdParseError::InvalidIndex)?; - Ok(EventId { - http_request_id: Some(request_id), - index, - }) - } else { - let index = usize::from_str(s).map_err(EventIdParseError::InvalidIndex)?; - Ok(EventId { - http_request_id: None, - index, - }) - } - } -} - -use super::{ - EventStore, EventStoreError, RestoreOutcome, ServerSseMessage, SessionManager, StreamId, -}; - -struct CachedTx { - tx: Sender, - cache: VecDeque, - http_request_id: Option, - capacity: usize, - starting_index: usize, - stream_id: StreamId, - event_store: Option>, -} - -impl CachedTx { - fn new( - tx: Sender, - http_request_id: Option, - starting_index: usize, - stream_id: StreamId, - event_store: Option>, - ) -> Self { - Self { - cache: VecDeque::with_capacity(tx.capacity()), - capacity: tx.capacity(), - tx, - http_request_id, - starting_index, - stream_id, - event_store, - } - } - fn new_common( - tx: Sender, - session_id: &SessionId, - event_store: Option>, - ) -> Self { - Self::new(tx, None, 0, format!("{session_id}:common"), event_store) - } - - fn next_event_id(&self) -> EventId { - let index = self.cache.back().map_or(self.starting_index, |m| { - m.event_id - .as_deref() - .unwrap_or_default() - .parse::() - .expect("valid event id") - .index - + 1 - }); - EventId { - http_request_id: self.http_request_id, - index, - } - } - - async fn send(&mut self, message: ServerJsonRpcMessage) -> Result<(), SessionError> { - self.store_cache_and_send(ServerSseMessage::from_message(message)) - .await - } - - async fn send_priming(&mut self, retry: Duration) -> Result<(), SessionError> { - self.store_cache_and_send(ServerSseMessage::retry(retry)) - .await - } - - async fn store_cache_and_send( - &mut self, - mut event: ServerSseMessage, - ) -> Result<(), SessionError> { - let event_id = if let Some(event_store) = &self.event_store { - event_store - .store_event(&self.stream_id, &event) - .await - .map_err(SessionError::EventStore)? - } else { - self.next_event_id().to_string() - }; - event.event_id = Some(event_id); - self.cache_and_send(event).await; - Ok(()) - } - - async fn cache_and_send(&mut self, message: ServerSseMessage) { - if self.cache.len() >= self.capacity { - self.cache.pop_front(); - self.cache.push_back(message.clone()); - } else { - self.cache.push_back(message.clone()); - } - let _ = self.tx.send(message).await.inspect_err(|e| { - let event_id = &e.0.event_id; - tracing::trace!(?event_id, "trying to send message in a closed session") - }); - } - - async fn sync(&mut self, index: usize) -> Result<(), SessionError> { - let Some(front) = self.cache.front() else { - return Ok(()); - }; - let front_event_id = front - .event_id - .as_deref() - .unwrap_or_default() - .parse::()?; - let sync_index = index.saturating_sub(front_event_id.index); - if sync_index > self.cache.len() { - // invalid index - return Err(SessionError::InvalidEventId); - } - for message in self.cache.iter().skip(sync_index) { - let send_result = self.tx.send(message.clone()).await; - if send_result.is_err() { - let event_id: EventId = message.event_id.as_deref().unwrap_or_default().parse()?; - return Err(SessionError::ChannelClosed(Some(event_id.index as u64))); - } - } - Ok(()) - } -} - -struct HttpRequestWise { - resources: HashSet, - tx: CachedTx, - completed_at: Option, -} - -type HttpRequestId = u64; -#[derive(Debug, Clone, Hash, PartialEq, Eq)] -enum ResourceKey { - McpRequestId(RequestId), - ProgressToken(ProgressToken), -} - -pub struct LocalSessionWorker { - id: SessionId, - next_http_request_id: HttpRequestId, - tx_router: HashMap, - resource_router: HashMap, - common: CachedTx, - /// Shadow senders for secondary SSE streams (e.g. from POST EventSource - /// reconnections). These keep the HTTP connections alive via SSE keep-alive - /// without receiving notifications, preventing MCP clients from entering - /// infinite reconnect loops when multiple EventSource connections compete - /// to replace the common channel. - shadow_txs: Vec>, - event_rx: Receiver, - session_config: SessionConfig, - event_store: Option>, -} - -impl LocalSessionWorker { - pub fn id(&self) -> &SessionId { - &self.id - } -} - -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum SessionError { - #[error("Invalid request id: {0}")] - DuplicatedRequestId(HttpRequestId), - #[error("Channel closed: {0:?}")] - ChannelClosed(Option), - #[error("Cannot parse event id: {0}")] - EventIdParseError(#[from] EventIdParseError), - #[error("Session service terminated")] - SessionServiceTerminated, - #[error("Invalid event id")] - InvalidEventId, - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - #[error("Event store error: {0}")] - EventStore(#[source] EventStoreError), -} - -impl From for std::io::Error { - fn from(value: SessionError) -> Self { - match value { - SessionError::Io(io) => io, - _ => std::io::Error::other(format!("Session error: {value}")), - } - } -} - -enum OutboundChannel { - RequestWise { id: HttpRequestId, close: bool }, - Common, -} -#[derive(Debug)] -#[non_exhaustive] -pub struct StreamableHttpMessageReceiver { - pub http_request_id: Option, - pub inner: Receiver, -} - -impl LocalSessionWorker { - fn unregister_resource(&mut self, resource: &ResourceKey) { - let Some(http_request_id) = self.resource_router.remove(resource) else { - return; - }; - tracing::trace!(?resource, http_request_id, "unregister resource"); - let Some(channel) = self.tx_router.get_mut(&http_request_id) else { - tracing::warn!(http_request_id, "http request wise channel not found"); - return; - }; - if !channel.resources.is_empty() && !matches!(resource, ResourceKey::McpRequestId(_)) { - return; - } - tracing::debug!(http_request_id, "close http request wise channel"); - let resources: Vec<_> = channel.resources.drain().collect(); - channel.completed_at = Some(Instant::now()); - // Close the sender so the client's SSE stream ends, - // but keep the entry so the cache is available for - // late resume requests. - let (closed_tx, _) = tokio::sync::mpsc::channel(1); - channel.tx.tx = closed_tx; - for resource in resources { - self.resource_router.remove(&resource); - } - } - fn register_resource(&mut self, resource: ResourceKey, http_request_id: HttpRequestId) { - tracing::trace!(?resource, http_request_id, "register resource"); - if let Some(channel) = self.tx_router.get_mut(&http_request_id) { - channel.resources.insert(resource.clone()); - self.resource_router.insert(resource, http_request_id); - } - } - fn register_request( - &mut self, - request: &JsonRpcRequest, - http_request_id: HttpRequestId, - ) { - use crate::model::GetMeta; - self.register_resource( - ResourceKey::McpRequestId(request.id.clone()), - http_request_id, - ); - if let Some(progress_token) = request.request.get_meta().get_progress_token() { - self.register_resource( - ResourceKey::ProgressToken(progress_token.clone()), - http_request_id, - ); - } - } - fn catch_cancellation_notification( - &mut self, - notification: &JsonRpcNotification, - ) { - if let ClientNotification::CancelledNotification(n) = ¬ification.notification - && let Some(request_id) = n.params.request_id.clone() - { - let resource = ResourceKey::McpRequestId(request_id); - self.unregister_resource(&resource); - } - } - fn evict_expired_channels(&mut self) { - let ttl = self.session_config.completed_cache_ttl; - self.tx_router - .retain(|_, rw| rw.completed_at.is_none_or(|at| at.elapsed() < ttl)); - } - fn next_http_request_id(&mut self) -> HttpRequestId { - let id = self.next_http_request_id; - self.next_http_request_id = self.next_http_request_id.wrapping_add(1); - id - } - async fn establish_request_wise_channel( - &mut self, - ) -> Result { - let http_request_id = self.next_http_request_id(); - let (tx, rx) = tokio::sync::mpsc::channel(self.session_config.channel_capacity); - let mut cached_tx = CachedTx::new( - tx, - Some(http_request_id), - 0, - uuid::Uuid::new_v4().to_string(), - self.event_store.clone(), - ); - if let Some(retry) = self.session_config.sse_retry { - cached_tx.send_priming(retry).await?; - } - self.tx_router.insert( - http_request_id, - HttpRequestWise { - resources: Default::default(), - tx: cached_tx, - completed_at: None, - }, - ); - tracing::debug!(http_request_id, "establish new request wise channel"); - Ok(StreamableHttpMessageReceiver { - http_request_id: Some(http_request_id), - inner: rx, - }) - } - fn resolve_outbound_channel(&self, message: &ServerJsonRpcMessage) -> OutboundChannel { - match &message { - // SEP-2260: requests carrying an OriginatingRequestId marker ride the - // originating request's SSE stream, never the standalone GET stream. - ServerJsonRpcMessage::Request(json_rpc_request) => { - use crate::model::GetExtensions; - let originating = json_rpc_request - .request - .extensions() - .get::(); - match originating { - Some(originating) => match self - .resource_router - .get(&ResourceKey::McpRequestId(originating.0.clone())) - { - Some(id) => OutboundChannel::RequestWise { - id: *id, - close: false, - }, - None => { - tracing::warn!( - originating_request_id = %originating.0, - "associated server request could not be routed to its \ - originating stream (request completed or association \ - lost); falling back to standalone stream" - ); - OutboundChannel::Common - } - }, - None => OutboundChannel::Common, - } - } - ServerJsonRpcMessage::Notification(JsonRpcNotification { - notification: - ServerNotification::ProgressNotification(Notification { - params: ProgressNotificationParam { progress_token, .. }, - .. - }), - .. - }) => { - let id = self - .resource_router - .get(&ResourceKey::ProgressToken(progress_token.clone())); - - if let Some(id) = id { - OutboundChannel::RequestWise { - id: *id, - close: false, - } - } else { - OutboundChannel::Common - } - } - ServerJsonRpcMessage::Notification(JsonRpcNotification { - notification: - ServerNotification::CancelledNotification(Notification { - params: CancelledNotificationParam { request_id, .. }, - .. - }), - .. - }) => { - if let Some(req_id) = request_id { - if let Some(id) = self - .resource_router - .get(&ResourceKey::McpRequestId(req_id.clone())) - { - OutboundChannel::RequestWise { - id: *id, - close: false, - } - } else { - OutboundChannel::Common - } - } else { - OutboundChannel::Common - } - } - ServerJsonRpcMessage::Notification(_) => OutboundChannel::Common, - ServerJsonRpcMessage::Response(json_rpc_response) => { - if let Some(id) = self - .resource_router - .get(&ResourceKey::McpRequestId(json_rpc_response.id.clone())) - { - OutboundChannel::RequestWise { - id: *id, - close: true, - } - } else { - OutboundChannel::Common - } - } - ServerJsonRpcMessage::Error(json_rpc_error) => { - if let Some(id) = json_rpc_error.id.clone().and_then(|rid| { - self.resource_router - .get(&ResourceKey::McpRequestId(rid)) - .copied() - }) { - OutboundChannel::RequestWise { id, close: true } - } else { - OutboundChannel::Common - } - } - } - } - async fn handle_server_message( - &mut self, - message: ServerJsonRpcMessage, - ) -> Result<(), SessionError> { - let outbound_channel = self.resolve_outbound_channel(&message); - match outbound_channel { - OutboundChannel::RequestWise { id, close } => { - if let Some(request_wise) = self.tx_router.get_mut(&id) { - request_wise.tx.send(message).await?; - if close && let Some(channel) = self.tx_router.remove(&id) { - for resource in channel.resources { - self.resource_router.remove(&resource); - } - } - } else { - return Err(SessionError::ChannelClosed(Some(id))); - } - } - OutboundChannel::Common => self.common.send(message).await?, - } - Ok(()) - } - async fn resume( - &mut self, - last_event_id: EventId, - ) -> Result { - // Clean up closed shadow senders before processing - self.shadow_txs.retain(|tx| !tx.is_closed()); - - match last_event_id.http_request_id { - Some(http_request_id) => { - let request_wise = self - .tx_router - .get_mut(&http_request_id) - .ok_or(SessionError::ChannelClosed(Some(http_request_id)))?; - let is_completed = request_wise.completed_at.is_some(); - let (tx, rx) = tokio::sync::mpsc::channel(self.session_config.channel_capacity); - request_wise.tx.tx = tx; - let index = last_event_id.index; - request_wise.tx.sync(index).await?; - if is_completed { - // Drop the sender after replaying so the stream ends - // instead of hanging indefinitely. - let (closed_tx, _) = tokio::sync::mpsc::channel(1); - request_wise.tx.tx = closed_tx; - } - Ok(StreamableHttpMessageReceiver { - http_request_id: Some(http_request_id), - inner: rx, - }) - } - None => { - self.resume_or_shadow_common(Some(last_event_id.index)) - .await - } - } - } - - async fn establish_common_channel( - &mut self, - ) -> Result { - let last_event_index = self.event_store.is_none().then_some(0); - self.resume_or_shadow_common(last_event_index).await - } - - /// Resume the common channel, or create a shadow stream if the primary is - /// still active. - /// - /// When the primary common channel is dead (receiver dropped), replace it - /// so this stream becomes the new primary notification channel. Cached - /// messages are replayed from `last_event_index` so the client receives - /// any events it missed (including server-initiated requests). - /// - /// When the primary is still active, create a "shadow" stream — an idle SSE - /// connection kept alive by keep-alive pings. This prevents multiple - /// EventSource connections (e.g. from POST response reconnections) from - /// killing each other by repeatedly replacing the common channel sender. - async fn resume_or_shadow_common( - &mut self, - last_event_index: Option, - ) -> Result { - let is_replacing_dead_primary = self.common.tx.is_closed(); - let capacity = if is_replacing_dead_primary { - self.session_config.channel_capacity - } else { - 1 // Shadow streams only need keep-alive pings - }; - let (tx, rx) = tokio::sync::mpsc::channel(capacity); - if is_replacing_dead_primary { - // Primary common channel is dead — replace it. - tracing::debug!("Replacing dead common channel with new primary"); - self.common.tx = tx; - if let Some(last_event_index) = last_event_index { - self.common.sync(last_event_index).await?; - } - } else { - // Primary common channel is still active. Create a shadow stream - // that stays alive via SSE keep-alive but doesn't receive - // notifications. This prevents competing EventSource connections - // from killing each other's channels. - const MAX_SHADOW_STREAMS: usize = 32; - - if self.shadow_txs.len() >= MAX_SHADOW_STREAMS { - tracing::warn!( - shadow_count = self.shadow_txs.len(), - "Shadow stream limit reached, dropping oldest" - ); - self.shadow_txs.remove(0); - } - tracing::debug!( - shadow_count = self.shadow_txs.len(), - "Common channel active, creating shadow stream" - ); - self.shadow_txs.push(tx); - } - Ok(StreamableHttpMessageReceiver { - http_request_id: None, - inner: rx, - }) - } - - async fn close_sse_stream( - &mut self, - http_request_id: Option, - retry_interval: Option, - ) -> Result<(), SessionError> { - match http_request_id { - // Close a request-wise stream - Some(id) => { - let request_wise = self - .tx_router - .get_mut(&id) - .ok_or(SessionError::ChannelClosed(Some(id)))?; - - // Send priming event if retry interval is specified - if let Some(interval) = retry_interval { - request_wise.tx.send_priming(interval).await?; - } - - // Close the stream by dropping the sender - let (tx, _rx) = tokio::sync::mpsc::channel(1); - request_wise.tx.tx = tx; - - tracing::debug!( - http_request_id = id, - "closed SSE stream for server-initiated disconnection" - ); - Ok(()) - } - // Close the standalone (common) stream - None => { - // Send priming event if retry interval is specified - if let Some(interval) = retry_interval { - self.common.send_priming(interval).await?; - } - - // Close the stream by dropping the sender - let (tx, _rx) = tokio::sync::mpsc::channel(1); - self.common.tx = tx; - - // Also close all shadow streams - self.shadow_txs.clear(); - - tracing::debug!("closed standalone SSE stream for server-initiated disconnection"); - Ok(()) - } - } - } -} - -#[derive(Debug)] -#[non_exhaustive] -pub enum SessionEvent { - ClientMessage { - message: ClientJsonRpcMessage, - http_request_id: Option, - }, - EstablishRequestWiseChannel { - responder: oneshot::Sender>, - }, - CloseRequestWiseChannel { - id: HttpRequestId, - responder: oneshot::Sender>, - }, - Resume { - last_event_id: EventId, - responder: oneshot::Sender>, - }, - InitializeRequest { - request: ClientJsonRpcMessage, - responder: oneshot::Sender>, - }, - Close, - CloseSseStream { - /// The HTTP request ID to close. If `None`, closes the standalone (common) stream. - http_request_id: Option, - /// Optional retry interval. If provided, a priming event is sent before closing. - retry_interval: Option, - responder: oneshot::Sender>, - }, - EstablishCommonChannel { - responder: oneshot::Sender>, - }, -} - -#[derive(Debug, Clone)] -#[non_exhaustive] -pub enum SessionQuitReason { - ServiceTerminated, - ClientTerminated, - ExpectInitializeRequest, - ExpectInitializeResponse, - Cancelled, -} - -#[derive(Debug, Clone)] -pub struct LocalSessionHandle { - id: SessionId, - // after all event_tx drop, inner task will be terminated - event_tx: Sender, -} - -impl LocalSessionHandle { - /// Get the session id - pub fn id(&self) -> &SessionId { - &self.id - } - - /// Close the session - pub async fn close(&self) -> Result<(), SessionError> { - self.event_tx - .send(SessionEvent::Close) - .await - .map_err(|_| SessionError::SessionServiceTerminated)?; - Ok(()) - } - - /// Send a message to the session - pub async fn push_message( - &self, - message: ClientJsonRpcMessage, - http_request_id: Option, - ) -> Result<(), SessionError> { - self.event_tx - .send(SessionEvent::ClientMessage { - message, - http_request_id, - }) - .await - .map_err(|_| SessionError::SessionServiceTerminated)?; - Ok(()) - } - - /// establish a channel for a http-request, the corresponded message from server will be - /// sent through this channel. The channel will be closed when the request is completed, - /// or you can close it manually by calling [`LocalSessionHandle::close_request_wise_channel`]. - pub async fn establish_request_wise_channel( - &self, - ) -> Result { - let (tx, rx) = tokio::sync::oneshot::channel(); - self.event_tx - .send(SessionEvent::EstablishRequestWiseChannel { responder: tx }) - .await - .map_err(|_| SessionError::SessionServiceTerminated)?; - rx.await - .map_err(|_| SessionError::SessionServiceTerminated)? - } - - /// close the http-request wise channel. - pub async fn close_request_wise_channel( - &self, - request_id: HttpRequestId, - ) -> Result<(), SessionError> { - let (tx, rx) = tokio::sync::oneshot::channel(); - self.event_tx - .send(SessionEvent::CloseRequestWiseChannel { - id: request_id, - responder: tx, - }) - .await - .map_err(|_| SessionError::SessionServiceTerminated)?; - rx.await - .map_err(|_| SessionError::SessionServiceTerminated)? - } - - /// Establish a common channel for general purpose messages. - pub async fn establish_common_channel( - &self, - ) -> Result { - let (tx, rx) = tokio::sync::oneshot::channel(); - self.event_tx - .send(SessionEvent::EstablishCommonChannel { responder: tx }) - .await - .map_err(|_| SessionError::SessionServiceTerminated)?; - rx.await - .map_err(|_| SessionError::SessionServiceTerminated)? - } - - /// Resume streaming response by the last event id. This is suitable for both request wise and common channel. - pub async fn resume( - &self, - last_event_id: EventId, - ) -> Result { - let (tx, rx) = tokio::sync::oneshot::channel(); - self.event_tx - .send(SessionEvent::Resume { - last_event_id, - responder: tx, - }) - .await - .map_err(|_| SessionError::SessionServiceTerminated)?; - rx.await - .map_err(|_| SessionError::SessionServiceTerminated)? - } - - /// Send an initialize request to the session. And wait for the initialized response. - /// - /// This is used to establish a session with the server. - pub async fn initialize( - &self, - request: ClientJsonRpcMessage, - ) -> Result { - let (tx, rx) = tokio::sync::oneshot::channel(); - self.event_tx - .send(SessionEvent::InitializeRequest { - request, - responder: tx, - }) - .await - .map_err(|_| SessionError::SessionServiceTerminated)?; - rx.await - .map_err(|_| SessionError::SessionServiceTerminated)? - } - - /// Close an SSE stream for a specific request. - /// - /// This closes the SSE connection for a POST request stream, but keeps the session - /// and message cache active. Clients can reconnect using the `Last-Event-ID` header - /// via a GET request to resume receiving messages. - /// - /// # Arguments - /// - /// * `http_request_id` - The HTTP request ID of the stream to close - /// * `retry_interval` - Optional retry interval. If provided, a priming event is sent - pub async fn close_sse_stream( - &self, - http_request_id: HttpRequestId, - retry_interval: Option, - ) -> Result<(), SessionError> { - let (tx, rx) = tokio::sync::oneshot::channel(); - self.event_tx - .send(SessionEvent::CloseSseStream { - http_request_id: Some(http_request_id), - retry_interval, - responder: tx, - }) - .await - .map_err(|_| SessionError::SessionServiceTerminated)?; - rx.await - .map_err(|_| SessionError::SessionServiceTerminated)? - } - - /// Close the standalone SSE stream. - /// - /// This closes the standalone SSE connection (established via GET request), - /// but keeps the session and message cache active. Clients can reconnect using - /// the `Last-Event-ID` header via a GET request to resume receiving messages. - /// - /// # Arguments - /// - /// * `retry_interval` - Optional retry interval. If provided, a priming event is sent - pub async fn close_standalone_sse_stream( - &self, - retry_interval: Option, - ) -> Result<(), SessionError> { - let (tx, rx) = tokio::sync::oneshot::channel(); - self.event_tx - .send(SessionEvent::CloseSseStream { - http_request_id: None, - retry_interval, - responder: tx, - }) - .await - .map_err(|_| SessionError::SessionServiceTerminated)?; - rx.await - .map_err(|_| SessionError::SessionServiceTerminated)? - } -} - -pub type SessionTransport = WorkerTransport; - -#[allow(clippy::large_enum_variant)] -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum LocalSessionWorkerError { - #[error("transport terminated")] - TransportTerminated, - #[error("unexpected message: {0:?}")] - UnexpectedEvent(SessionEvent), - #[error("fail to send initialize request {0}")] - FailToSendInitializeRequest(SessionError), - #[error("fail to handle message: {0}")] - FailToHandleMessage(SessionError), - #[error("init timeout after {}ms", _0.as_millis())] - InitTimeout(Duration), - #[error("Transport closed")] - TransportClosed, - #[error("Tokio join error {0}")] - TokioJoinError(#[from] tokio::task::JoinError), -} -impl Worker for LocalSessionWorker { - type Error = LocalSessionWorkerError; - type Role = RoleServer; - fn err_closed() -> Self::Error { - LocalSessionWorkerError::TransportClosed - } - fn err_join(e: tokio::task::JoinError) -> Self::Error { - LocalSessionWorkerError::TokioJoinError(e) - } - fn config(&self) -> crate::transport::worker::WorkerConfig { - crate::transport::worker::WorkerConfig { - name: Some(format!("streamable-http-session-{}", self.id)), - channel_buffer_capacity: self.session_config.channel_capacity, - } - } - #[instrument(name = "streamable_http_session", skip_all, fields(id = self.id.as_ref()))] - async fn run( - mut self, - mut context: WorkerContext, - ) -> Result<(), WorkerQuitReason> { - enum InnerEvent { - FromHttpService(SessionEvent), - FromHandler(WorkerSendRequest), - } - let init_timeout = self.session_config.init_timeout.unwrap_or(Duration::MAX); - let evt = tokio::select! { - evt = self.event_rx.recv() => evt.ok_or_else(|| { - WorkerQuitReason::fatal( - LocalSessionWorkerError::TransportTerminated, - "get initialize request", - ) - })?, - _ = context.cancellation_token.cancelled() => { - return Err(WorkerQuitReason::Cancelled); - } - _ = tokio::time::sleep(init_timeout) => { - return Err(WorkerQuitReason::fatal( - LocalSessionWorkerError::InitTimeout(init_timeout), - "waiting for initialize request", - )); - } - }; - let SessionEvent::InitializeRequest { request, responder } = evt else { - return Err(WorkerQuitReason::fatal( - LocalSessionWorkerError::UnexpectedEvent(evt), - "get initialize request", - )); - }; - context.send_to_handler(request).await?; - let send_initialize_response = context.recv_from_handler().await?; - responder - .send(Ok(send_initialize_response.message)) - .map_err(|_| { - WorkerQuitReason::fatal( - LocalSessionWorkerError::FailToSendInitializeRequest( - SessionError::SessionServiceTerminated, - ), - "send initialize response", - ) - })?; - send_initialize_response - .responder - .send(Ok(())) - .map_err(|_| WorkerQuitReason::HandlerTerminated)?; - let ct = context.cancellation_token.clone(); - let keep_alive = self.session_config.keep_alive.unwrap_or(Duration::MAX); - loop { - self.evict_expired_channels(); - let keep_alive_timeout = tokio::time::sleep(keep_alive); - let event = tokio::select! { - event = self.event_rx.recv() => { - if let Some(event) = event { - InnerEvent::FromHttpService(event) - } else { - return Err(WorkerQuitReason::fatal(LocalSessionWorkerError::TransportTerminated, "waiting next session event")) - } - }, - from_handler = context.recv_from_handler() => { - InnerEvent::FromHandler(from_handler?) - } - _ = ct.cancelled() => { - return Err(WorkerQuitReason::Cancelled) - } - _ = keep_alive_timeout => { - return Err(WorkerQuitReason::IdleTimeout(keep_alive)) - } - }; - match event { - InnerEvent::FromHandler(WorkerSendRequest { message, responder }) => { - // catch response - let to_unregister = match &message { - crate::model::JsonRpcMessage::Response(json_rpc_response) => { - let request_id = json_rpc_response.id.clone(); - Some(ResourceKey::McpRequestId(request_id)) - } - crate::model::JsonRpcMessage::Error(json_rpc_error) => { - json_rpc_error.id.clone().map(ResourceKey::McpRequestId) - } - _ => { - None - // no need to unregister resource - } - }; - let handle_result = self - .handle_server_message(message) - .await - .map_err(LocalSessionWorkerError::FailToHandleMessage); - let _ = responder.send(handle_result).inspect_err(|error| { - tracing::warn!(?error, "failed to send message to http service handler"); - }); - if let Some(to_unregister) = to_unregister { - self.unregister_resource(&to_unregister); - } - } - InnerEvent::FromHttpService(SessionEvent::ClientMessage { - message: json_rpc_message, - http_request_id, - }) => { - match &json_rpc_message { - crate::model::JsonRpcMessage::Request(request) => { - if let Some(http_request_id) = http_request_id { - self.register_request(request, http_request_id) - } - } - crate::model::JsonRpcMessage::Notification(notification) => { - self.catch_cancellation_notification(notification) - } - _ => {} - } - context.send_to_handler(json_rpc_message).await?; - } - InnerEvent::FromHttpService(SessionEvent::EstablishRequestWiseChannel { - responder, - }) => { - let handle_result = self.establish_request_wise_channel().await; - let _ = responder.send(handle_result); - } - InnerEvent::FromHttpService(SessionEvent::EstablishCommonChannel { responder }) => { - let handle_result = self.establish_common_channel().await; - let _ = responder.send(handle_result); - } - InnerEvent::FromHttpService(SessionEvent::CloseRequestWiseChannel { - id, - responder, - }) => { - let _handle_result = self.tx_router.remove(&id); - let _ = responder.send(Ok(())); - } - InnerEvent::FromHttpService(SessionEvent::Resume { - last_event_id, - responder, - }) => { - let handle_result = self.resume(last_event_id).await; - let _ = responder.send(handle_result); - } - InnerEvent::FromHttpService(SessionEvent::Close) => { - return Err(WorkerQuitReason::TransportClosed); - } - InnerEvent::FromHttpService(SessionEvent::CloseSseStream { - http_request_id, - retry_interval, - responder, - }) => { - let handle_result = - self.close_sse_stream(http_request_id, retry_interval).await; - let _ = responder.send(handle_result); - } - _ => { - // ignore - } - } - } - } -} - -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct SessionConfig { - /// the capacity of the channel for the session. Default is 16. - pub channel_capacity: usize, - /// The session will be closed after this duration of inactivity. - /// - /// This serves as a safety net for cleaning up sessions whose HTTP - /// connections have silently dropped (e.g., due to an HTTP/2 - /// `RST_STREAM`). Without a timeout, such sessions become zombies: - /// the session worker keeps running indefinitely because the session - /// handle's sender is still held in the session manager, preventing - /// the worker's event channel from closing. - /// - /// Defaults to 5 minutes. Set to `None` to disable (not recommended - /// for long-running servers behind proxies). - pub keep_alive: Option, - /// SSE retry interval for priming events on request-wise streams. - /// When set, the session layer prepends a priming event with the correct - /// stream-identifying event ID to each request-wise SSE stream. - /// Default is 3 seconds, matching `StreamableHttpServerConfig::default()`. - pub sse_retry: Option, - /// How long to retain completed request-wise channel caches for late - /// resume requests. After this duration, completed entries are evicted - /// and resume will return an error. Default is 60 seconds. - pub completed_cache_ttl: Duration, - /// Maximum duration to wait for the `initialize` request after session - /// creation. If not received within this window, the session is - /// terminated. Default is 60 seconds. Set to `None` to disable. - pub init_timeout: Option, -} - -impl SessionConfig { - pub const DEFAULT_CHANNEL_CAPACITY: usize = 16; - pub const DEFAULT_KEEP_ALIVE: Duration = Duration::from_secs(300); - pub const DEFAULT_SSE_RETRY: Duration = Duration::from_secs(3); - pub const DEFAULT_COMPLETED_CACHE_TTL: Duration = Duration::from_secs(60); - pub const DEFAULT_INIT_TIMEOUT: Duration = Duration::from_secs(60); -} - -impl Default for SessionConfig { - fn default() -> Self { - Self { - channel_capacity: Self::DEFAULT_CHANNEL_CAPACITY, - keep_alive: Some(Self::DEFAULT_KEEP_ALIVE), - sse_retry: Some(Self::DEFAULT_SSE_RETRY), - completed_cache_ttl: Self::DEFAULT_COMPLETED_CACHE_TTL, - init_timeout: Some(Self::DEFAULT_INIT_TIMEOUT), - } - } -} - -/// Create a new session with the given id and configuration. -/// -/// This function will return a pair of [`LocalSessionHandle`] and [`LocalSessionWorker`]. -/// -/// You can run the [`LocalSessionWorker`] as a transport for mcp server. And use the [`LocalSessionHandle`] operate the session. -pub fn create_local_session( - id: impl Into, - config: SessionConfig, -) -> (LocalSessionHandle, LocalSessionWorker) { - create_local_session_with_event_store(id, config, None) -} - -fn create_local_session_with_event_store( - id: impl Into, - config: SessionConfig, - event_store: Option>, -) -> (LocalSessionHandle, LocalSessionWorker) { - let id = id.into(); - let (event_tx, event_rx) = tokio::sync::mpsc::channel(config.channel_capacity); - let (common_tx, _) = tokio::sync::mpsc::channel(config.channel_capacity); - let common = CachedTx::new_common(common_tx, &id, event_store.clone()); - tracing::info!(session_id = ?id, "create new session"); - let handle = LocalSessionHandle { - event_tx, - id: id.clone(), - }; - let session_worker = LocalSessionWorker { - next_http_request_id: 0, - id, - tx_router: HashMap::new(), - resource_router: HashMap::new(), - common, - shadow_txs: Vec::new(), - event_rx, - session_config: config.clone(), - event_store, - }; - (handle, session_worker) -} - -#[cfg(test)] -mod sep2260_routing_tests { - use super::*; - use crate::service::OriginatingRequestId; - - fn roots_request(originating: Option) -> ServerJsonRpcMessage { - #[expect( - deprecated, - reason = "roots is SEP-2577-deprecated; any restricted request works here" - )] - let mut request = crate::model::ListRootsRequest { - method: Default::default(), - extensions: Default::default(), - }; - if let Some(id) = originating { - request.extensions.insert(OriginatingRequestId(id)); - } - ServerJsonRpcMessage::request( - crate::model::ServerRequest::ListRootsRequest(request), - RequestId::Number(1000), - ) - } - - #[tokio::test] - async fn associated_server_request_routes_to_originating_stream() { - let (_handle, mut worker) = create_local_session("test-session", SessionConfig::default()); - let receiver = worker.establish_request_wise_channel().await.unwrap(); - let http_request_id = receiver.http_request_id.unwrap(); - let originating_id = RequestId::Number(7); - worker.register_resource( - ResourceKey::McpRequestId(originating_id.clone()), - http_request_id, - ); - - let channel = worker.resolve_outbound_channel(&roots_request(Some(originating_id))); - assert!( - matches!(channel, OutboundChannel::RequestWise { id, close: false } if id == http_request_id) - ); - } - - #[tokio::test] - async fn unassociated_server_request_routes_to_common_stream() { - let (_handle, mut worker) = create_local_session("test-session", SessionConfig::default()); - let _receiver = worker.establish_request_wise_channel().await.unwrap(); - let channel = worker.resolve_outbound_channel(&roots_request(None)); - assert!(matches!(channel, OutboundChannel::Common)); - } - - #[tokio::test] - async fn associated_request_for_completed_request_falls_back_to_common() { - // Originating request already unregistered (completion race / re-scoped - // task op): must fall back to Common, never a closed request-wise channel. - let (_handle, mut worker) = create_local_session("test-session", SessionConfig::default()); - let receiver = worker.establish_request_wise_channel().await.unwrap(); - let http_request_id = receiver.http_request_id.unwrap(); - let originating_id = RequestId::Number(7); - worker.register_resource( - ResourceKey::McpRequestId(originating_id.clone()), - http_request_id, - ); - worker.unregister_resource(&ResourceKey::McpRequestId(originating_id.clone())); - - let channel = worker.resolve_outbound_channel(&roots_request(Some(originating_id))); - assert!(matches!(channel, OutboundChannel::Common)); - } -} diff --git a/vendor/rmcp/src/transport/streamable_http_server/session/never.rs b/vendor/rmcp/src/transport/streamable_http_server/session/never.rs deleted file mode 100644 index d83031c37..000000000 --- a/vendor/rmcp/src/transport/streamable_http_server/session/never.rs +++ /dev/null @@ -1,126 +0,0 @@ -use std::sync::Arc; - -use futures::Stream; -use thiserror::Error; - -use super::{EventStore, ServerSseMessage, SessionId, SessionManager}; -use crate::{ - RoleServer, - model::{ClientJsonRpcMessage, ServerJsonRpcMessage}, - transport::Transport, -}; - -#[derive(Debug, Clone, Error)] -#[error("Session management is not supported")] -#[non_exhaustive] -pub struct ErrorSessionManagementNotSupported; -#[derive(Debug, Clone, Default)] -#[non_exhaustive] -pub struct NeverSessionManager { - event_store: Option>, -} - -impl NeverSessionManager { - /// Configure resumable SSE storage without enabling sessions. - pub fn with_event_store(mut self, event_store: Arc) -> Self { - self.event_store = Some(event_store); - self - } -} -#[non_exhaustive] -pub enum NeverTransport {} -impl Transport for NeverTransport { - type Error = ErrorSessionManagementNotSupported; - - fn send( - &mut self, - _item: ServerJsonRpcMessage, - ) -> impl Future> + Send + 'static { - futures::future::ready(Err(ErrorSessionManagementNotSupported)) - } - - fn receive(&mut self) -> impl Future> { - futures::future::ready(None) - } - - async fn close(&mut self) -> Result<(), Self::Error> { - Err(ErrorSessionManagementNotSupported) - } -} - -impl SessionManager for NeverSessionManager { - type Error = ErrorSessionManagementNotSupported; - type Transport = NeverTransport; - - fn create_session( - &self, - ) -> impl Future> + Send { - futures::future::ready(Err(ErrorSessionManagementNotSupported)) - } - - fn initialize_session( - &self, - _id: &SessionId, - _message: ClientJsonRpcMessage, - ) -> impl Future> + Send { - futures::future::ready(Err(ErrorSessionManagementNotSupported)) - } - - fn has_session( - &self, - _id: &SessionId, - ) -> impl Future> + Send { - futures::future::ready(Err(ErrorSessionManagementNotSupported)) - } - - fn close_session( - &self, - _id: &SessionId, - ) -> impl Future> + Send { - futures::future::ready(Err(ErrorSessionManagementNotSupported)) - } - - fn create_stream( - &self, - _id: &SessionId, - _message: ClientJsonRpcMessage, - ) -> impl Future< - Output = Result + Send + 'static, Self::Error>, - > + Send { - futures::future::ready(Result::, _>::Err( - ErrorSessionManagementNotSupported, - )) - } - fn create_standalone_stream( - &self, - _id: &SessionId, - ) -> impl Future< - Output = Result + Send + 'static, Self::Error>, - > + Send { - futures::future::ready(Result::, _>::Err( - ErrorSessionManagementNotSupported, - )) - } - fn resume( - &self, - _id: &SessionId, - _last_event_id: String, - ) -> impl Future< - Output = Result + Send + 'static, Self::Error>, - > + Send { - futures::future::ready(Result::, _>::Err( - ErrorSessionManagementNotSupported, - )) - } - fn accept_message( - &self, - _id: &SessionId, - _message: ClientJsonRpcMessage, - ) -> impl Future> + Send { - futures::future::ready(Err(ErrorSessionManagementNotSupported)) - } - - fn event_store(&self) -> Option> { - self.event_store.clone() - } -} diff --git a/vendor/rmcp/src/transport/streamable_http_server/session/store.rs b/vendor/rmcp/src/transport/streamable_http_server/session/store.rs deleted file mode 100644 index 8b656201c..000000000 --- a/vendor/rmcp/src/transport/streamable_http_server/session/store.rs +++ /dev/null @@ -1,126 +0,0 @@ -use std::pin::Pin; - -use futures::Stream; - -use crate::{ - model::InitializeRequestParams, transport::common::server_side_http::ServerSseMessage, -}; - -/// An opaque identifier for a persisted SSE event. -pub type EventId = String; - -/// An opaque identifier for an SSE stream. -pub type StreamId = String; - -/// A stream of persisted SSE events in delivery order. -pub type EventStream = Pin + Send + Sync + 'static>>; - -/// Type alias for boxed event store errors. -pub type EventStoreError = Box; - -/// Persistent storage for resumable Streamable HTTP events. -/// -/// Implementations typically use a database or distributed log shared by all -/// server instances. Event IDs must be globally unique across all streams, and -/// events must be committed before [`EventStore::store_event`] returns so the -/// returned ID is safe to send to a client. -#[async_trait::async_trait] -pub trait EventStore: Send + Sync + 'static { - /// Persist an event and return the opaque ID clients should receive. - /// - /// The store assigns a globally unique ID and must retain its association - /// with `stream_id` so a later replay only returns events from that stream. - async fn store_event( - &self, - stream_id: &str, - event: &ServerSseMessage, - ) -> Result; - - /// Return events strictly after `last_event_id` in delivery order. - /// - /// Implementations must locate the stream from the globally unique event - /// ID and yield only later events from that stream, with their originally - /// assigned event IDs. - /// - /// A finite stream enables reconnect-and-poll behavior. Implementations - /// backed by a distributed log may keep the stream open to deliver new - /// events as they are appended by any server instance. - async fn replay_events_after( - &self, - last_event_id: &str, - ) -> Result; -} - -impl std::fmt::Debug for dyn EventStore { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("") - } -} - -/// State persisted to an external store for cross-instance session recovery. -/// -/// When a client reconnects to a different server instance, the new instance -/// loads this state to transparently replay the `initialize` handshake without -/// the client needing to re-initialize. -#[non_exhaustive] -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct SessionState { - /// Parameters from the client's original `initialize` request. - pub initialize_params: InitializeRequestParams, -} - -impl SessionState { - pub fn new(initialize_params: InitializeRequestParams) -> Self { - Self { initialize_params } - } -} - -/// Type alias for boxed session store errors. -pub type SessionStoreError = Box; - -/// Pluggable external session store for cross-instance recovery. -/// -/// Implement this trait to back sessions with Redis, a database, or any -/// key-value store. The simplest usage is to set -/// `StreamableHttpServerConfig::session_store` to an `Arc`. -/// -/// # Example (in-memory, for testing) -/// -/// ```rust,ignore -/// use std::{collections::HashMap, sync::Arc}; -/// use tokio::sync::RwLock; -/// use rmcp::transport::streamable_http_server::session::store::{ -/// SessionState, SessionStore, SessionStoreError, -/// }; -/// -/// #[derive(Default)] -/// struct InMemoryStore(Arc>>); -/// -/// #[async_trait::async_trait] -/// impl SessionStore for InMemoryStore { -/// async fn load(&self, id: &str) -> Result, SessionStoreError> { -/// Ok(self.0.read().await.get(id).cloned()) -/// } -/// async fn store(&self, id: &str, state: &SessionState) -> Result<(), SessionStoreError> { -/// self.0.write().await.insert(id.to_owned(), state.clone()); -/// Ok(()) -/// } -/// async fn delete(&self, id: &str) -> Result<(), SessionStoreError> { -/// self.0.write().await.remove(id); -/// Ok(()) -/// } -/// } -/// ``` -#[async_trait::async_trait] -pub trait SessionStore: Send + Sync + 'static { - /// Load session state for the given `session_id`. - /// - /// Returns `Ok(None)` when no entry exists (i.e. session is unknown to the store). - async fn load(&self, session_id: &str) -> Result, SessionStoreError>; - - /// Persist session state for the given `session_id`. - async fn store(&self, session_id: &str, state: &SessionState) -> Result<(), SessionStoreError>; - - /// Remove session state for the given `session_id`. - async fn delete(&self, session_id: &str) -> Result<(), SessionStoreError>; -} diff --git a/vendor/rmcp/src/transport/streamable_http_server/tower.rs b/vendor/rmcp/src/transport/streamable_http_server/tower.rs deleted file mode 100644 index f1fef585f..000000000 --- a/vendor/rmcp/src/transport/streamable_http_server/tower.rs +++ /dev/null @@ -1,2151 +0,0 @@ -use std::{ - borrow::Cow, - collections::HashMap, - convert::Infallible, - fmt::Display, - pin::Pin, - sync::Arc, - task::{Context, Poll}, - time::Duration, -}; - -use bytes::Bytes; -use futures::{Stream, StreamExt, future::BoxFuture}; -use http::{HeaderMap, Method, Request, Response, header::ALLOW}; -use http_body::Body; -use http_body_util::{BodyExt, Full, combinators::BoxBody}; -use pin_project_lite::pin_project; -use tokio_stream::wrappers::ReceiverStream; -use tokio_util::sync::CancellationToken; - -use super::session::{ - EventStore, EventStoreError, RestoreOutcome, SessionId, SessionManager, SessionRestoreMarker, - SessionState, SessionStore, -}; -use crate::{ - RoleServer, - model::{ - ClientCapabilities, ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorCode, - ErrorData, GetExtensions, GetMeta, Implementation, InitializeRequest, - InitializeRequestParams, InitializedNotification, JsonObject, JsonRpcError, - ProtocolVersion, RequestId, ServerInfo, ServerJsonRpcMessage, ServerResult, - }, - serve_server, - service::{ - NotificationContext, RequestContext, Service, negotiate_protocol_version, - serve_directly_with_ct, uses_legacy_lifecycle, - }, - transport::{ - OneshotTransport, TransportAdapterIdentity, - common::{ - http_header::{ - EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_MCP_PROTOCOL_VERSION, - HEADER_SESSION_ID, JSON_MIME_TYPE, - }, - mcp_headers, - server_side_http::{ - BoxResponse, ServerSseMessage, accepted_response, expect_json, - internal_error_response, sse_stream_response, unexpected_message_response, - }, - }, - }, -}; - -/// Default maximum POST request body size (4 MiB). -pub(crate) const DEFAULT_MAX_REQUEST_BODY_BYTES: usize = 4 * 1024 * 1024; -const STATELESS_STREAM_CHANNEL_CAPACITY: usize = 16; - -#[non_exhaustive] -#[derive(Debug, Clone)] -pub struct StreamableHttpServerConfig { - /// The ping message duration for SSE connections. - pub sse_keep_alive: Option, - /// The retry interval for SSE priming events. - pub sse_retry: Option, - /// If true, the server will create a session for each request and keep it alive. - /// When enabled, SSE priming events are sent to enable client reconnection. - /// - /// Only applies to legacy protocol versions (`< 2026-07-28`). Per SEP-2567, - /// sessions are removed from the `2026-07-28` version, so requests - /// negotiating that version are always served statelessly regardless of - /// this setting. - pub legacy_session_mode: bool, - /// When true and `legacy_session_mode` is false, the server prefers - /// `Content-Type: application/json` for simple request-response tools. - /// If the handler emits a notification or request before the final response, - /// the server falls back to `text/event-stream` so no message is lost. - pub json_response: bool, - /// Cancellation token for the Streamable HTTP server. - /// - /// When this token is cancelled, all active sessions are terminated and - /// the server stops accepting new requests. - pub cancellation_token: CancellationToken, - /// Allowed hostnames or `host:port` authorities for inbound `Host` validation. - /// - /// By default, Streamable HTTP servers only accept loopback hosts to - /// prevent DNS rebinding attacks against locally running servers. Public - /// deployments should override this list with their own hostnames. - /// examples: - /// allowed_hosts = ["localhost", "127.0.0.1", "0.0.0.0"] - /// or with ports: - /// allowed_hosts = ["example.com", "example.com:8080"] - pub allowed_hosts: Vec, - /// Allowed browser origins for inbound `Origin` validation. - /// - /// Defaults to an empty list, which disables Origin validation. When - /// non-empty, requests carrying an `Origin` header must match per RFC 6454 - /// `(scheme, host, port)`; missing-`Origin` requests still pass. Entries - /// must include a scheme; `"null"` matches the browser's `Origin: null`. - /// examples: - /// allowed_origins = ["https://app.example.com", "http://localhost:8080"] - pub allowed_origins: Vec, - /// Optional external session store for cross-instance recovery. - /// - /// When set, [`SessionState`] (the client's `initialize` parameters) is - /// persisted after a successful handshake and deleted when the session - /// closes. On any subsequent request that arrives at an instance with no - /// in-memory session, the store is consulted: if an entry is found the - /// session is transparently restored so the client does not need to - /// re-initialize. - /// - /// # Example - /// ```rust,ignore - /// use std::sync::Arc; - /// use rmcp::transport::streamable_http_server::{ - /// StreamableHttpServerConfig, session::SessionStore, - /// }; - /// - /// let config = StreamableHttpServerConfig { - /// session_store: Some(Arc::new(MyRedisStore::new())), - /// ..Default::default() - /// }; - /// ``` - pub session_store: Option>, - /// Maximum POST request body size in bytes. - /// - /// Enforced while streaming the body, independent of `Content-Length`, - /// chunked transfer encoding, or HTTP version. Oversized payloads receive - /// a `413 Payload Too Large` response. - pub max_request_body_bytes: usize, - /// Require stateless JSON-RPC request POSTs to carry per-request protocol - /// signals before handler dispatch. - /// - /// Non-initialize requests must carry `MCP-Protocol-Version`; ordinary - /// non-discovery requests must also carry - /// `_meta.io.modelcontextprotocol/protocolVersion`. `server/discover` - /// retains its existing request-metadata validation. For `2026-07-28` - /// requests, the server handler continues to require the remaining - /// per-request metadata, including `clientCapabilities`. Initialize, - /// notifications, and other message kinds retain their existing rules. - /// - /// This option applies to requests routed statelessly. Set - /// `legacy_session_mode` to `false` to ensure every request uses that path. - /// Legacy session routing and its error precedence remain unchanged. - /// - /// The validator checks metadata presence rather than applying a version - /// allowlist. However, rmcp clients negotiated below `2026-07-28` do not - /// attach per-request protocol metadata, so enabling this option rejects - /// their ordinary requests. Servers using this option should normally - /// override - /// [`ServerHandler::supported_protocol_versions`](crate::ServerHandler::supported_protocol_versions) - /// to advertise only `2026-07-28` and later. - /// - /// Default is `false`, preserving today's legacy behavior where an absent - /// header is treated as protocol version `2025-03-26`. - pub stateless_protocol_metadata_required: bool, -} - -impl std::fmt::Debug for dyn SessionStore { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("") - } -} - -impl Default for StreamableHttpServerConfig { - fn default() -> Self { - Self { - sse_keep_alive: Some(Duration::from_secs(15)), - sse_retry: Some(Duration::from_secs(3)), - legacy_session_mode: true, - json_response: false, - cancellation_token: CancellationToken::new(), - allowed_hosts: vec!["localhost".into(), "127.0.0.1".into(), "::1".into()], - allowed_origins: vec![], - session_store: None, - max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES, - stateless_protocol_metadata_required: false, - } - } -} - -impl StreamableHttpServerConfig { - pub fn with_allowed_hosts( - mut self, - allowed_hosts: impl IntoIterator>, - ) -> Self { - self.allowed_hosts = allowed_hosts.into_iter().map(Into::into).collect(); - self - } - /// Disable allowed hosts. This will allow requests with any `Host` header, which is NOT recommended for public deployments. - pub fn disable_allowed_hosts(mut self) -> Self { - self.allowed_hosts.clear(); - self - } - pub fn with_allowed_origins( - mut self, - allowed_origins: impl IntoIterator>, - ) -> Self { - self.allowed_origins = allowed_origins.into_iter().map(Into::into).collect(); - self - } - /// Disable Origin validation, reverting to the default ignore-Origin behavior. - pub fn disable_allowed_origins(mut self) -> Self { - self.allowed_origins.clear(); - self - } - pub fn with_sse_keep_alive(mut self, duration: Option) -> Self { - self.sse_keep_alive = duration; - self - } - - pub fn with_sse_retry(mut self, duration: Option) -> Self { - self.sse_retry = duration; - self - } - - pub fn with_legacy_session_mode(mut self, legacy_session_mode: bool) -> Self { - self.legacy_session_mode = legacy_session_mode; - self - } - - pub fn with_json_response(mut self, json_response: bool) -> Self { - self.json_response = json_response; - self - } - - pub fn with_cancellation_token(mut self, token: CancellationToken) -> Self { - self.cancellation_token = token; - self - } - - /// Set the maximum POST request body size in bytes. - pub fn with_max_request_body_bytes(mut self, bytes: usize) -> Self { - self.max_request_body_bytes = bytes; - self - } - - /// Require per-request protocol signals on stateless JSON-RPC request - /// POSTs. - /// - /// See [`StreamableHttpServerConfig::stateless_protocol_metadata_required`]. - pub fn with_stateless_protocol_metadata_required( - mut self, - stateless_protocol_metadata_required: bool, - ) -> Self { - self.stateless_protocol_metadata_required = stateless_protocol_metadata_required; - self - } -} - -#[expect( - clippy::result_large_err, - reason = "BoxResponse is intentionally large; matches other handlers in this file" -)] -/// Validates the `MCP-Protocol-Version` header on incoming HTTP requests. -/// -/// Per the MCP 2025-06-18 spec: -/// - If the header is present but contains an unsupported version, return 400 Bad Request. -/// - If the header is absent, assume `2025-03-26` for backwards compatibility (no error). -fn validate_protocol_version_header( - headers: &http::HeaderMap, - allow_unknown: bool, -) -> Result<(), BoxResponse> { - if let Some(value) = headers.get(HEADER_MCP_PROTOCOL_VERSION) { - let version_str = value.to_str().map_err(|_| { - Response::builder() - .status(http::StatusCode::BAD_REQUEST) - .body( - Full::new(Bytes::from( - "Bad Request: Invalid MCP-Protocol-Version header encoding", - )) - .boxed(), - ) - .expect("valid response") - })?; - let is_known = ProtocolVersion::KNOWN_VERSIONS - .iter() - .any(|v| v.as_str() == version_str); - if !allow_unknown && !is_known { - return Err(Response::builder() - .status(http::StatusCode::BAD_REQUEST) - .body( - Full::new(Bytes::from(format!( - "Bad Request: Unsupported MCP-Protocol-Version: {version_str}" - ))) - .boxed(), - ) - .expect("valid response")); - } - } - Ok(()) -} - -fn message_has_per_request_protocol_version(message: &ClientJsonRpcMessage) -> bool { - match message { - ClientJsonRpcMessage::Request(request) => { - request.request.get_meta().protocol_version().is_some() - } - _ => false, - } -} - -struct NegotiatingStatelessHttpService(S); - -impl> Service for NegotiatingStatelessHttpService { - async fn handle_request( - &self, - request: ClientRequest, - context: RequestContext, - ) -> Result { - let requested_protocol_version = - if let ClientRequest::InitializeRequest(initialize) = &request { - Some(initialize.params.protocol_version.clone()) - } else { - None - }; - let peer = context.peer.clone(); - let mut response = self.0.handle_request(request, context).await?; - if let (Some(requested), ServerResult::InitializeResult(result)) = - (requested_protocol_version, &mut response) - { - result.protocol_version = negotiate_protocol_version( - &requested, - result.protocol_version.clone(), - &self.0.supported_protocol_versions(), - ); - if let Some(peer_info) = peer.peer_info() { - let mut peer_info = (*peer_info).clone(); - peer_info.protocol_version = result.protocol_version.clone(); - peer.set_peer_info(peer_info); - } - } - Ok(response) - } - - async fn handle_notification( - &self, - notification: ClientNotification, - context: NotificationContext, - ) -> Result<(), ErrorData> { - self.0.handle_notification(notification, context).await - } - - fn get_info(&self) -> ServerInfo { - self.0.get_info() - } - - fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { - self.0.supported_protocol_versions() - } -} - -#[expect( - clippy::result_large_err, - reason = "BoxResponse is intentionally large; matches other handlers in this file" -)] -// SEP-2567: sessions are removed from the discover lifecycle. Validate -// protocol-version consistency, then classify the request with the shared -// lifecycle helper. -fn is_legacy_request( - message: Option<&ClientJsonRpcMessage>, - headers: &HeaderMap, -) -> Result { - let has_per_request_version = message.is_some_and(message_has_per_request_protocol_version); - validate_protocol_version_header(headers, has_per_request_version)?; - if let Some(message) = message { - if let ClientJsonRpcMessage::Request(req) = message { - if let ClientRequest::InitializeRequest(init) = &req.request { - validate_header_matches_init_body( - headers, - init.params.protocol_version.as_str(), - Some(req.id.clone()), - )?; - } - } - validate_request_protocol_version_meta(headers, message)?; - } - - let uses_discover_lifecycle = matches!( - message, - Some(ClientJsonRpcMessage::Request(req)) - if !matches!(&req.request, ClientRequest::InitializeRequest(_)) - && req - .request - .get_meta() - .missing_required_keys(&ProtocolVersion::V_2026_07_28) - .is_empty() - ); - - let from_body = match message { - Some(ClientJsonRpcMessage::Request(req)) => match &req.request { - ClientRequest::InitializeRequest(init) => Some(init.params.protocol_version.clone()), - _ => req.request.get_meta().protocol_version(), - }, - _ => None, - }; - let version = from_body - .or_else(|| { - headers - .get(HEADER_MCP_PROTOCOL_VERSION) - .and_then(|value| value.to_str().ok()) - .and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_owned())).ok()) - }) - .unwrap_or(ProtocolVersion::V_2025_03_26); - Ok(uses_legacy_lifecycle( - Some(&version), - uses_discover_lifecycle, - )) -} - -fn method_not_allowed_response() -> BoxResponse { - Response::builder() - .status(http::StatusCode::METHOD_NOT_ALLOWED) - .header(ALLOW, "POST") - .body(Full::new(Bytes::from("Method Not Allowed")).boxed()) - .expect("valid response") -} - -async fn persist_and_forward_event( - event_store: &dyn EventStore, - stream_id: &str, - mut event: ServerSseMessage, - output: &mut Option>, -) -> Result<(), EventStoreError> { - event.event_id = Some(event_store.store_event(stream_id, &event).await?); - if let Some(sender) = output { - if sender.send(event).await.is_err() { - *output = None; - } - } - Ok(()) -} - -fn invalid_request_jsonrpc_response( - id: Option, - message: impl Into>, -) -> BoxResponse { - let err = JsonRpcError::new(id, ErrorData::invalid_request(message, None)); - let body = serde_json::to_vec(&err).expect("serialize JsonRpcError"); - Response::builder() - .status(http::StatusCode::BAD_REQUEST) - .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) - .body(Full::new(Bytes::from(body)).boxed()) - .expect("valid response") -} - -fn invalid_params_jsonrpc_response( - id: Option, - message: impl Into>, -) -> BoxResponse { - let err = JsonRpcError::new(id, ErrorData::invalid_params(message, None)); - let body = serde_json::to_vec(&err).expect("serialize JsonRpcError"); - Response::builder() - .status(http::StatusCode::BAD_REQUEST) - .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) - .body(Full::new(Bytes::from(body)).boxed()) - .expect("valid response") -} - -#[expect( - clippy::result_large_err, - reason = "BoxResponse is intentionally large; matches other handlers in this file" -)] -/// Absent header is allowed; the first initialize round-trip may legitimately omit it. -fn validate_header_matches_init_body( - headers: &http::HeaderMap, - body_version: &str, - request_id: Option, -) -> Result<(), BoxResponse> { - let Some(header_value) = headers.get(HEADER_MCP_PROTOCOL_VERSION) else { - return Ok(()); - }; - let header_str = header_value.to_str().map_err(|_| { - invalid_request_jsonrpc_response( - request_id.clone(), - "Invalid Request: MCP-Protocol-Version header is not valid UTF-8", - ) - })?; - if header_str != body_version { - tracing::warn!( - header = header_str, - body = body_version, - "rejecting initialize: MCP-Protocol-Version header does not match params.protocolVersion" - ); - return Err(invalid_request_jsonrpc_response( - request_id, - format!( - "Invalid Request: MCP-Protocol-Version header ({header_str}) does not match initialize params.protocolVersion ({body_version})" - ), - )); - } - Ok(()) -} - -#[expect( - clippy::result_large_err, - reason = "BoxResponse is intentionally large; matches other handlers in this file" -)] -fn validate_request_protocol_version_meta( - headers: &HeaderMap, - message: &ClientJsonRpcMessage, -) -> Result<(), BoxResponse> { - let ClientJsonRpcMessage::Request(request) = message else { - return Ok(()); - }; - if matches!(&request.request, ClientRequest::InitializeRequest(_)) { - return Ok(()); - } - let is_discover = matches!(&request.request, ClientRequest::DiscoverRequest(_)); - let meta = request.request.get_meta(); - let header_version = headers - .get(HEADER_MCP_PROTOCOL_VERSION) - .and_then(|value| value.to_str().ok()); - let Some(meta_version) = meta.protocol_version() else { - let requires_request_metadata = is_discover - || header_version - .is_some_and(|version| version >= ProtocolVersion::V_2026_07_28.as_str()); - if requires_request_metadata { - let missing = meta.missing_required_keys(&ProtocolVersion::V_2026_07_28); - return Err(invalid_params_jsonrpc_response( - Some(request.id.clone()), - format!( - "Invalid params: request _meta is missing or has malformed required fields: {}", - missing.join(", ") - ), - )); - } - return Ok(()); - }; - let Some(header_version) = header_version else { - return Err(header_mismatch_jsonrpc_response( - Some(request.id.clone()), - "request _meta protocolVersion requires MCP-Protocol-Version header", - )); - }; - if header_version != meta_version.as_str() { - return Err(header_mismatch_jsonrpc_response( - Some(request.id.clone()), - format!( - "MCP-Protocol-Version header ({header_version}) does not match request _meta protocolVersion ({meta_version})" - ), - )); - } - Ok(()) -} - -/// When `stateless_protocol_metadata_required` is enabled in stateless mode, -/// every non-initialize Streamable HTTP JSON-RPC request POST must carry the -/// `MCP-Protocol-Version` HTTP header. A missing header is rejected with -/// HTTP 400 / JSON-RPC `-32020` before handler dispatch. `server/discover` -/// is included so the seam aligns with the per-POST header contract; its -/// body-metadata rule is preserved unchanged. -#[expect( - clippy::result_large_err, - reason = "BoxResponse is intentionally large; matches other handlers in this file" -)] -fn validate_required_protocol_header( - config: &StreamableHttpServerConfig, - headers: &HeaderMap, - message: &ClientJsonRpcMessage, -) -> Result<(), BoxResponse> { - if !config.stateless_protocol_metadata_required { - return Ok(()); - } - let ClientJsonRpcMessage::Request(request) = message else { - // Notifications, response messages, and error messages are exempt. - return Ok(()); - }; - if matches!(&request.request, ClientRequest::InitializeRequest(_)) { - // Initialize keeps its own header-matching rule. - return Ok(()); - } - if headers.contains_key(HEADER_MCP_PROTOCOL_VERSION) { - return Ok(()); - } - Err(header_mismatch_jsonrpc_response( - Some(request.id.clone()), - "Missing MCP-Protocol-Version header for request requiring per-request protocol metadata", - )) -} - -/// When `stateless_protocol_metadata_required` is enabled in stateless mode, -/// every non-initialize, non-discover Streamable HTTP JSON-RPC request must -/// carry `io.modelcontextprotocol/protocolVersion` in `_meta`. A missing entry -/// is rejected with HTTP 400 / JSON-RPC `-32602` (invalid_params). `initialize`, -/// `server/discover` (whose body-metadata rule is already enforced by -/// `validate_request_protocol_version_meta`), notifications, and other message -/// kinds are exempt. -#[expect( - clippy::result_large_err, - reason = "BoxResponse is intentionally large; matches other handlers in this file" -)] -fn validate_required_protocol_meta( - config: &StreamableHttpServerConfig, - message: &ClientJsonRpcMessage, -) -> Result<(), BoxResponse> { - if !config.stateless_protocol_metadata_required { - return Ok(()); - } - let ClientJsonRpcMessage::Request(request) = message else { - return Ok(()); - }; - if matches!( - &request.request, - ClientRequest::InitializeRequest(_) | ClientRequest::DiscoverRequest(_) - ) { - return Ok(()); - } - if request.request.get_meta().protocol_version().is_some() { - return Ok(()); - } - Err(invalid_params_jsonrpc_response( - Some(request.id.clone()), - "Invalid params: request requires protocolVersion in request _meta", - )) -} - -fn jsonrpc_http_status(message: &ServerJsonRpcMessage) -> http::StatusCode { - let ServerJsonRpcMessage::Error(error) = message else { - return http::StatusCode::OK; - }; - // Modern per-request HTTP treats invalid params as a malformed request. - // Legacy requests bypass this mapper and retain HTTP 200 JSON-RPC errors. - match error.error.code { - ErrorCode::UNSUPPORTED_PROTOCOL_VERSION - | ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY - | ErrorCode::INVALID_PARAMS => http::StatusCode::BAD_REQUEST, - ErrorCode::METHOD_NOT_FOUND => http::StatusCode::NOT_FOUND, - _ => http::StatusCode::OK, - } -} - -fn jsonrpc_message_response( - message: ServerJsonRpcMessage, - map_protocol_status: bool, -) -> Result { - let status = if map_protocol_status { - jsonrpc_http_status(&message) - } else { - http::StatusCode::OK - }; - let body = - serde_json::to_vec(&message).map_err(internal_error_response("serialize json response"))?; - Ok(Response::builder() - .status(status) - .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) - .body(Full::new(Bytes::from(body)).boxed()) - .expect("valid response")) -} - -fn header_mismatch_jsonrpc_response( - id: Option, - message: impl Into>, -) -> BoxResponse { - let err = JsonRpcError::new(id, ErrorData::header_mismatch(message, None)); - let body = serde_json::to_vec(&err).expect("serialize JsonRpcError"); - Response::builder() - .status(http::StatusCode::BAD_REQUEST) - .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) - .body(Full::new(Bytes::from(body)).boxed()) - .expect("valid response") -} - -/// Validates SEP-2243 `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*` headers against the body. -/// -/// Only enforced when the request declares a protocol version `>= STANDARD_HEADERS`. -/// The `initialize` handshake is exempt: clients emit these headers only after the -/// version has been negotiated. `tool_schema` supplies the called tool's input schema -/// so annotated `Mcp-Param-*` headers can be checked (no schema => those are skipped). -#[expect( - clippy::result_large_err, - reason = "BoxResponse is intentionally large; matches other handlers in this file" -)] -fn validate_standard_headers( - headers: &HeaderMap, - message: &ClientJsonRpcMessage, - tool_schema: impl Fn(&str) -> Option>, -) -> Result<(), BoxResponse> { - let version_requires_headers = headers - .get(HEADER_MCP_PROTOCOL_VERSION) - .and_then(|value| value.to_str().ok()) - .is_some_and(|version| version >= ProtocolVersion::STANDARD_HEADERS.as_str()); - if !version_requires_headers { - return Ok(()); - } - - let request_id = match message { - ClientJsonRpcMessage::Request(req) => { - if matches!(&req.request, ClientRequest::InitializeRequest(_)) { - return Ok(()); - } - Some(req.id.clone()) - } - ClientJsonRpcMessage::Notification(_) => None, - _ => return Ok(()), - }; - - let Ok(value) = serde_json::to_value(message) else { - return Ok(()); - }; - // For tools/call, look up the tool schema so Mcp-Param-* headers are validated. - let schema = value - .get("method") - .and_then(|method| method.as_str()) - .filter(|method| *method == "tools/call") - .and_then(|_| value.get("params")) - .and_then(|params| params.get("name")) - .and_then(|name| name.as_str()) - .and_then(tool_schema); - if let Err(reason) = mcp_headers::validate_request_headers(headers, &value, schema.as_deref()) { - return Err(header_mismatch_jsonrpc_response(request_id, reason)); - } - Ok(()) -} - -fn forbidden_response(message: impl Into) -> BoxResponse { - Response::builder() - .status(http::StatusCode::FORBIDDEN) - .body(Full::new(Bytes::from(message.into())).boxed()) - .expect("valid response") -} - -fn normalize_host(host: &str) -> String { - host.trim_matches('[') - .trim_matches(']') - .to_ascii_lowercase() -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct NormalizedAuthority { - host: String, - port: Option, -} - -fn normalize_authority(host: &str, port: Option) -> NormalizedAuthority { - NormalizedAuthority { - host: normalize_host(host), - port, - } -} - -fn parse_allowed_authority(allowed: &str) -> Option { - let allowed = allowed.trim(); - if allowed.is_empty() { - return None; - } - - if let Ok(authority) = http::uri::Authority::try_from(allowed) { - return Some(normalize_authority(authority.host(), authority.port_u16())); - } - - Some(normalize_authority(allowed, None)) -} - -fn host_is_allowed(host: &NormalizedAuthority, allowed_hosts: &[String]) -> bool { - if allowed_hosts.is_empty() { - // If the allowed hosts list is empty, allow all hosts (not recommended). - return true; - } - allowed_hosts - .iter() - .filter_map(|allowed| parse_allowed_authority(allowed)) - .any(|allowed| { - allowed.host == host.host - && match allowed.port { - Some(port) => host.port == Some(port), - None => true, - } - }) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum NormalizedOrigin { - Null, - Tuple { - scheme: String, - host: String, - port: Option, - }, -} - -fn parse_origin_value(value: &str) -> Option { - let value = value.trim(); - if value.is_empty() { - return None; - } - if value.eq_ignore_ascii_case("null") { - return Some(NormalizedOrigin::Null); - } - let uri = http::Uri::try_from(value).ok()?; - let scheme = uri.scheme_str()?.to_ascii_lowercase(); - let authority = uri.authority()?; - Some(NormalizedOrigin::Tuple { - scheme, - host: normalize_host(authority.host()), - port: authority.port_u16(), - }) -} - -fn origin_is_allowed(origin: &NormalizedOrigin, allowed_origins: &[String]) -> bool { - if allowed_origins.is_empty() { - return true; - } - allowed_origins - .iter() - .filter_map(|raw| parse_origin_value(raw)) - .any(|allowed| match (&allowed, origin) { - (NormalizedOrigin::Null, NormalizedOrigin::Null) => true, - ( - NormalizedOrigin::Tuple { - scheme: a_scheme, - host: a_host, - port: a_port, - }, - NormalizedOrigin::Tuple { - scheme: o_scheme, - host: o_host, - port: o_port, - }, - ) => a_scheme == o_scheme && a_host == o_host && (a_port.is_none() || a_port == o_port), - _ => false, - }) -} - -fn bad_request_response(message: &str) -> BoxResponse { - let body = Full::from(message.to_string()).boxed(); - - http::Response::builder() - .status(http::StatusCode::BAD_REQUEST) - .header(http::header::CONTENT_TYPE, "text/plain; charset=utf-8") - .body(body) - .expect("failed to build bad request response") -} - -fn parse_host_header( - uri: &http::Uri, - headers: &HeaderMap, -) -> Result { - if let Some(host) = headers.get(http::header::HOST) { - let host_str = host - .to_str() - .inspect_err(|_| { - tracing::warn!(host = ?host, "rejected request with non-UTF-8 Host header"); - }) - .map_err(|_| bad_request_response("Bad Request: Invalid Host header encoding"))?; - let authority = http::uri::Authority::try_from(host_str) - .inspect_err(|_| { - tracing::warn!( - host = host_str, - "rejected request with malformed Host header" - ); - }) - .map_err(|_| bad_request_response("Bad Request: Invalid Host header"))?; - return Ok(normalize_authority(authority.host(), authority.port_u16())); - } - // HTTP/2 carries the host in `:authority`; middleware such as - // `axum::Router::nest` can drop the `Host` header hyper synthesizes from it. - let authority = uri.authority().ok_or_else(|| { - tracing::warn!("rejected request with missing Host header and no :authority"); - bad_request_response("Bad Request: missing Host header") - })?; - Ok(normalize_authority(authority.host(), authority.port_u16())) -} - -fn validate_dns_rebinding_headers( - uri: &http::Uri, - headers: &HeaderMap, - config: &StreamableHttpServerConfig, -) -> Result<(), BoxResponse> { - let host = parse_host_header(uri, headers)?; - if !host_is_allowed(&host, &config.allowed_hosts) { - tracing::warn!( - host = ?host, - "rejected request with disallowed Host header (possible DNS rebinding attempt)", - ); - return Err(forbidden_response("Forbidden: Host header is not allowed")); - } - validate_origin_header(headers, &config.allowed_origins)?; - Ok(()) -} - -fn validate_origin_header( - headers: &HeaderMap, - allowed_origins: &[String], -) -> Result<(), BoxResponse> { - if allowed_origins.is_empty() { - return Ok(()); - } - let Some(origin_header) = headers.get(http::header::ORIGIN) else { - return Ok(()); - }; - let origin_str = origin_header - .to_str() - .inspect_err(|_| { - tracing::warn!(origin = ?origin_header, "rejected request with non-UTF-8 Origin header"); - }) - .map_err(|_| bad_request_response("Bad Request: Invalid Origin header encoding"))?; - let origin = parse_origin_value(origin_str).ok_or_else(|| { - tracing::warn!( - origin = origin_str, - "rejected request with malformed Origin header", - ); - bad_request_response("Bad Request: Invalid Origin header") - })?; - if !origin_is_allowed(&origin, allowed_origins) { - tracing::warn!( - origin = ?origin, - "rejected request with disallowed Origin header (possible cross-origin attack)", - ); - return Err(forbidden_response( - "Forbidden: Origin header is not allowed", - )); - } - Ok(()) -} - -/// # Streamable HTTP server -/// -/// An HTTP service that implements the -/// [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http) -/// for MCP servers. -/// -/// ## Session management -/// -/// When [`StreamableHttpServerConfig::legacy_session_mode`] is `true` (the default), -/// the server creates a session for each client that sends an `initialize` -/// request. The session ID is returned in the `Mcp-Session-Id` response header -/// and the client must include it on all subsequent requests. -/// -/// Two tool calls carrying the same `Mcp-Session-Id` come from the same logical -/// session (typically one conversation in an LLM client). Different session IDs -/// mean different sessions. -/// -/// The [`SessionManager`] trait controls how sessions are stored and routed: -/// -/// * [`LocalSessionManager`](super::session::local::LocalSessionManager) — -/// in-memory session store (default). -/// * [`NeverSessionManager`](super::session::never::NeverSessionManager) — -/// disables sessions entirely (stateless mode). -/// -/// ## Accessing HTTP request data from tool handlers -/// -/// The service consumes the request body but injects the remaining -/// [`http::request::Parts`] into [`crate::model::Extensions`], which is -/// accessible through [`crate::service::RequestContext`]. -/// -/// ### Reading the raw HTTP parts -/// -/// ```rust -/// use rmcp::handler::server::tool::Extension; -/// use http::request::Parts; -/// async fn my_tool(Extension(parts): Extension) { -/// tracing::info!("http parts:{parts:?}") -/// } -/// ``` -/// -/// ### Reading the session ID inside a tool handler -/// -/// ```rust,ignore -/// use rmcp::handler::server::tool::Extension; -/// use rmcp::service::RequestContext; -/// use rmcp::model::RoleServer; -/// -/// #[tool(description = "session-aware tool")] -/// async fn my_tool( -/// &self, -/// Extension(parts): Extension, -/// ) -> Result { -/// if let Some(session_id) = parts.headers.get("mcp-session-id") { -/// tracing::info!(?session_id, "called from session"); -/// } -/// // ... -/// # todo!() -/// } -/// ``` -/// -/// ### Accessing custom axum/tower extension state -/// -/// State added via axum's `Extension` layer is available inside -/// `Parts.extensions`: -/// -/// ```rust,ignore -/// use rmcp::service::RequestContext; -/// use rmcp::model::RoleServer; -/// -/// #[derive(Clone)] -/// struct AppState { /* ... */ } -/// -/// #[tool(description = "example")] -/// async fn my_tool( -/// &self, -/// ctx: RequestContext, -/// ) -> Result { -/// let parts = ctx.extensions.get::().unwrap(); -/// let state = parts.extensions.get::().unwrap(); -/// // use state... -/// # todo!() -/// } -/// ``` -pub struct StreamableHttpService { - pub config: StreamableHttpServerConfig, - session_manager: Arc, - service_factory: Arc Result + Send + Sync>, - /// Tracks in-progress session restores so that concurrent requests for the - /// same unknown session ID wait for the first restore to complete rather - /// than racing to replay the initialize handshake. `None` when no external - /// session store is configured (avoids allocating the map). - pending_restores: Option< - Arc>>>>, - >, - /// Caches tool input schemas by name for SEP-2243 `Mcp-Param-*` validation. - /// Populated lazily via `get_tool` so the service factory runs at most once - /// per tool name. `None` value means the tool exposes no schema. - tool_schemas: Arc>>>>, -} - -impl Clone for StreamableHttpService { - fn clone(&self) -> Self { - Self { - config: self.config.clone(), - session_manager: self.session_manager.clone(), - service_factory: self.service_factory.clone(), - pending_restores: self.pending_restores.clone(), - tool_schemas: self.tool_schemas.clone(), - } - } -} - -impl tower_service::Service> for StreamableHttpService -where - RequestBody: Body + Send + 'static, - S: crate::ServerHandler + Send + 'static, - M: SessionManager, - RequestBody::Error: Display, - RequestBody::Data: Send + 'static, -{ - type Response = BoxResponse; - type Error = Infallible; - type Future = BoxFuture<'static, Result>; - fn call(&mut self, req: http::Request) -> Self::Future { - let service = self.clone(); - Box::pin(async move { - let response = service.handle(req).await; - Ok(response) - }) - } - fn poll_ready( - &mut self, - _cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - std::task::Poll::Ready(Ok(())) - } -} - -/// Guard used inside [`StreamableHttpService::try_restore_from_store`]. -/// -/// Ensures the `pending_restores` map entry is always cleaned up — even when -/// the future is cancelled mid-await. -/// -/// `result` defaults to `false` (failure / cancellation). Only the success path -/// needs to set it to `true` before returning. -struct PendingRestoreGuard { - pending_restores: - Arc>>>>, - session_id: SessionId, - watch_tx: tokio::sync::watch::Sender>, - /// The value that will be broadcast to waiting tasks on drop. - result: bool, -} - -impl Drop for PendingRestoreGuard { - fn drop(&mut self) { - // `send` is synchronous — unblocks waiters immediately, no lock needed. - let _ = self.watch_tx.send(Some(self.result)); - // Remove the map entry asynchronously (requires the async write lock). - let pending_restores = self.pending_restores.clone(); - let session_id = self.session_id.clone(); - tokio::spawn(async move { - pending_restores.write().await.remove(&session_id); - }); - } -} - -impl StreamableHttpService -where - S: crate::ServerHandler + Send + 'static, - M: SessionManager, -{ - pub fn new( - service_factory: impl Fn() -> Result + Send + Sync + 'static, - session_manager: Arc, - config: StreamableHttpServerConfig, - ) -> Self { - let pending_restores = config.session_store.is_some().then(|| { - Arc::new(tokio::sync::RwLock::new(HashMap::< - SessionId, - tokio::sync::watch::Sender>, - >::new())) - }); - Self { - config, - session_manager, - service_factory: Arc::new(service_factory), - pending_restores, - tool_schemas: Arc::new(std::sync::RwLock::new(HashMap::new())), - } - } - fn get_service(&self) -> Result { - (self.service_factory)() - } - - fn persisted_stateless_stream( - &self, - first: Option, - mut receiver: tokio::sync::mpsc::Receiver, - request_ct: CancellationToken, - event_store: Arc, - ) -> ReceiverStream { - let (sender, output) = tokio::sync::mpsc::channel(STATELESS_STREAM_CHANNEL_CAPACITY); - let stream_id = uuid::Uuid::new_v4().to_string(); - let retry = self.config.sse_retry; - let server_ct = self.config.cancellation_token.child_token(); - - tokio::spawn(async move { - let mut sender = Some(sender); - if let Some(retry) = retry { - if let Err(error) = persist_and_forward_event( - event_store.as_ref(), - &stream_id, - ServerSseMessage::retry(retry), - &mut sender, - ) - .await - { - tracing::error!(%stream_id, %error, "failed to persist SSE priming event"); - request_ct.cancel(); - return; - } - } - - let mut first = first; - loop { - let message = if let Some(message) = first.take() { - Some(message) - } else { - tokio::select! { - message = receiver.recv() => message, - _ = server_ct.cancelled() => { - request_ct.cancel(); - None - } - } - }; - let Some(message) = message else { - break; - }; - tracing::trace!(?message); - if let Err(error) = persist_and_forward_event( - event_store.as_ref(), - &stream_id, - ServerSseMessage::from_message(message), - &mut sender, - ) - .await - { - tracing::error!(%stream_id, %error, "failed to persist SSE event"); - request_ct.cancel(); - break; - } - } - }); - - ReceiverStream::new(output) - } - - fn stateless_sse_response( - &self, - first: Option, - receiver: tokio::sync::mpsc::Receiver, - request_ct: CancellationToken, - ) -> BoxResponse { - if let Some(event_store) = self.session_manager.event_store() { - let stream = self.persisted_stateless_stream(first, receiver, request_ct, event_store); - sse_stream_response( - stream, - self.config.sse_keep_alive, - self.config.cancellation_token.child_token(), - ) - } else { - let stream = futures::stream::iter(first) - .chain(ReceiverStream::new(receiver)) - .map(|message| { - tracing::trace!(?message); - ServerSseMessage::from_message(message) - }); - sse_stream_response( - CancelOnDisconnect::new(stream, request_ct), - self.config.sse_keep_alive, - self.config.cancellation_token.child_token(), - ) - } - } - - // The HTTP status must be known before opening an SSE stream. - async fn serve_negotiated_request_directly( - &self, - service: S, - mut request: crate::model::JsonRpcRequest, - parts: http::request::Parts, - ) -> Result { - let peer_info = Self::peer_info_for_stateless_request(&request, &parts.headers); - request.request.extensions_mut().insert(parts); - let (transport, mut receiver) = - OneshotTransport::::new(ClientJsonRpcMessage::Request(request)); - // Give this stateless request its own cancellation token so a client - // disconnect can cancel the in-flight handler (#857), as in the - // non-negotiated stateless path below. - let request_ct = CancellationToken::new(); - let service = serve_directly_with_ct( - NegotiatingStatelessHttpService(service), - transport, - peer_info, - request_ct.clone(), - ); - tokio::spawn(async move { - let _ = service.waiting().await; - }); - - let cancel = self.config.cancellation_token.child_token(); - // Cancel the handler if the client disconnects while it is still - // producing its first message (this future is dropped before - // `receiver.recv()` completes). Disarmed once the handler emits - // anything, so a normal response is never cancelled. - let mut disconnect_guard = Some(request_ct.clone().drop_guard()); - let first = tokio::select! { - message = receiver.recv() => { - if let Some(guard) = disconnect_guard.take() { - guard.disarm(); - } - message - } - _ = cancel.cancelled() => None, - } - .ok_or_else(|| { - internal_error_response("empty response")(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "no response message received from handler", - )) - })?; - - let terminal = matches!( - &first, - ServerJsonRpcMessage::Response(_) | ServerJsonRpcMessage::Error(_) - ); - if terminal - && (self.config.json_response || jsonrpc_http_status(&first) != http::StatusCode::OK) - { - // This message is the whole reply, so `receiver` is dropped here and - // anything the handler emits afterwards is undeliverable. Cancel it so - // a still-running handler stops instead of running on unobserved: its - // terminal `send` would otherwise fail before adding the termination - // permit, leaving the serve loop parked forever. A no-op when the - // handler already completed. - request_ct.cancel(); - return jsonrpc_message_response(first, true); - } - - Ok(self.stateless_sse_response(Some(first), receiver, request_ct)) - } - - /// Returns the cached input schema for `name`, constructing a service once - /// per name to read its `ServerHandler::get_tool` definition. Used to - /// validate SEP-2243 `Mcp-Param-*` headers against the request body. - fn tool_schema(&self, name: &str) -> Option> { - if let Ok(cache) = self.tool_schemas.read() { - if let Some(schema) = cache.get(name) { - return schema.clone(); - } - } - let schema = self - .get_service() - .ok() - .and_then(|service| service.get_tool(name)) - .map(|tool| tool.input_schema); - if let Ok(mut cache) = self.tool_schemas.write() { - cache.insert(name.to_owned(), schema.clone()); - } - schema - } - - /// Spawn a task that runs `serve_server` for the given session, waits for - /// it to finish, and then calls `close_session`. - /// - /// `init_done_tx`: when `Some`, the sender is fired after `serve_server` - /// returns successfully, signalling to the caller that the MCP handshake - /// is complete. Used by `try_restore_from_store` to synchronise with the - /// restore `initialize` replay; `handle_post` passes `None`. - fn spawn_session_worker( - session_manager: Arc, - session_id: SessionId, - service: S, - transport: M::Transport, - init_done_tx: Option>, - ) where - S: crate::ServerHandler + Send + 'static, - M: SessionManager, - { - tokio::spawn(async move { - let svc = - serve_server::(service, transport) - .await; - match svc { - Ok(svc) => { - if let Some(tx) = init_done_tx { - let _ = tx.send(()); - } - let _ = svc.waiting().await; - } - Err(e) => { - tracing::error!("Failed to serve session: {e}"); - // Dropping init_done_tx (if Some) signals failure to the caller. - } - } - let _ = session_manager - .close_session(&session_id) - .await - .inspect_err(|e| { - tracing::error!("Failed to close session {session_id}: {e}"); - }); - }); - } - - /// Attempt to restore a session from the external store. - /// - /// Returns `true` when the session is available and ready to serve the - /// current request (either just restored or already in memory). Returns - /// `false` when no store is configured or the session ID is unknown. - /// - /// Concurrent requests for the same unknown session ID are serialized: the - /// first caller performs the full restore and handshake replay while others - /// subscribe to a `watch` channel and wait, avoiding duplicate handshakes. - async fn try_restore_from_store( - &self, - session_id: &SessionId, - parts: &http::request::Parts, - ) -> Result - where - S: crate::ServerHandler + Send + 'static, - M: SessionManager, - { - // Both fields are Some iff a session store is configured. - let (Some(pending_restores), Some(store)) = - (&self.pending_restores, &self.config.session_store) - else { - return Ok(false); - }; - - // Serialize concurrent restores for the same session ID. - // Write-lock once: if another task is already restoring, subscribe and wait; - // otherwise, register ourselves as the restoring task. - // Channel value: None = in progress, Some(true) = restored, Some(false) = not found/failed. - let (watch_tx, _watch_rx) = tokio::sync::watch::channel(None::); - { - let mut pending = pending_restores.write().await; - if let Some(tx) = pending.get(session_id) { - let mut rx = tx.subscribe(); - drop(pending); - // Wait for the restore to finish, then propagate the outcome. - let result = rx - .wait_for(|r| r.is_some()) - .await - .map(|r| r.unwrap_or(false)) - .unwrap_or(false); - return Ok(result); - } - pending.insert(session_id.clone(), watch_tx.clone()); - } - - // Guard: signals waiters and cleans up the map entry on drop - let mut guard = PendingRestoreGuard { - pending_restores: pending_restores.clone(), - session_id: session_id.clone(), - watch_tx: watch_tx.clone(), - result: false, - }; - - // --- Step 3: load from external store --- - let state = match store.load(session_id.as_ref()).await { - Ok(Some(s)) => s, - Ok(None) => { - return Ok(false); - } - Err(e) => { - tracing::error!( - session_id = session_id.as_ref(), - error = %e, - "session store load failed during restore" - ); - return Err(std::io::Error::other(e)); - } - }; - - // --- Step 4: ask the session manager to allocate an in-memory worker --- - let transport = match self - .session_manager - .restore_session(session_id.clone()) - .await - .map_err(|e| std::io::Error::other(e.to_string())) - { - Ok(RestoreOutcome::Restored(t)) => t, - Ok(RestoreOutcome::AlreadyPresent) => { - // Invariant violation: pending_restores ensures only one task can call - // restore_session per session ID, so AlreadyPresent is impossible here. - return Err(std::io::Error::other( - "restore_session returned AlreadyPresent unexpectedly; session manager might have modified the session store outside of the restore_session API", - )); - } - Ok(RestoreOutcome::NotSupported) => { - return Ok(false); - } - Err(e) => { - return Err(e); - } - }; - - // --- Step 5: replay the MCP initialize handshake --- - let service = match self.get_service() { - Ok(s) => s, - Err(e) => { - return Err(e); - } - }; - - // `serve_server` requires both the `initialize` request and the - // `notifications/initialized` notification before transitioning to - // the running state — we must send both before returning. - let mut restore_init = ClientJsonRpcMessage::request( - ClientRequest::InitializeRequest(InitializeRequest { - params: state.initialize_params, - ..Default::default() - }), - crate::model::NumberOrString::Number(0), - ); - restore_init.insert_extension(parts.clone()); - restore_init.insert_extension(SessionRestoreMarker { - id: session_id.clone(), - }); - let mut restore_initialized = ClientJsonRpcMessage::notification( - ClientNotification::InitializedNotification(InitializedNotification { - ..Default::default() - }), - ); - restore_initialized.insert_extension(parts.clone()); - restore_initialized.insert_extension(SessionRestoreMarker { - id: session_id.clone(), - }); - // Signal from the spawned task once serve_server finishes initialising. - let (init_done_tx, init_done_rx) = tokio::sync::oneshot::channel::<()>(); - - Self::spawn_session_worker( - self.session_manager.clone(), - session_id.clone(), - service, - transport, - Some(init_done_tx), - ); - - if let Err(e) = self - .session_manager - .initialize_session(session_id, restore_init) - .await - .map_err(|e| std::io::Error::other(e.to_string())) - { - return Err(e); - } - - if let Err(e) = self - .session_manager - .accept_message(session_id, restore_initialized) - .await - .map_err(|e| std::io::Error::other(e.to_string())) - { - return Err(e); - } - - if init_done_rx.await.is_err() { - return Err(std::io::Error::other( - "serve_server initialization failed during restore", - )); - } - - // Restore complete — wake any waiting concurrent requests. - guard.result = true; - - tracing::debug!( - session_id = session_id.as_ref(), - "session restored from external store" - ); - Ok(true) - } - pub async fn handle(&self, request: Request) -> Response> - where - B: Body + Send + 'static, - B::Error: Display, - { - if let Err(response) = - validate_dns_rebinding_headers(request.uri(), request.headers(), &self.config) - { - return response; - } - let method = request.method().clone(); - let supports_stateless_replay = self.session_manager.event_store().is_some(); - let allowed_methods = match (self.config.legacy_session_mode, supports_stateless_replay) { - (true, _) => "GET, POST, DELETE", - (false, true) => "GET, POST", - (false, false) => "POST", - }; - let result = match method { - Method::POST => self.handle_post(request).await, - Method::GET if self.config.legacy_session_mode || supports_stateless_replay => { - self.handle_get(request).await - } - Method::DELETE if self.config.legacy_session_mode => self.handle_delete(request).await, - _ => { - // Handle other methods or return an error - let response = Response::builder() - .status(http::StatusCode::METHOD_NOT_ALLOWED) - .header(ALLOW, allowed_methods) - .body(Full::new(Bytes::from("Method Not Allowed")).boxed()) - .expect("valid response"); - return response; - } - }; - match result { - Ok(response) => response, - Err(response) => response, - } - } - async fn handle_get(&self, request: Request) -> Result - where - B: Body + Send + 'static, - B::Error: Display, - { - // check accept header - if !request - .headers() - .get(http::header::ACCEPT) - .and_then(|header| header.to_str().ok()) - .is_some_and(|header| header.contains(EVENT_STREAM_MIME_TYPE)) - { - return Ok(Response::builder() - .status(http::StatusCode::NOT_ACCEPTABLE) - .body( - Full::new(Bytes::from( - "Not Acceptable: Client must accept text/event-stream", - )) - .boxed(), - ) - .expect("valid response")); - } - let request_uses_legacy_protocol = is_legacy_request(None, request.headers())?; - let legacy_request = self.config.legacy_session_mode && request_uses_legacy_protocol; - if !legacy_request { - let Some(last_event_id) = request - .headers() - .get(HEADER_LAST_EVENT_ID) - .and_then(|value| value.to_str().ok()) - else { - return Ok(method_not_allowed_response()); - }; - let Some(event_store) = self.session_manager.event_store() else { - return Ok(method_not_allowed_response()); - }; - let stream = match event_store.replay_events_after(last_event_id).await { - Ok(stream) => stream, - Err(error) => { - tracing::warn!(%error, "stateless SSE resume failed, returning empty stream"); - Box::pin(futures::stream::empty()) - } - }; - return Ok(sse_stream_response( - stream, - self.config.sse_keep_alive, - self.config.cancellation_token.child_token(), - )); - } - // check session id - let session_id = request - .headers() - .get(HEADER_SESSION_ID) - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_owned().into()); - let Some(session_id) = session_id else { - // MCP spec: servers that require a session ID SHOULD respond with 400 Bad Request - return Ok(Response::builder() - .status(http::StatusCode::BAD_REQUEST) - .body(Full::new(Bytes::from("Bad Request: Session ID is required")).boxed()) - .expect("valid response")); - }; - // check if session exists - let has_session = self - .session_manager - .has_session(&session_id) - .await - .map_err(internal_error_response("check session"))?; - let (parts, _) = request.into_parts(); - if !has_session { - // Attempt transparent cross-instance restore from external store. - let restored = self - .try_restore_from_store(&session_id, &parts) - .await - .map_err(internal_error_response("restore session"))?; - if !restored { - // MCP spec: server MUST respond with 404 Not Found for terminated/unknown sessions - return Ok(Response::builder() - .status(http::StatusCode::NOT_FOUND) - .body(Full::new(Bytes::from("Not Found: Session not found")).boxed()) - .expect("valid response")); - } - } - // Validate MCP-Protocol-Version header (per 2025-06-18 spec) - validate_protocol_version_header(&parts.headers, false)?; - // check if last event id is provided - let last_event_id = parts - .headers - .get(HEADER_LAST_EVENT_ID) - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_owned()); - if let Some(last_event_id) = last_event_id { - match self - .session_manager - .resume(&session_id, last_event_id) - .await - { - Ok(stream) => { - return Ok(sse_stream_response( - stream, - self.config.sse_keep_alive, - self.config.cancellation_token.child_token(), - )); - } - Err(e) => { - // Return 200 with an immediately-closed empty stream. - // Returning an HTTP error would cause EventSource to retry - // with the same Last-Event-ID in an infinite loop. An empty - // 200 cleanly terminates the EventSource without delivering - // events from a different stream. - tracing::warn!("Resume failed ({e}), returning empty stream"); - return Ok(sse_stream_response( - futures::stream::empty(), - None, - self.config.cancellation_token.child_token(), - )); - } - } - } - // No Last-Event-ID — create standalone stream - let stream = self - .session_manager - .create_standalone_stream(&session_id) - .await - .map_err(internal_error_response("create standalone stream"))?; - let stream = if let Some(retry) = self.config.sse_retry { - let priming = if self.session_manager.event_store().is_some() { - ServerSseMessage::retry(retry) - } else { - ServerSseMessage::priming("0", retry) - }; - futures::stream::once(async move { priming }) - .chain(stream) - .left_stream() - } else { - stream.right_stream() - }; - Ok(sse_stream_response( - stream, - self.config.sse_keep_alive, - self.config.cancellation_token.child_token(), - )) - } - - async fn handle_post(&self, request: Request) -> Result - where - B: Body + Send + 'static, - B::Error: Display, - { - // check accept header - if !request - .headers() - .get(http::header::ACCEPT) - .and_then(|header| header.to_str().ok()) - .is_some_and(|header| { - header.contains(JSON_MIME_TYPE) && header.contains(EVENT_STREAM_MIME_TYPE) - }) - { - return Ok(Response::builder() - .status(http::StatusCode::NOT_ACCEPTABLE) - .body(Full::new(Bytes::from("Not Acceptable: Client must accept both application/json and text/event-stream")).boxed()) - .expect("valid response")); - } - - // check content type - if !request - .headers() - .get(http::header::CONTENT_TYPE) - .and_then(|header| header.to_str().ok()) - .is_some_and(|header| header.starts_with(JSON_MIME_TYPE)) - { - return Ok(Response::builder() - .status(http::StatusCode::UNSUPPORTED_MEDIA_TYPE) - .body( - Full::new(Bytes::from( - "Unsupported Media Type: Content-Type must be application/json", - )) - .boxed(), - ) - .expect("valid response")); - } - - // json deserialize request body - let (part, body) = request.into_parts(); - let mut message = match expect_json(body, self.config.max_request_body_bytes).await { - Ok(message) => message, - Err(response) => return Ok(response), - }; - - let use_session = - self.config.legacy_session_mode && is_legacy_request(Some(&message), &part.headers)?; - - if use_session { - // do we have a session id? - let session_id = part - .headers - .get(HEADER_SESSION_ID) - .and_then(|v| v.to_str().ok()); - if let Some(session_id) = session_id { - let session_id = session_id.to_owned().into(); - let has_session = self - .session_manager - .has_session(&session_id) - .await - .map_err(internal_error_response("check session"))?; - if !has_session { - // Attempt transparent cross-instance restore from external store. - let restored = self - .try_restore_from_store(&session_id, &part) - .await - .map_err(internal_error_response("restore session"))?; - if !restored { - // MCP spec: server MUST respond with 404 Not Found for terminated/unknown sessions - return Ok(Response::builder() - .status(http::StatusCode::NOT_FOUND) - .body(Full::new(Bytes::from("Not Found: Session not found")).boxed()) - .expect("valid response")); - } - } - - // Validate MCP-Protocol-Version header (per 2025-06-18 spec) - let has_per_request_version = message_has_per_request_protocol_version(&message); - validate_protocol_version_header(&part.headers, has_per_request_version)?; - validate_request_protocol_version_meta(&part.headers, &message)?; - // Validate SEP-2243 standard headers against the body - validate_standard_headers(&part.headers, &message, |name| self.tool_schema(name))?; - - // inject request part to extensions - match &mut message { - ClientJsonRpcMessage::Request(req) => { - req.request.extensions_mut().insert(part); - } - ClientJsonRpcMessage::Notification(not) => { - not.notification.extensions_mut().insert(part); - } - _ => { - // skip - } - } - - match message { - ClientJsonRpcMessage::Request(_) => { - // Priming for request-wise streams is handled by the - // session layer (SessionManager::create_stream) which - // has access to the http_request_id for correct event IDs. - let stream = self - .session_manager - .create_stream(&session_id, message) - .await - .map_err(internal_error_response("get session"))?; - Ok(sse_stream_response( - stream, - self.config.sse_keep_alive, - self.config.cancellation_token.child_token(), - )) - } - ClientJsonRpcMessage::Notification(_) - | ClientJsonRpcMessage::Response(_) - | ClientJsonRpcMessage::Error(_) => { - // handle notification - self.session_manager - .accept_message(&session_id, message) - .await - .map_err(internal_error_response("accept message"))?; - Ok(accepted_response()) - } - } - } else { - if matches!( - &message, - ClientJsonRpcMessage::Request(request) - if matches!(&request.request, ClientRequest::DiscoverRequest(_)) - ) { - validate_protocol_version_header( - &part.headers, - message_has_per_request_protocol_version(&message), - )?; - validate_standard_headers(&part.headers, &message, |name| { - self.tool_schema(name) - })?; - validate_request_protocol_version_meta(&part.headers, &message)?; - let ClientJsonRpcMessage::Request(request) = message else { - unreachable!("guarded as a request above"); - }; - let service = self - .get_service() - .map_err(internal_error_response("get service"))?; - return self - .serve_negotiated_request_directly(service, request, part) - .await; - } - // Capture init params for external store persistence before - // extensions are injected (which would require Clone). - let stored_init_params = match &mut message { - ClientJsonRpcMessage::Request(req) => { - let ClientRequest::InitializeRequest(init_req) = &req.request else { - return Err(unexpected_message_response("initialize request")); - }; - // Reject mismatched MCP-Protocol-Version header before binding the session to anything. - validate_header_matches_init_body( - &part.headers, - init_req.params.protocol_version.as_str(), - Some(req.id.clone()), - )?; - let stored_init_params = self - .config - .session_store - .as_ref() - .map(|_| init_req.params.clone()); - // inject request part to extensions - req.request.extensions_mut().insert(part); - stored_init_params - } - _ => { - return Err(unexpected_message_response("initialize request")); - } - }; - let service = self - .get_service() - .map_err(internal_error_response("get service"))?; - let (session_id, transport) = self - .session_manager - .create_session() - .await - .map_err(internal_error_response("create session"))?; - // spawn a task to serve the session - Self::spawn_session_worker( - self.session_manager.clone(), - session_id.clone(), - service, - transport, - None, - ); - // get initialize response - let response = self - .session_manager - .initialize_session(&session_id, message) - .await - .map_err(internal_error_response("create stream"))?; - // Persist session state to external store after a successful handshake. - if let (Some(store), Some(params)) = - (&self.config.session_store, stored_init_params) - { - let state = SessionState { - initialize_params: params, - }; - let _ = store - .store(session_id.as_ref(), &state) - .await - .inspect_err(|e| { - tracing::warn!( - "Failed to persist session {} to store: {e}", - session_id - ); - }); - } - let stream = - futures::stream::once(async move { ServerSseMessage::from_message(response) }); - // Prepend priming event if sse_retry configured - let stream = if let Some(retry) = self.config.sse_retry { - let priming = ServerSseMessage::priming("0", retry); - futures::stream::once(async move { priming }) - .chain(stream) - .left_stream() - } else { - stream.right_stream() - }; - let mut response = sse_stream_response( - stream, - self.config.sse_keep_alive, - self.config.cancellation_token.child_token(), - ); - - response.headers_mut().insert( - HEADER_SESSION_ID, - session_id - .parse() - .map_err(internal_error_response("create session id header"))?, - ); - Ok(response) - } - } else { - // Stateless mode: - // - on initialize: the header (if present) must match `params.protocolVersion` - // - on every other request: the header must name a known version. - // - // The opt-in seam applies only here so legacy session routing and - // its error precedence remain unchanged. - validate_required_protocol_header(&self.config, &part.headers, &message)?; - let has_per_request_version = message_has_per_request_protocol_version(&message); - match &message { - ClientJsonRpcMessage::Request(req) => { - if let ClientRequest::InitializeRequest(init_req) = &req.request { - validate_header_matches_init_body( - &part.headers, - init_req.params.protocol_version.as_str(), - Some(req.id.clone()), - )?; - } else { - validate_protocol_version_header(&part.headers, has_per_request_version)?; - } - } - _ => { - validate_protocol_version_header(&part.headers, has_per_request_version)?; - } - } - // Validate SEP-2243 standard headers against the body - validate_standard_headers(&part.headers, &message, |name| self.tool_schema(name))?; - validate_request_protocol_version_meta(&part.headers, &message)?; - validate_required_protocol_meta(&self.config, &message)?; - let service = self - .get_service() - .map_err(internal_error_response("get service"))?; - match message { - ClientJsonRpcMessage::Request(mut request) => { - let negotiates_per_request = has_per_request_version - || matches!(&request.request, ClientRequest::DiscoverRequest(_)); - if negotiates_per_request { - return self - .serve_negotiated_request_directly(service, request, part) - .await; - } - // Build a peer_info so context.protocol_version() works inside handlers. - // serve_directly skips the handshake and receives None by default, making - // protocol_version() always return None in stateless mode. We reconstruct it: - // - initialize requests: version comes from the request body params - // - all other requests: version comes from the MCP-Protocol-Version header - // (already validated above; absent header defaults to 2025-03-26) - let peer_info = Self::peer_info_for_stateless_request(&request, &part.headers); - request.request.extensions_mut().insert(part); - let (transport, mut receiver) = - OneshotTransport::::new(ClientJsonRpcMessage::Request(request)); - // Give this stateless request its own cancellation token so an - // unpersisted response can cancel the in-flight handler on - // disconnect (#857). - let request_ct = CancellationToken::new(); - let service = serve_directly_with_ct( - NegotiatingStatelessHttpService(service), - transport, - peer_info, - request_ct.clone(), - ); - tokio::spawn(async move { - // on service created - let _ = service.waiting().await; - }); - if self.config.json_response { - // Prefer JSON for a terminal first message. If the handler - // emits an intermediate notification or request, preserve - // the complete message sequence by falling back to SSE. - let cancel = self.config.cancellation_token.child_token(); - // Cancel the handler if the client disconnects while it is - // still producing its first message (this future is dropped - // before `receiver.recv()` completes). Disarmed once the - // handler emits anything, so a normal response is never - // cancelled. - let mut disconnect_guard = Some(request_ct.clone().drop_guard()); - let Some(message) = (tokio::select! { - res = receiver.recv() => { - if let Some(guard) = disconnect_guard.take() { - guard.disarm(); - } - res - } - _ = cancel.cancelled() => None, - }) else { - return Err(internal_error_response("empty response")( - std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "no response message received from handler", - ), - )); - }; - tracing::trace!(?message); - if matches!( - message, - ServerJsonRpcMessage::Response(_) | ServerJsonRpcMessage::Error(_) - ) { - let body = serde_json::to_vec(&message).map_err(|e| { - internal_error_response("serialize json response")(e) - })?; - Ok(Response::builder() - .status(http::StatusCode::OK) - .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) - .body(Full::new(Bytes::from(body)).boxed()) - .expect("valid response")) - } else { - Ok(self.stateless_sse_response(Some(message), receiver, request_ct)) - } - } else { - Ok(self.stateless_sse_response(None, receiver, request_ct)) - } - } - ClientJsonRpcMessage::Notification(_notification) => { - // ignore - Ok(accepted_response()) - } - ClientJsonRpcMessage::Response(_json_rpc_response) => Ok(accepted_response()), - ClientJsonRpcMessage::Error(_json_rpc_error) => Ok(accepted_response()), - } - } - } - - async fn handle_delete(&self, request: Request) -> Result - where - B: Body + Send + 'static, - B::Error: Display, - { - if !is_legacy_request(None, request.headers())? { - return Ok(method_not_allowed_response()); - } - // check session id - let session_id = request - .headers() - .get(HEADER_SESSION_ID) - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_owned().into()); - let Some(session_id) = session_id else { - // MCP spec: servers that require a session ID SHOULD respond with 400 Bad Request - return Ok(Response::builder() - .status(http::StatusCode::BAD_REQUEST) - .body(Full::new(Bytes::from("Bad Request: Session ID is required")).boxed()) - .expect("valid response")); - }; - // Validate MCP-Protocol-Version header (per 2025-06-18 spec) - validate_protocol_version_header(request.headers(), false)?; - // close session - self.session_manager - .close_session(&session_id) - .await - .map_err(internal_error_response("close session"))?; - // Remove from external store: a DELETE means the client intentionally - // ends the session, so the store entry is no longer needed. - if let Some(store) = &self.config.session_store { - let _ = store.delete(session_id.as_ref()).await.inspect_err(|e| { - tracing::warn!("Failed to delete session {} from store: {e}", session_id); - }); - } - Ok(accepted_response()) - } - - /// Build a `ClientInfo` (peer_info) for a stateless request so that - /// `context.protocol_version()` returns the correct value inside handlers. - /// - /// `serve_directly` skips the MCP handshake and accepts `peer_info = None`, - /// which means `context.protocol_version()` is always `None` in stateless mode. - /// We reconstruct the protocol version from the available signal per request type: - /// - initialize: version is in the request body params (authoritative) - /// - all other requests: version is in the MCP-Protocol-Version header - /// (validated before this point; absent header defaults to 2025-03-26) - fn peer_info_for_stateless_request( - request: &crate::model::JsonRpcRequest, - headers: &HeaderMap, - ) -> Option { - let version = if let ClientRequest::InitializeRequest(ref init) = request.request { - init.params.protocol_version.clone() - } else { - headers - .get(HEADER_MCP_PROTOCOL_VERSION) - .and_then(|v| v.to_str().ok()) - .and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_owned())).ok()) - .unwrap_or(ProtocolVersion::V_2025_03_26) - }; - Some(InitializeRequestParams { - meta: None, - protocol_version: version, - capabilities: ClientCapabilities::default(), - client_info: Implementation::default(), - }) - } -} - -pin_project! { - /// Cancels an unpersisted stateless request when its response is dropped. - /// - /// Persisted requests keep running so another connection can resume them. - /// Without an event store, dropping the stream fires the request's - /// cancellation token. Natural completion disarms the guard. - struct CancelOnDisconnect { - #[pin] - inner: S, - ct: Option, - } - impl PinnedDrop for CancelOnDisconnect { - fn drop(this: Pin<&mut Self>) { - let this = this.project(); - if let Some(ct) = this.ct.take() { - ct.cancel(); - } - } - } -} - -impl CancelOnDisconnect { - fn new(inner: S, ct: CancellationToken) -> Self { - Self { - inner, - ct: Some(ct), - } - } -} - -impl Stream for CancelOnDisconnect { - type Item = S::Item; - - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let this = self.project(); - let polled = this.inner.poll_next(cx); - if let Poll::Ready(None) = &polled { - // Ended naturally: the request completed, so don't cancel on drop. - *this.ct = None; - } - polled - } -} diff --git a/vendor/rmcp/src/transport/worker.rs b/vendor/rmcp/src/transport/worker.rs deleted file mode 100644 index 5294640e5..000000000 --- a/vendor/rmcp/src/transport/worker.rs +++ /dev/null @@ -1,217 +0,0 @@ -use std::{borrow::Cow, time::Duration}; - -use tokio_util::sync::CancellationToken; -use tracing::{Instrument, Level}; - -use super::{IntoTransport, Transport}; -use crate::service::{RxJsonRpcMessage, ServiceRole, TxJsonRpcMessage}; - -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum WorkerQuitReason { - #[error("Join error {0}")] - Join(#[from] tokio::task::JoinError), - #[error("Transport fatal {error}, when {context}")] - Fatal { - error: E, - context: Cow<'static, str>, - }, - #[error("Transport cancelled")] - Cancelled, - #[error("Transport closed")] - TransportClosed, - #[error("Handler terminated")] - HandlerTerminated, - #[error("Worker idle timeout after {}ms", _0.as_millis())] - IdleTimeout(Duration), -} - -impl WorkerQuitReason { - pub fn fatal(error: E, context: impl Into>) -> Self { - Self::Fatal { - error, - context: context.into(), - } - } - pub fn fatal_context(context: impl Into>) -> impl FnOnce(E) -> Self { - |e| Self::Fatal { - error: e, - context: context.into(), - } - } -} - -pub trait Worker: Sized + Send + 'static { - type Error: std::error::Error + Send + Sync + 'static; - type Role: ServiceRole; - fn err_closed() -> Self::Error; - fn err_join(e: tokio::task::JoinError) -> Self::Error; - fn run( - self, - context: WorkerContext, - ) -> impl Future>> + Send; - fn config(&self) -> WorkerConfig { - WorkerConfig::default() - } -} - -#[non_exhaustive] -pub struct WorkerSendRequest { - pub message: TxJsonRpcMessage, - pub responder: tokio::sync::oneshot::Sender>, -} - -pub struct WorkerTransport { - rx: tokio::sync::mpsc::Receiver>, - send_service: tokio::sync::mpsc::Sender>, - join_handle: Option>>>, - _drop_guard: tokio_util::sync::DropGuard, - ct: CancellationToken, -} - -#[non_exhaustive] -pub struct WorkerConfig { - pub name: Option, - pub channel_buffer_capacity: usize, -} - -impl Default for WorkerConfig { - fn default() -> Self { - Self { - name: None, - channel_buffer_capacity: 16, - } - } -} -#[non_exhaustive] -pub enum WorkerAdapter {} - -impl IntoTransport for W { - fn into_transport(self) -> impl Transport + 'static { - WorkerTransport::spawn(self) - } -} - -impl WorkerTransport { - pub fn cancel_token(&self) -> CancellationToken { - self.ct.clone() - } - pub fn spawn(worker: W) -> Self { - Self::spawn_with_ct(worker, CancellationToken::new()) - } - pub fn spawn_with_ct(worker: W, transport_task_ct: CancellationToken) -> Self { - let config = worker.config(); - let worker_name = config.name; - let (to_transport_tx, from_handler_rx) = - tokio::sync::mpsc::channel::>(config.channel_buffer_capacity); - let (to_handler_tx, from_transport_rx) = - tokio::sync::mpsc::channel::>(config.channel_buffer_capacity); - let context = WorkerContext { - to_handler_tx, - from_handler_rx, - cancellation_token: transport_task_ct.clone(), - }; - - let join_handle = tokio::spawn(async move { - worker - .run(context) - .instrument(tracing::span!( - Level::TRACE, - "transport_worker", - name = worker_name, - )) - .await - .inspect_err(|e| match e { - WorkerQuitReason::Cancelled - | WorkerQuitReason::TransportClosed - | WorkerQuitReason::HandlerTerminated - | WorkerQuitReason::IdleTimeout(_) => { - tracing::debug!("worker quit with reason: {:?}", e); - } - WorkerQuitReason::Join(e) => { - tracing::error!("worker quit with join error: {:?}", e); - } - WorkerQuitReason::Fatal { error, context } => { - tracing::error!("worker quit with fatal: {error}, when {context}"); - } - }) - .inspect(|_| { - tracing::debug!("worker quit"); - }) - }); - Self { - rx: from_transport_rx, - send_service: to_transport_tx, - join_handle: Some(join_handle), - ct: transport_task_ct.clone(), - _drop_guard: transport_task_ct.drop_guard(), - } - } -} - -#[non_exhaustive] -pub struct SendRequest { - pub message: TxJsonRpcMessage, - pub responder: tokio::sync::oneshot::Sender>, -} - -#[non_exhaustive] -pub struct WorkerContext { - pub to_handler_tx: tokio::sync::mpsc::Sender>, - pub from_handler_rx: tokio::sync::mpsc::Receiver>, - pub cancellation_token: CancellationToken, -} - -impl WorkerContext { - pub async fn send_to_handler( - &mut self, - item: RxJsonRpcMessage, - ) -> Result<(), WorkerQuitReason> { - self.to_handler_tx - .send(item) - .await - .map_err(|_| WorkerQuitReason::HandlerTerminated) - } - - pub async fn recv_from_handler( - &mut self, - ) -> Result, WorkerQuitReason> { - self.from_handler_rx - .recv() - .await - .ok_or(WorkerQuitReason::HandlerTerminated) - } -} - -impl Transport for WorkerTransport { - type Error = W::Error; - - fn send( - &mut self, - item: TxJsonRpcMessage, - ) -> impl Future> + Send + 'static { - let tx = self.send_service.clone(); - let (responder, receiver) = tokio::sync::oneshot::channel(); - let request = WorkerSendRequest { - message: item, - responder, - }; - async move { - tx.send(request).await.map_err(|_| W::err_closed())?; - receiver.await.map_err(|_| W::err_closed())??; - Ok(()) - } - } - async fn receive(&mut self) -> Option> { - self.rx.recv().await - } - async fn close(&mut self) -> Result<(), Self::Error> { - if let Some(handle) = self.join_handle.take() { - self.ct.cancel(); - let _quit_reason = handle.await.map_err(W::err_join)?; - Ok(()) - } else { - Ok(()) - } - } -} diff --git a/vendor/rmcp/src/transport/ws.rs b/vendor/rmcp/src/transport/ws.rs deleted file mode 100644 index 5ec9cbac9..000000000 --- a/vendor/rmcp/src/transport/ws.rs +++ /dev/null @@ -1 +0,0 @@ -// Maybe we don't really need a ws implementation? diff --git a/vendor/rmcp/tests/common/calculator.rs b/vendor/rmcp/tests/common/calculator.rs deleted file mode 100644 index 22c6d38ef..000000000 --- a/vendor/rmcp/tests/common/calculator.rs +++ /dev/null @@ -1,59 +0,0 @@ -#![allow(dead_code)] -use rmcp::{ - ServerHandler, - handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{ServerCapabilities, ServerInfo}, - schemars, tool, tool_router, -}; -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct SumRequest { - #[schemars(description = "the left hand side number")] - pub a: i32, - pub b: i32, -} - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct SubRequest { - #[schemars(description = "the left hand side number")] - pub a: i32, - #[schemars(description = "the right hand side number")] - pub b: i32, -} -#[derive(Debug, Clone)] -pub struct Calculator { - tool_router: ToolRouter, -} - -impl Calculator { - pub fn new() -> Self { - Self { - tool_router: Self::tool_router(), - } - } -} - -impl Default for Calculator { - fn default() -> Self { - Self::new() - } -} - -#[tool_router] -impl Calculator { - #[tool(description = "Calculate the sum of two numbers")] - fn sum(&self, Parameters(SumRequest { a, b }): Parameters) -> String { - (a + b).to_string() - } - - #[tool(description = "Calculate the sub of two numbers")] - fn sub(&self, Parameters(SubRequest { a, b }): Parameters) -> String { - (a - b).to_string() - } -} - -impl ServerHandler for Calculator { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - .with_instructions("A simple calculator") - } -} diff --git a/vendor/rmcp/tests/common/handlers.rs b/vendor/rmcp/tests/common/handlers.rs deleted file mode 100644 index 7928fa6eb..000000000 --- a/vendor/rmcp/tests/common/handlers.rs +++ /dev/null @@ -1,190 +0,0 @@ -// Sampling/Roots/Logging are SEP-2577-deprecated; this test handler exercises them. -#![expect(deprecated)] -use std::{ - future::Future, - sync::{Arc, Mutex}, -}; - -#[cfg(feature = "client")] -use rmcp::service::NotificationContext; -#[cfg(feature = "client")] -use rmcp::{ClientHandler, RoleClient}; -use rmcp::{ - ErrorData as McpError, RoleServer, ServerHandler, - model::*, - service::{MaybeSendFuture, RequestContext}, -}; -#[cfg(feature = "client")] -use serde_json::json; -use tokio::sync::Notify; - -#[derive(Clone)] -#[allow(dead_code)] -pub struct TestClientHandler { - pub honor_this_server: bool, - pub honor_all_servers: bool, - pub receive_signal: Arc, - pub received_messages: Arc>>, -} - -impl TestClientHandler { - #[allow(dead_code)] - pub fn new(honor_this_server: bool, honor_all_servers: bool) -> Self { - Self { - honor_this_server, - honor_all_servers, - receive_signal: Arc::new(Notify::new()), - received_messages: Arc::new(Mutex::new(Vec::new())), - } - } - - #[allow(dead_code)] - pub fn with_notification( - honor_this_server: bool, - honor_all_servers: bool, - receive_signal: Arc, - received_messages: Arc>>, - ) -> Self { - Self { - honor_this_server, - honor_all_servers, - receive_signal, - received_messages, - } - } -} - -#[cfg(feature = "client")] -impl ClientHandler for TestClientHandler { - async fn create_message( - &self, - params: CreateMessageRequestParams, - _context: RequestContext, - ) -> Result { - // First validate that there's at least one User message - if !params.messages.iter().any(|msg| msg.role == Role::User) { - return Err(McpError::invalid_request( - "Message sequence must contain at least one user message", - Some(json!({"messages": params.messages})), - )); - } - - // Create response based on context inclusion - let response = match params.include_context { - Some(ContextInclusion::ThisServer) if self.honor_this_server => { - "Test response with context: test context" - } - Some(ContextInclusion::AllServers) if self.honor_all_servers => { - "Test response with context: test context" - } - _ => "Test response without context", - }; - - Ok(CreateMessageResult::new( - SamplingMessage::assistant_text(response.to_string()), - "test-model".to_string(), - ) - .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN)) - } - - fn on_logging_message( - &self, - params: LoggingMessageNotificationParam, - _context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - let receive_signal = self.receive_signal.clone(); - let received_messages = self.received_messages.clone(); - - async move { - println!("Client: Received log message: {:?}", params); - let mut messages = received_messages.lock().unwrap(); - messages.push(params); - receive_signal.notify_one(); - } - } -} - -pub struct TestServer {} - -impl TestServer { - #[allow(dead_code)] - pub fn new() -> Self { - Self {} - } -} - -impl ServerHandler for TestServer { - #[allow(deprecated)] - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_logging().build()) - } - - #[allow(deprecated)] - fn set_level( - &self, - request: SetLevelRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - let peer = context.peer; - async move { - let (data, logger) = match request.level { - LoggingLevel::Error => ( - serde_json::json!({ - "message": "Failed to process request", - "error_code": "E1001", - "error_details": "Connection timeout", - "timestamp": chrono::Utc::now().to_rfc3339(), - }), - Some("error_handler".to_string()), - ), - LoggingLevel::Debug => ( - serde_json::json!({ - "message": "Processing request", - "function": "handle_request", - "line": 42, - "context": { - "request_id": "req-123", - "user_id": "user-456" - }, - "timestamp": chrono::Utc::now().to_rfc3339(), - }), - Some("debug_logger".to_string()), - ), - LoggingLevel::Info => ( - serde_json::json!({ - "message": "System status update", - "status": "healthy", - "metrics": { - "requests_per_second": 150, - "average_latency_ms": 45, - "error_rate": 0.01 - }, - "timestamp": chrono::Utc::now().to_rfc3339(), - }), - Some("monitoring".to_string()), - ), - _ => ( - serde_json::json!({ - "message": format!("Message at level {:?}", request.level), - "timestamp": chrono::Utc::now().to_rfc3339(), - }), - None, - ), - }; - - if let Err(e) = peer - .notify_logging_message({ - let mut param = LoggingMessageNotificationParam::new(request.level, data); - if let Some(l) = logger { - param = param.with_logger(l); - } - param - }) - .await - { - panic!("Failed to send notification: {}", e); - } - Ok(()) - } - } -} diff --git a/vendor/rmcp/tests/common/mod.rs b/vendor/rmcp/tests/common/mod.rs deleted file mode 100644 index 491960651..000000000 --- a/vendor/rmcp/tests/common/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod calculator; -pub mod handlers; diff --git a/vendor/rmcp/tests/test_auth_error_classification.rs b/vendor/rmcp/tests/test_auth_error_classification.rs deleted file mode 100644 index 99b050810..000000000 --- a/vendor/rmcp/tests/test_auth_error_classification.rs +++ /dev/null @@ -1,85 +0,0 @@ -use std::{any::TypeId, error::Error}; - -use rmcp::{ - service::ClientInitializeError, - transport::{ - AuthError, DynamicTransportError, - streamable_http_client::{AuthRequiredError, InsufficientScopeError, StreamableHttpError}, - }, -}; -use thiserror::Error; - -type TestHttpError = StreamableHttpError; - -#[derive(Debug, Error)] -#[error("outer transport wrapper")] -struct OuterError(#[source] TestHttpError); - -fn initialization_error(error: impl Error + Send + Sync + 'static) -> ClientInitializeError { - ClientInitializeError::TransportError { - error: DynamicTransportError::from_parts( - "test transport", - TypeId::of::<()>(), - Box::new(error), - ), - context: "initialize".into(), - } -} - -#[test] -fn classifies_local_authorization_required() { - let error = TestHttpError::Auth(AuthError::AuthorizationRequired); - - assert!(initialization_error(error).is_authorization_required()); -} - -#[test] -fn classifies_http_authorization_challenge() { - let error = - TestHttpError::AuthRequired(AuthRequiredError::new("Bearer realm=\"mcp\"".to_owned())); - - assert!(initialization_error(error).is_authorization_required()); -} - -#[test] -fn classifies_authorization_required_through_multiple_sources() { - let error = OuterError(TestHttpError::Auth(AuthError::AuthorizationRequired)); - - assert!(initialization_error(error).is_authorization_required()); -} - -#[test] -fn does_not_classify_unrelated_transport_errors() { - let closed = TestHttpError::TransportChannelClosed; - let refresh = TestHttpError::Auth(AuthError::TokenRefreshFailed("timeout".to_owned())); - let scope = TestHttpError::InsufficientScope(InsufficientScopeError::new( - "Bearer error=\"insufficient_scope\"".to_owned(), - Some("admin".to_owned()), - )); - - assert!(!initialization_error(closed).is_authorization_required()); - assert!(!initialization_error(refresh).is_authorization_required()); - assert!(!initialization_error(scope).is_authorization_required()); -} - -#[test] -fn does_not_classify_non_transport_initialization_errors() { - assert!(!ClientInitializeError::Cancelled.is_authorization_required()); - assert!( - !ClientInitializeError::ConnectionClosed("server closed the connection".to_owned()) - .is_authorization_required() - ); -} - -#[test] -fn http_challenge_remains_available_as_an_error_source() { - let error = - TestHttpError::AuthRequired(AuthRequiredError::new("Bearer realm=\"mcp\"".to_owned())); - - let source = error.source().expect("auth challenge should be a source"); - let challenge = source - .downcast_ref::() - .expect("source should retain the challenge type"); - - assert_eq!(challenge.www_authenticate_header, "Bearer realm=\"mcp\""); -} diff --git a/vendor/rmcp/tests/test_cache_hints.rs b/vendor/rmcp/tests/test_cache_hints.rs deleted file mode 100644 index 2b6aecd8c..000000000 --- a/vendor/rmcp/tests/test_cache_hints.rs +++ /dev/null @@ -1,79 +0,0 @@ -use rmcp::model::{CacheScope, ListToolsResult, ReadResourceResult, ResourceContents}; -use serde_json::json; - -#[test] -fn paginated_results_serialize_cache_hints_as_top_level_fields() { - let result = ListToolsResult::with_all_items(Vec::new()) - .with_ttl_ms(5_000) - .with_cache_scope(CacheScope::Private); - - let actual = serde_json::to_value(result).expect("serialize list tools result"); - - assert_eq!( - actual, - json!({ - "ttlMs": 5000, - "cacheScope": "private", - "tools": [], - "resultType": "complete" - }) - ); - assert!(actual.get("_meta").is_none()); -} - -#[test] -fn read_resource_results_serialize_cache_hints_as_top_level_fields() { - let result = - ReadResourceResult::new(vec![ResourceContents::text("hello", "file:///example.txt")]) - .with_ttl_ms(10_000) - .with_cache_scope(CacheScope::Public); - - let actual = serde_json::to_value(result).expect("serialize read resource result"); - - assert_eq!(actual["ttlMs"], 10000); - assert_eq!(actual["cacheScope"], "public"); - assert!(actual["contents"][0].get("_meta").is_none()); -} - -#[test] -fn cache_hints_are_omitted_when_absent() { - let result = ListToolsResult::with_all_items(Vec::new()); - let actual = serde_json::to_value(result).expect("serialize list tools result"); - - assert_eq!(actual, json!({ "tools": [], "resultType": "complete" })); -} - -#[test] -fn cache_hints_default_to_none_and_negative_ttl_is_normalized_to_zero() { - let absent: ListToolsResult = serde_json::from_value(json!({ - "tools": [] - })) - .expect("deserialize result without ttlMs"); - assert_eq!(absent.ttl_ms, None); - assert_eq!(absent.cache_scope, None); - - let negative: ReadResourceResult = serde_json::from_value(json!({ - "ttlMs": -42, - "cacheScope": "private", - "contents": [] - })) - .expect("deserialize result with negative ttlMs"); - assert_eq!(negative.ttl_ms, Some(0)); - assert_eq!(negative.cache_scope, Some(CacheScope::Private)); -} - -#[test] -fn cache_scope_round_trips() { - assert_eq!( - serde_json::to_value(CacheScope::Public).unwrap(), - json!("public") - ); - assert_eq!( - serde_json::to_value(CacheScope::Private).unwrap(), - json!("private") - ); - assert_eq!( - serde_json::from_value::(json!("private")).unwrap(), - CacheScope::Private - ); -} diff --git a/vendor/rmcp/tests/test_cancelled_response.rs b/vendor/rmcp/tests/test_cancelled_response.rs deleted file mode 100644 index 5961a7b5d..000000000 --- a/vendor/rmcp/tests/test_cancelled_response.rs +++ /dev/null @@ -1,212 +0,0 @@ -//! A receiver SHOULD NOT send a response for a request it has already been told -//! to cancel. This drives a real stdio server with raw JSON-RPC: the tool blocks -//! until the request is cancelled, so its result is only produced *after* the -//! cancellation — the service loop must drop it rather than write it to the wire. - -use std::{collections::BTreeSet, process::Stdio, time::Duration}; - -use rmcp::{ - ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, - model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities, - ServerInfo, - }, - service::RequestContext, -}; -use serde_json::{Value, json}; -use tokio::{ - io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}, - process::{Child, Command}, -}; - -const HELPER_ENV: &str = "RMCP_CANCELLED_RESPONSE_HELPER"; -const READ_TIMEOUT: Duration = Duration::from_secs(10); - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn cancelled_request_receives_no_response() -> anyhow::Result<()> { - let mut child = spawn_helper(); - let mut writer = child.stdin.take().expect("helper stdin"); - let stdout = child.stdout.take().expect("helper stdout"); - let mut reader = BufReader::new(stdout); - - send_json( - &mut writer, - &json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": { "name": "raw-test-client", "version": "0.0.0" } - } - }), - ) - .await?; - collect_ids_until(&mut reader, 1, READ_TIMEOUT).await?; - send_json( - &mut writer, - &json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), - ) - .await?; - - // Start a request that blocks until cancelled, then cancel it. Its response is - // produced only after the cancellation arrives, so it must be suppressed. - send_json( - &mut writer, - &json!({ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": { "name": "wait-for-cancel", "arguments": {} } - }), - ) - .await?; - send_json( - &mut writer, - &json!({ - "jsonrpc": "2.0", - "method": "notifications/cancelled", - "params": { "requestId": 2 } - }), - ) - .await?; - // A ping proves the server is alive past the cancellation, so the absence of - // an id=2 response is genuine suppression rather than a dead connection. - send_json( - &mut writer, - &json!({ "jsonrpc": "2.0", "id": 3, "method": "ping" }), - ) - .await?; - - let seen = collect_ids_until(&mut reader, 3, READ_TIMEOUT).await?; - assert!(seen.contains(&3)); - assert!(!seen.contains(&2)); - - drop(writer); - wait_for_child(&mut child).await; - Ok(()) -} - -struct WaitForCancelServer; - -impl ServerHandler for WaitForCancelServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - } - - async fn call_tool( - &self, - _request: CallToolRequestParams, - context: RequestContext, - ) -> Result { - context.ct.cancelled().await; - Ok(CallToolResult::success(vec![ContentBlock::text("late response")]).into()) - } -} - -#[tokio::test] -async fn cancelled_response_helper() -> anyhow::Result<()> { - if std::env::var(HELPER_ENV).as_deref() != Ok("1") { - return Ok(()); - } - run_helper_server().await?; - Ok(()) -} - -#[cfg(feature = "local")] -async fn run_helper_server() -> anyhow::Result<()> { - tokio::task::LocalSet::new() - .run_until(serve_helper_stdio()) - .await -} - -#[cfg(not(feature = "local"))] -async fn run_helper_server() -> anyhow::Result<()> { - serve_helper_stdio().await -} - -async fn serve_helper_stdio() -> anyhow::Result<()> { - let server = WaitForCancelServer.serve(rmcp::transport::stdio()).await?; - server.waiting().await?; - Ok(()) -} - -fn spawn_helper() -> Child { - let exe = std::env::current_exe().expect("current test exe"); - Command::new(exe) - .arg("--exact") - .arg("cancelled_response_helper") - .arg("--quiet") - .arg("--nocapture") - .arg("--test-threads") - .arg("1") - .env(HELPER_ENV, "1") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .kill_on_drop(true) - .spawn() - .expect("spawn helper") -} - -async fn wait_for_child(child: &mut Child) { - let _ = tokio::time::timeout(Duration::from_secs(2), child.wait()).await; - if child.id().is_some() { - let _ = child.kill().await; - } -} - -async fn send_json(writer: &mut W, message: &Value) -> anyhow::Result<()> -where - W: AsyncWrite + Unpin, -{ - let serialized = serde_json::to_string(message)?; - writer.write_all(serialized.as_bytes()).await?; - writer.write_all(b"\n").await?; - writer.flush().await?; - Ok(()) -} - -/// Read response lines, collecting every message id seen, until `stop_id` is seen -/// (then a short grace read to catch any straggler) or the timeout elapses. -async fn collect_ids_until( - reader: &mut BufReader, - stop_id: u64, - timeout: Duration, -) -> anyhow::Result> -where - R: tokio::io::AsyncRead + Unpin, -{ - let mut seen = BTreeSet::new(); - let mut deadline = tokio::time::Instant::now() + timeout; - loop { - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); - if remaining.is_zero() { - break; - } - let mut line = String::new(); - let Ok(read_result) = tokio::time::timeout(remaining, reader.read_line(&mut line)).await - else { - break; - }; - if read_result? == 0 { - break; - } - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let Ok(value) = serde_json::from_str::(trimmed) else { - continue; - }; - if let Some(id) = value.get("id").and_then(Value::as_u64) { - seen.insert(id); - if id == stop_id { - // Give any late (incorrectly-sent) response a brief window to arrive. - deadline = tokio::time::Instant::now() + Duration::from_millis(300); - } - } - } - Ok(seen) -} diff --git a/vendor/rmcp/tests/test_client_credentials.rs b/vendor/rmcp/tests/test_client_credentials.rs deleted file mode 100644 index f21c85814..000000000 --- a/vendor/rmcp/tests/test_client_credentials.rs +++ /dev/null @@ -1,258 +0,0 @@ -use std::{convert::Infallible, net::SocketAddr}; - -use axum::{ - Router, - body::Body, - http::{Request, Response, StatusCode}, - routing::{get, post}, -}; -use rmcp::transport::auth::{ClientCredentialsConfig, OAuthState}; - -fn json_response(body: serde_json::Value) -> Response { - Response::builder() - .status(StatusCode::OK) - .header("content-type", "application/json") - .body(Body::from(serde_json::to_string(&body).unwrap())) - .unwrap() -} - -fn json_error(status: StatusCode, body: serde_json::Value) -> Response { - Response::builder() - .status(status) - .header("content-type", "application/json") - .body(Body::from(serde_json::to_string(&body).unwrap())) - .unwrap() -} - -async fn resource_metadata_handler(req: Request) -> Result, Infallible> { - let host = req.headers().get("host").unwrap().to_str().unwrap(); - let base_url = format!("http://{}", host); - Ok(json_response(serde_json::json!({ - "resource": base_url, - "authorization_servers": [base_url], - "scopes_supported": ["read", "write"] - }))) -} - -async fn auth_server_metadata_handler(req: Request) -> Result, Infallible> { - let host = req.headers().get("host").unwrap().to_str().unwrap(); - let base_url = format!("http://{}", host); - Ok(auth_server_metadata_response(&base_url, &base_url)) -} - -async fn path_inserted_auth_server_metadata_handler( - req: Request, -) -> Result, Infallible> { - let host = req.headers().get("host").unwrap().to_str().unwrap(); - let base_url = format!("http://{}", host); - Ok(auth_server_metadata_response( - &format!("{}/mcp", base_url), - &base_url, - )) -} - -fn auth_server_metadata_response(issuer: &str, endpoint_base_url: &str) -> Response { - json_response(serde_json::json!({ - "issuer": issuer, - "authorization_endpoint": format!("{}/authorize", endpoint_base_url), - "token_endpoint": format!("{}/token", endpoint_base_url), - "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], - "grant_types_supported": ["client_credentials"], - "scopes_supported": ["read", "write"] - })) -} - -async fn token_handler(req: Request) -> Result, Infallible> { - let body_bytes = axum::body::to_bytes(req.into_body(), 1024 * 64) - .await - .unwrap(); - let body_str = String::from_utf8(body_bytes.to_vec()).unwrap(); - - // Parse form-urlencoded body - let params: Vec<(String, String)> = url::form_urlencoded::parse(body_str.as_bytes()) - .into_owned() - .collect(); - - let get_param = |key: &str| -> Option { - params - .iter() - .find(|(k, _)| k == key) - .map(|(_, v)| v.clone()) - }; - - let grant_type = get_param("grant_type").unwrap_or_default(); - if grant_type != "client_credentials" { - return Ok(json_error( - StatusCode::BAD_REQUEST, - serde_json::json!({ - "error": "unsupported_grant_type", - "error_description": "Only client_credentials grant type is supported" - }), - )); - } - - let client_id = get_param("client_id").unwrap_or_default(); - if client_id != "test-m2m-client" { - return Ok(json_error( - StatusCode::UNAUTHORIZED, - serde_json::json!({ - "error": "invalid_client", - "error_description": "Unknown client_id" - }), - )); - } - - let client_secret = get_param("client_secret").unwrap_or_default(); - if client_secret != "test-m2m-secret" { - return Ok(json_error( - StatusCode::UNAUTHORIZED, - serde_json::json!({ - "error": "invalid_client", - "error_description": "Invalid client_secret" - }), - )); - } - - let scope = get_param("scope").unwrap_or_default(); - - let mut response = serde_json::json!({ - "access_token": "m2m-access-token-12345", - "token_type": "Bearer", - "expires_in": 3600 - }); - - if !scope.is_empty() { - response["scope"] = serde_json::Value::String(scope); - } - - Ok(json_response(response)) -} - -async fn start_mock_server() -> (String, SocketAddr) { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let base_url = format!("http://{}", addr); - - let app = Router::new() - .route( - "/.well-known/oauth-protected-resource", - get(resource_metadata_handler), - ) - .route( - "/.well-known/oauth-authorization-server", - get(auth_server_metadata_handler), - ) - .route("/token", post(token_handler)); - - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - (base_url, addr) -} - -async fn start_path_insert_metadata_server() -> (String, SocketAddr) { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let base_url = format!("http://{}", addr); - - let app = Router::new() - .route( - "/.well-known/oauth-authorization-server/mcp", - get(path_inserted_auth_server_metadata_handler), - ) - .route("/token", post(token_handler)); - - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - (base_url, addr) -} - -#[tokio::test] -async fn test_client_credentials_flow_client_secret() { - let (base_url, _addr) = start_mock_server().await; - - let mut oauth_state = OAuthState::new(&base_url, None).await.unwrap(); - - let config = ClientCredentialsConfig::ClientSecret { - client_id: "test-m2m-client".to_string(), - client_secret: "test-m2m-secret".to_string(), - scopes: vec!["read".to_string(), "write".to_string()], - resource: Some(base_url.clone()), - }; - - oauth_state - .authenticate_client_credentials(config) - .await - .unwrap(); - - let manager = oauth_state - .into_authorization_manager() - .expect("Should be in Authorized state"); - - let token = manager.get_access_token().await.unwrap(); - assert_eq!(token, "m2m-access-token-12345"); -} - -#[tokio::test] -async fn test_client_credentials_discovers_path_inserted_oauth_metadata() { - let (base_url, _addr) = start_path_insert_metadata_server().await; - let resource_url = format!("{base_url}/mcp"); - - let mut oauth_state = OAuthState::new(&resource_url, None).await.unwrap(); - - let config = ClientCredentialsConfig::ClientSecret { - client_id: "test-m2m-client".to_string(), - client_secret: "test-m2m-secret".to_string(), - scopes: vec!["read".to_string()], - resource: Some(resource_url), - }; - - oauth_state - .authenticate_client_credentials(config) - .await - .unwrap(); - - let manager = oauth_state - .into_authorization_manager() - .expect("Should be in Authorized state"); - - let token = manager.get_access_token().await.unwrap(); - assert_eq!(token, "m2m-access-token-12345"); -} - -#[tokio::test] -async fn test_client_credentials_invalid_secret() { - let (base_url, _addr) = start_mock_server().await; - - let mut oauth_state = OAuthState::new(&base_url, None).await.unwrap(); - - let config = ClientCredentialsConfig::ClientSecret { - client_id: "test-m2m-client".to_string(), - client_secret: "wrong-secret".to_string(), - scopes: vec![], - resource: Some(base_url.clone()), - }; - - let result = oauth_state.authenticate_client_credentials(config).await; - assert!(result.is_err(), "Should fail with invalid credentials"); -} - -#[tokio::test] -async fn test_client_credentials_invalid_client_id() { - let (base_url, _addr) = start_mock_server().await; - - let mut oauth_state = OAuthState::new(&base_url, None).await.unwrap(); - - let config = ClientCredentialsConfig::ClientSecret { - client_id: "unknown-client".to_string(), - client_secret: "test-m2m-secret".to_string(), - scopes: vec![], - resource: Some(base_url.clone()), - }; - - let result = oauth_state.authenticate_client_credentials(config).await; - assert!(result.is_err(), "Should fail with unknown client_id"); -} diff --git a/vendor/rmcp/tests/test_client_initialization.rs b/vendor/rmcp/tests/test_client_initialization.rs deleted file mode 100644 index 960e1cf53..000000000 --- a/vendor/rmcp/tests/test_client_initialization.rs +++ /dev/null @@ -1,152 +0,0 @@ -// cargo test --features "server client" --package rmcp test_client_initialization -#![cfg(all(feature = "client", not(feature = "local")))] - -mod common; - -use std::borrow::Cow; - -use common::handlers::TestClientHandler; -use rmcp::{ - ServiceExt, - model::{ - ClientJsonRpcMessage, ErrorCode, ErrorData, InitializeResult, JsonRpcError, - JsonRpcVersion2_0, RequestId, ServerCapabilities, ServerJsonRpcMessage, ServerResult, - }, - transport::{IntoTransport, Transport}, -}; - -fn stringify_numeric_id(id: RequestId) -> RequestId { - let RequestId::Number(id) = id else { - panic!("expected a numeric request ID"); - }; - RequestId::String(id.to_string().into()) -} - -#[tokio::test] -async fn client_initialization_accepts_stringified_numeric_response_id() { - let (server_transport, client_transport) = tokio::io::duplex(1024); - let mut server = IntoTransport::::into_transport(server_transport); - let server_task = tokio::spawn(async move { - let ClientJsonRpcMessage::Request(request) = - server.receive().await.expect("expected initialize request") - else { - panic!("expected initialize request"); - }; - server - .send(ServerJsonRpcMessage::response( - ServerResult::InitializeResult( - InitializeResult::new(ServerCapabilities::default()), - ), - stringify_numeric_id(request.id), - )) - .await - .expect("send initialize response"); - assert!(matches!( - server.receive().await, - Some(ClientJsonRpcMessage::Notification(_)) - )); - }); - - let client = TestClientHandler::new(true, true) - .serve(client_transport) - .await - .expect("client should accept stringified initialize response ID"); - assert!( - client - .peer_info() - .expect("peer info should be retained") - .server_info - .is_some(), - "initialize always provides a server implementation identity" - ); - client.cancel().await.expect("cancel client"); - server_task.await.expect("server task"); -} - -#[tokio::test] -async fn client_correlates_stringified_numeric_response_id() { - let (server_transport, client_transport) = tokio::io::duplex(1024); - let mut server = IntoTransport::::into_transport(server_transport); - let server_task = tokio::spawn(async move { - let ClientJsonRpcMessage::Request(initialize) = - server.receive().await.expect("expected initialize request") - else { - panic!("expected initialize request"); - }; - server - .send(ServerJsonRpcMessage::response( - ServerResult::InitializeResult( - InitializeResult::new(ServerCapabilities::default()), - ), - initialize.id, - )) - .await - .expect("send initialize response"); - assert!(matches!( - server.receive().await, - Some(ClientJsonRpcMessage::Notification(_)) - )); - - let ClientJsonRpcMessage::Request(request) = - server.receive().await.expect("expected tools/list request") - else { - panic!("expected tools/list request"); - }; - server - .send(ServerJsonRpcMessage::response( - ServerResult::ListToolsResult(Default::default()), - stringify_numeric_id(request.id), - )) - .await - .expect("send tools/list response"); - }); - - let client = TestClientHandler::new(true, true) - .serve(client_transport) - .await - .expect("initialize client"); - client - .list_tools(None) - .await - .expect("client should correlate stringified response ID"); - client.cancel().await.expect("cancel client"); - server_task.await.expect("server task"); -} - -#[tokio::test] -async fn test_client_init_handles_jsonrpc_error() { - let (server_transport, client_transport) = tokio::io::duplex(1024); - let mut server = IntoTransport::::into_transport(server_transport); - - let client_handle = tokio::spawn(async move { - TestClientHandler::new(true, true) - .serve(client_transport) - .await - }); - - tokio::spawn(async move { - let _init_request = server.receive().await; - - let error_msg = ServerJsonRpcMessage::Error(JsonRpcError { - jsonrpc: JsonRpcVersion2_0, - id: Some(RequestId::Number(1)), - error: ErrorData { - code: ErrorCode(-32600), - message: Cow::Borrowed("Invalid Request"), - data: None, - }, - }); - let _: Result<(), _> = server.send(error_msg).await; - }); - - let result = client_handle.await.unwrap(); - - assert!(result.is_err()); - match result { - Err(rmcp::service::ClientInitializeError::JsonRpcError(error_data)) => { - assert_eq!(error_data.code, ErrorCode(-32600)); - assert_eq!(error_data.message, "Invalid Request"); - } - _ => panic!("Expected ClientInitializeError::JsonRpcError"), - } -} diff --git a/vendor/rmcp/tests/test_client_lifecycle_modes.rs b/vendor/rmcp/tests/test_client_lifecycle_modes.rs deleted file mode 100644 index 4b75c3550..000000000 --- a/vendor/rmcp/tests/test_client_lifecycle_modes.rs +++ /dev/null @@ -1,463 +0,0 @@ -#![cfg(all(feature = "client", feature = "server", not(feature = "local")))] - -use rmcp::{ - ClientHandler, ClientLifecycleMode, ClientServiceExt, ServerHandler, ServiceExt, - model::{ - ClientJsonRpcMessage, ClientRequest, DiscoverResult, ErrorCode, ErrorData, GetMeta, - Implementation, InitializeResult, ProtocolVersion, RequestId, ServerCapabilities, - ServerJsonRpcMessage, ServerResult, - }, - service::PeerRequestOptions, - transport::{IntoTransport, Transport}, -}; - -#[derive(Clone, Default)] -struct DiscoverClient; - -impl ClientHandler for DiscoverClient {} - -#[derive(Clone, Default)] -struct StatelessServer; - -impl ServerHandler for StatelessServer {} - -#[tokio::test] -async fn discover_startup_accepts_stringified_numeric_response_id() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let mut server = IntoTransport::::into_transport(server_transport); - let server_task = tokio::spawn(async move { - let ClientJsonRpcMessage::Request(request) = - server.receive().await.expect("expected discover request") - else { - panic!("expected discover request"); - }; - let RequestId::Number(response_id) = request.id else { - panic!("expected a numeric request ID"); - }; - server - .send(ServerJsonRpcMessage::response( - ServerResult::DiscoverResult( - DiscoverResult::new( - vec![ProtocolVersion::V_2026_07_28], - ServerCapabilities::default(), - ) - .with_server_info(Implementation::new("discover-server", "1.0.0")), - ), - RequestId::String(response_id.to_string().into()), - )) - .await - .expect("send discover response"); - }); - - let client = DiscoverClient - .serve_with_lifecycle( - client_transport, - ClientLifecycleMode::Discover { - preferred_versions: vec![ProtocolVersion::V_2026_07_28], - }, - ) - .await - .expect("client should accept stringified discover response ID"); - client.cancel().await.expect("cancel client"); - server_task.await.expect("server task"); -} - -#[tokio::test] -#[allow(deprecated)] -async fn discover_startup_accepts_missing_optional_server_info() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let mut server = IntoTransport::::into_transport(server_transport); - let (rejection_observed_tx, rejection_observed_rx) = tokio::sync::oneshot::channel(); - let server_task = tokio::spawn(async move { - let ClientJsonRpcMessage::Request(discover_request) = - server.receive().await.expect("expected discover request") - else { - panic!("expected discover request"); - }; - let mut result = DiscoverResult::new( - vec![ProtocolVersion::V_2026_07_28], - ServerCapabilities::builder().enable_tools().build(), - ); - result.instructions = Some("discovery instructions".into()); - result.meta = Some(rmcp::model::MetaObject::new()); - result - .meta - .as_mut() - .expect("metadata") - .0 - .insert("example.test/key".into(), serde_json::json!(7)); - server - .send(ServerJsonRpcMessage::response( - ServerResult::DiscoverResult(result), - discover_request.id, - )) - .await - .expect("send discover response"); - - server - .send(ServerJsonRpcMessage::request( - rmcp::model::ServerRequest::CreateMessageRequest( - rmcp::model::CreateMessageRequest::new( - rmcp::model::CreateMessageRequestParams::new( - vec![rmcp::model::SamplingMessage::user_text("unsolicited")], - 16, - ), - ), - ), - RequestId::Number(99), - )) - .await - .expect("send unsolicited server request"); - let Some(ClientJsonRpcMessage::Error(error)) = server.receive().await else { - panic!("expected unsolicited server request to be rejected"); - }; - assert_eq!(error.error.code, ErrorCode::INVALID_PARAMS); - rejection_observed_tx - .send(()) - .expect("signal observed rejection"); - - let ClientJsonRpcMessage::Request(request) = - server.receive().await.expect("expected normal request") - else { - panic!("expected normal request"); - }; - assert_eq!( - request.request.get_meta().protocol_version(), - Some(ProtocolVersion::V_2026_07_28) - ); - server - .send(ServerJsonRpcMessage::response( - ServerResult::ListToolsResult(Default::default()), - request.id, - )) - .await - .expect("send list tools response"); - }); - - let client = DiscoverClient - .serve_with_lifecycle( - client_transport, - ClientLifecycleMode::Discover { - preferred_versions: vec![ProtocolVersion::V_2026_07_28], - }, - ) - .await - .expect("missing optional server info should not fail discovery"); - let peer_info = client.peer_info().expect("peer info should be retained"); - assert_eq!(peer_info.protocol_version, ProtocolVersion::V_2026_07_28); - assert!(peer_info.capabilities.tools.is_some()); - assert_eq!(peer_info.server_info, None); - assert_eq!( - peer_info.instructions.as_deref(), - Some("discovery instructions") - ); - assert_eq!( - peer_info - .meta - .as_ref() - .and_then(|meta| meta.0.get("example.test/key")), - Some(&serde_json::json!(7)) - ); - rejection_observed_rx - .await - .expect("server should observe association rejection"); - client.list_tools(None).await.expect("list tools"); - client.cancel().await.expect("cancel client"); - server_task.await.expect("server task"); -} - -#[tokio::test] -async fn high_level_server_accepts_discover_startup_without_initialize() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let server_task = tokio::spawn(async move { - StatelessServer - .serve(server_transport) - .await - .expect("server should accept discover") - }); - - let client = DiscoverClient - .serve_with_lifecycle( - client_transport, - ClientLifecycleMode::Discover { - preferred_versions: vec![ProtocolVersion::V_2026_07_28], - }, - ) - .await - .expect("discover client should start"); - client.list_tools(None).await.expect("list tools"); - client.cancel().await.expect("cancel client"); - let server = server_task.await.expect("server task"); - server.cancel().await.expect("cancel server"); -} - -#[tokio::test] -async fn discover_startup_omits_initialize() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let mut server = IntoTransport::::into_transport(server_transport); - let server_task = tokio::spawn(async move { - let ClientJsonRpcMessage::Request(request) = - server.receive().await.expect("expected discover request") - else { - panic!("expected request"); - }; - assert!(matches!(request.request, ClientRequest::DiscoverRequest(_))); - let meta = request.request.get_meta(); - assert_eq!(meta.protocol_version(), Some(ProtocolVersion::V_2026_07_28)); - assert!(meta.client_info().is_some()); - assert!(meta.client_capabilities().is_some()); - - server - .send(ServerJsonRpcMessage::response( - ServerResult::DiscoverResult( - DiscoverResult::new( - vec![ProtocolVersion::V_2026_07_28], - ServerCapabilities::default(), - ) - .with_server_info(Implementation::new("discover-server", "1.0.0")), - ), - request.id, - )) - .await - .expect("send discover response"); - - let ClientJsonRpcMessage::Request(request) = - server.receive().await.expect("expected normal request") - else { - panic!("expected request"); - }; - assert!(!matches!( - request.request, - ClientRequest::InitializeRequest(_) - )); - let meta = request.request.get_meta(); - assert_eq!(meta.protocol_version(), Some(ProtocolVersion::V_2025_11_25)); - assert!(meta.client_info().is_some()); - assert!(meta.client_capabilities().is_some()); - assert_eq!( - meta.get("example.test/extension"), - Some(&serde_json::json!(7)) - ); - server - .send(ServerJsonRpcMessage::response( - ServerResult::ListToolsResult(Default::default()), - request.id, - )) - .await - .expect("send tools response"); - }); - - let client = DiscoverClient - .serve_with_lifecycle( - client_transport, - ClientLifecycleMode::Discover { - preferred_versions: vec![ProtocolVersion::V_2026_07_28], - }, - ) - .await - .expect("discover client should start"); - let mut caller_meta = rmcp::model::RequestMetaObject::new(); - caller_meta.insert("example.test/extension".into(), serde_json::json!(7)); - caller_meta.set_protocol_version(ProtocolVersion::V_2025_11_25); - client - .send_request_with_option( - ClientRequest::ListToolsRequest(rmcp::model::ListToolsRequest { - method: Default::default(), - params: None, - extensions: Default::default(), - }), - PeerRequestOptions::default().with_meta(caller_meta), - ) - .await - .expect("send list tools") - .await_response() - .await - .expect("list tools response"); - client.cancel().await.expect("cancel client"); - server_task.await.expect("server task"); -} - -#[tokio::test] -async fn auto_startup_falls_back_after_discover_method_not_found() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let mut server = IntoTransport::::into_transport(server_transport); - let server_task = tokio::spawn(async move { - let ClientJsonRpcMessage::Request(discover) = - server.receive().await.expect("expected discover request") - else { - panic!("expected request"); - }; - assert!(matches!( - discover.request, - ClientRequest::DiscoverRequest(_) - )); - server - .send(ServerJsonRpcMessage::error( - ErrorData::new(ErrorCode::METHOD_NOT_FOUND, "Method not found", None), - Some(discover.id), - )) - .await - .expect("send method-not-found"); - - let ClientJsonRpcMessage::Request(initialize) = - server.receive().await.expect("expected initialize request") - else { - panic!("expected request"); - }; - assert!(matches!( - initialize.request, - ClientRequest::InitializeRequest(_) - )); - server - .send(ServerJsonRpcMessage::response( - ServerResult::InitializeResult( - InitializeResult::new(ServerCapabilities::default()), - ), - initialize.id, - )) - .await - .expect("send initialize response"); - assert!(matches!( - server.receive().await, - Some(ClientJsonRpcMessage::Notification(_)) - )); - }); - - let client = DiscoverClient - .serve_with_lifecycle( - client_transport, - ClientLifecycleMode::Auto { - preferred_versions: vec![ProtocolVersion::V_2026_07_28], - legacy_version: Some(ProtocolVersion::V_2025_11_25), - }, - ) - .await - .expect("auto client should fall back"); - client.cancel().await.expect("cancel client"); - server_task.await.expect("server task"); -} - -#[tokio::test] -async fn discover_startup_retries_a_mutually_supported_version() { - let unsupported: ProtocolVersion = - serde_json::from_value(serde_json::json!("2099-01-01")).unwrap(); - let (server_transport, client_transport) = tokio::io::duplex(4096); - let mut server = IntoTransport::::into_transport(server_transport); - let server_task = tokio::spawn(async move { - let ClientJsonRpcMessage::Request(first) = - server.receive().await.expect("expected first discover") - else { - panic!("expected request"); - }; - assert_eq!( - first.request.get_meta().protocol_version(), - Some(unsupported.clone()) - ); - server - .send(ServerJsonRpcMessage::error( - ErrorData::unsupported_protocol_version( - unsupported, - &[ProtocolVersion::V_2026_07_28], - ), - Some(first.id), - )) - .await - .expect("send unsupported error"); - - let ClientJsonRpcMessage::Request(second) = - server.receive().await.expect("expected retry discover") - else { - panic!("expected request"); - }; - assert_eq!( - second.request.get_meta().protocol_version(), - Some(ProtocolVersion::V_2026_07_28) - ); - server - .send(ServerJsonRpcMessage::response( - ServerResult::DiscoverResult( - DiscoverResult::new( - vec![ProtocolVersion::V_2026_07_28], - ServerCapabilities::default(), - ) - .with_server_info(Implementation::new("discover-server", "1.0.0")), - ), - second.id, - )) - .await - .expect("send discover response"); - }); - - let client = DiscoverClient - .serve_with_lifecycle( - client_transport, - ClientLifecycleMode::Discover { - preferred_versions: vec![ - serde_json::from_value(serde_json::json!("2099-01-01")).unwrap(), - ProtocolVersion::V_2026_07_28, - ], - }, - ) - .await - .expect("discover client should retry"); - client.cancel().await.expect("cancel client"); - server_task.await.expect("server task"); -} - -#[tokio::test] -async fn discover_startup_retries_current_version_once_when_server_reports_it_supported() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let mut server = IntoTransport::::into_transport(server_transport); - let server_task = tokio::spawn(async move { - let ClientJsonRpcMessage::Request(first) = - server.receive().await.expect("expected first discover") - else { - panic!("expected request"); - }; - server - .send(ServerJsonRpcMessage::error( - ErrorData::unsupported_protocol_version( - ProtocolVersion::V_2026_07_28, - &[ProtocolVersion::V_2026_07_28], - ), - Some(first.id), - )) - .await - .expect("send unsupported error"); - - let ClientJsonRpcMessage::Request(second) = - server.receive().await.expect("expected retry discover") - else { - panic!("expected request"); - }; - assert_eq!( - second.request.get_meta().protocol_version(), - Some(ProtocolVersion::V_2026_07_28) - ); - server - .send(ServerJsonRpcMessage::response( - ServerResult::DiscoverResult( - DiscoverResult::new( - vec![ProtocolVersion::V_2026_07_28], - ServerCapabilities::default(), - ) - .with_server_info(Implementation::new("discover-server", "1.0.0")), - ), - second.id, - )) - .await - .expect("send discover response"); - }); - - let client = DiscoverClient - .serve_with_lifecycle( - client_transport, - ClientLifecycleMode::Discover { - preferred_versions: vec![ProtocolVersion::V_2026_07_28], - }, - ) - .await - .expect("discover client should retry once"); - client.cancel().await.expect("cancel client"); - server_task.await.expect("server task"); -} diff --git a/vendor/rmcp/tests/test_close_connection.rs b/vendor/rmcp/tests/test_close_connection.rs deleted file mode 100644 index 50479a237..000000000 --- a/vendor/rmcp/tests/test_close_connection.rs +++ /dev/null @@ -1,128 +0,0 @@ -#![cfg(not(feature = "local"))] -//cargo test --test test_close_connection --features "client server" - -mod common; -use std::time::Duration; - -use common::handlers::{TestClientHandler, TestServer}; -use rmcp::{ServiceExt, service::QuitReason}; - -/// Test that close() properly shuts down the connection -#[tokio::test] -async fn test_close_method() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - // Start server - let server_handle = tokio::spawn(async move { - let server = TestServer::new().serve(server_transport).await?; - server.waiting().await?; - anyhow::Ok(()) - }); - - // Start client - let handler = TestClientHandler::new(true, true); - let mut client = handler.serve(client_transport).await?; - - // Verify client is not closed - assert!(!client.is_closed()); - - // Call close() and verify it returns - let result = client.close().await?; - assert!(matches!(result, QuitReason::Cancelled)); - - // Verify client is now closed - assert!(client.is_closed()); - - // Calling close() again should return Closed immediately - let result = client.close().await?; - assert!(matches!(result, QuitReason::Closed)); - - // Wait for server to finish - server_handle.await??; - Ok(()) -} - -/// Test that close_with_timeout() respects the timeout -#[tokio::test] -async fn test_close_with_timeout() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - // Start server - let server_handle = tokio::spawn(async move { - let server = TestServer::new().serve(server_transport).await?; - server.waiting().await?; - anyhow::Ok(()) - }); - - // Start client - let handler = TestClientHandler::new(true, true); - let mut client = handler.serve(client_transport).await?; - - // Close with a reasonable timeout - let result = client.close_with_timeout(Duration::from_secs(5)).await?; - assert!(result.is_some()); - assert!(matches!(result.unwrap(), QuitReason::Cancelled)); - - // Verify client is now closed - assert!(client.is_closed()); - - // Wait for server to finish - server_handle.await??; - Ok(()) -} - -/// Test that cancel() still works and consumes self -#[tokio::test] -async fn test_cancel_method() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - // Start server - let server_handle = tokio::spawn(async move { - let server = TestServer::new().serve(server_transport).await?; - server.waiting().await?; - anyhow::Ok(()) - }); - - // Start client - let handler = TestClientHandler::new(true, true); - let client = handler.serve(client_transport).await?; - - // Cancel should work as before - let result = client.cancel().await?; - assert!(matches!(result, QuitReason::Cancelled)); - - // Wait for server to finish - server_handle.await??; - Ok(()) -} - -/// Test that dropping without close() logs a debug message (we can't easily test -/// the log output, but we can verify the drop doesn't panic) -#[tokio::test] -async fn test_drop_without_close() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - // Start server that will handle the drop - let server_handle = tokio::spawn(async move { - let server = TestServer::new().serve(server_transport).await?; - // The server should close when the client drops - let result = server.waiting().await?; - // Server should detect closure - assert!(matches!(result, QuitReason::Closed | QuitReason::Cancelled)); - anyhow::Ok(()) - }); - - // Create and immediately drop the client - { - let handler = TestClientHandler::new(true, true); - let _client = handler.serve(client_transport).await?; - // Client dropped here without calling close() - } - - // Give the async cleanup a moment to run - tokio::time::sleep(Duration::from_millis(100)).await; - - // Wait for server to finish (it should detect the closure) - server_handle.await??; - Ok(()) -} diff --git a/vendor/rmcp/tests/test_completion.rs b/vendor/rmcp/tests/test_completion.rs deleted file mode 100644 index b155bd91b..000000000 --- a/vendor/rmcp/tests/test_completion.rs +++ /dev/null @@ -1,208 +0,0 @@ -use std::collections::HashMap; - -use rmcp::model::*; -use serde_json::json; - -#[test] -fn test_completion_context_serialization() { - let mut args = HashMap::new(); - args.insert("key1".to_string(), "value1".to_string()); - args.insert("key2".to_string(), "value2".to_string()); - - let context = CompletionContext::with_arguments(args); - - // Test serialization - let json = serde_json::to_value(&context).unwrap(); - let expected = json!({ - "arguments": { - "key1": "value1", - "key2": "value2" - } - }); - assert_eq!(json, expected); - - // Test deserialization - let deserialized: CompletionContext = serde_json::from_value(expected).unwrap(); - assert_eq!(deserialized, context); -} - -#[test] -fn test_completion_context_methods() { - let mut args = HashMap::new(); - args.insert("city".to_string(), "San Francisco".to_string()); - args.insert("country".to_string(), "USA".to_string()); - - let context = CompletionContext::with_arguments(args); - - assert!(context.has_arguments()); - assert_eq!( - context.get_argument("city"), - Some(&"San Francisco".to_string()) - ); - assert_eq!(context.get_argument("missing"), None); - - let names: Vec<&str> = context.argument_names().collect(); - assert!(names.contains(&"city")); - assert!(names.contains(&"country")); - assert_eq!(names.len(), 2); -} - -#[test] -fn test_complete_request_param_serialization() { - let mut args = HashMap::new(); - args.insert("previous_input".to_string(), "test".to_string()); - - let request = CompleteRequestParams::new( - Reference::for_prompt("weather_prompt"), - ArgumentInfo::new("location", "San"), - ) - .with_context(CompletionContext::with_arguments(args)); - - let json = serde_json::to_value(&request).unwrap(); - assert!(json["ref"]["name"].as_str().unwrap() == "weather_prompt"); - assert!(json["argument"]["name"].as_str().unwrap() == "location"); - assert!(json["argument"]["value"].as_str().unwrap() == "San"); - assert!( - json["context"]["arguments"]["previous_input"] - .as_str() - .unwrap() - == "test" - ); -} - -#[test] -fn test_completion_info_validation() { - // Valid completion with less than max values - let values = vec!["option1".to_string(), "option2".to_string()]; - let completion = CompletionInfo::new(values.clone()).unwrap(); - assert_eq!(completion.values, values); - assert!(completion.validate().is_ok()); - - // Test max values limit - let many_values: Vec = (0..=CompletionInfo::MAX_VALUES) - .map(|i| format!("option_{}", i)) - .collect(); - let result = CompletionInfo::new(many_values); - assert!(result.is_err()); -} - -#[test] -fn test_completion_info_helper_methods() { - let values = vec!["test1".to_string(), "test2".to_string()]; - - // Test with_all_values - let completion = CompletionInfo::with_all_values(values.clone()).unwrap(); - assert_eq!(completion.values, values); - assert_eq!(completion.total, Some(2)); - assert_eq!(completion.has_more, Some(false)); - assert!(!completion.has_more_results()); - assert_eq!(completion.total_available(), Some(2)); - - // Test with_pagination - let paginated = CompletionInfo::with_pagination(values.clone(), Some(10), true).unwrap(); - assert_eq!(paginated.values, values); - assert_eq!(paginated.total, Some(10)); - assert_eq!(paginated.has_more, Some(true)); - assert!(paginated.has_more_results()); - assert_eq!(paginated.total_available(), Some(10)); -} - -#[test] -fn test_completion_info_bounds() { - // Test exactly at the limit - let max_values: Vec = (0..CompletionInfo::MAX_VALUES) - .map(|i| format!("value_{}", i)) - .collect(); - assert!(CompletionInfo::new(max_values).is_ok()); - - // Test over the limit - let over_limit: Vec = (0..=CompletionInfo::MAX_VALUES) - .map(|i| format!("value_{}", i)) - .collect(); - assert!(CompletionInfo::new(over_limit).is_err()); -} - -#[test] -fn test_reference_convenience_methods() { - let prompt_ref = Reference::for_prompt("test_prompt"); - assert_eq!(prompt_ref.reference_type(), "ref/prompt"); - assert_eq!(prompt_ref.as_prompt_name(), Some("test_prompt")); - assert_eq!(prompt_ref.as_resource_uri(), None); - - let resource_ref = Reference::for_resource("file://path/to/resource"); - assert_eq!(resource_ref.reference_type(), "ref/resource"); - assert_eq!( - resource_ref.as_resource_uri(), - Some("file://path/to/resource") - ); - assert_eq!(resource_ref.as_prompt_name(), None); -} - -#[test] -fn test_completion_serialization_format() { - // Test that completion follows MCP 2025-06-18 specification format - let completion = - CompletionInfo::with_all_values(vec!["value1".to_string(), "value2".to_string()]).unwrap(); - - let json = serde_json::to_value(&completion).unwrap(); - - // Verify JSON structure matches specification - assert!(json.is_object()); - assert!(json["values"].is_array()); - assert_eq!(json["values"].as_array().unwrap().len(), 2); - assert_eq!(json["total"].as_u64().unwrap(), 2); - assert!(!json["hasMore"].as_bool().unwrap()); -} - -#[test] -fn test_resource_reference() { - // ResourceTemplateReference가 `ref/resource` 와이어 태그로 직렬화/역직렬화되는지 확인 - let reference = Reference::for_resource("test://uri"); - - let json = serde_json::to_value(&reference).unwrap(); - assert_eq!(json["type"], "ref/resource"); - assert_eq!(json["uri"], "test://uri"); - - let back: Reference = serde_json::from_value(json).unwrap(); - match back { - Reference::Resource(r) => assert_eq!(r.uri, "test://uri"), - other => panic!("expected Reference::Resource, got {other:?}"), - } -} - -#[test] -fn test_complete_result_default() { - let result = CompleteResult::default(); - assert!(result.completion.values.is_empty()); - assert_eq!(result.completion.total, None); - assert_eq!(result.completion.has_more, None); -} - -#[test] -fn test_completion_context_empty() { - let context = CompletionContext::new(); - assert!(!context.has_arguments()); - assert_eq!(context.get_argument("any"), None); - assert!(context.argument_names().count() == 0); -} - -#[test] -fn test_mcp_schema_compliance() { - // Test that our types serialize correctly according to MCP specification - let request = CompleteRequestParams::new( - Reference::for_resource("file://{path}"), - ArgumentInfo::new("path", "src/"), - ); - - let json_str = serde_json::to_string(&request).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); - - // Verify key structure matches MCP spec - assert!(parsed["ref"].is_object()); - assert!(parsed["argument"].is_object()); - assert!(parsed["argument"]["name"].is_string()); - assert!(parsed["argument"]["value"].is_string()); - - // Verify type tag is correct - assert_eq!(parsed["ref"]["type"].as_str().unwrap(), "ref/resource"); -} diff --git a/vendor/rmcp/tests/test_complex_schema.rs b/vendor/rmcp/tests/test_complex_schema.rs deleted file mode 100644 index 9372b0152..000000000 --- a/vendor/rmcp/tests/test_complex_schema.rs +++ /dev/null @@ -1,105 +0,0 @@ -#![allow(clippy::exhaustive_structs, clippy::exhaustive_enums)] - -use rmcp::{ - ErrorData as McpError, handler::server::wrapper::Parameters, model::*, schemars, tool, - tool_router, -}; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -pub enum ChatRole { - System, - User, - Assistant, - Tool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -pub struct ChatMessage { - pub role: ChatRole, - pub content: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -pub struct ChatRequest { - pub system: Option, - pub messages: Vec, -} - -#[derive(Clone, Default)] -pub struct Demo; - -#[tool_router] -impl Demo { - pub fn new() -> Self { - Self - } - - #[tool(description = "LLM")] - async fn chat( - &self, - chat_request: Parameters, - ) -> Result { - let content = ContentBlock::json(chat_request.0)?; - Ok(CallToolResult::success(vec![content])) - } -} - -fn expected_schema() -> serde_json::Value { - serde_json::json!({ - "$defs": { - "ChatMessage": { - "properties": { - "content": { - "type": "string" - }, - "role": { - "$ref": "#/$defs/ChatRole" - } - }, - "required": [ - "role", - "content" - ], - "type": "object" - }, - "ChatRole": { - "enum": [ - "System", - "User", - "Assistant", - "Tool" - ], - "type": "string" - } - }, - "$schema": "https://json-schema.org/draft/2020-12/schema", - "properties": { - "messages": { - "items": { - "$ref": "#/$defs/ChatMessage" - }, - "type": "array" - }, - "system": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "messages" - ], - "type": "object" - }) -} - -#[test] -fn test_complex_schema() { - let attr = Demo::chat_tool_attr(); - let input_schema = attr.input_schema; - let expected = expected_schema(); - let produced = serde_json::Value::Object(input_schema.as_ref().clone()); - assert_eq!(produced, expected, "schema mismatch"); -} diff --git a/vendor/rmcp/tests/test_custom_headers.rs b/vendor/rmcp/tests/test_custom_headers.rs deleted file mode 100644 index 736dce18e..000000000 --- a/vendor/rmcp/tests/test_custom_headers.rs +++ /dev/null @@ -1,1231 +0,0 @@ -#![cfg(not(feature = "local"))] -use std::collections::HashMap; - -use http::{HeaderName, HeaderValue}; - -#[test] -fn test_config_custom_headers_default_empty() { - use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; - - let config = StreamableHttpClientTransportConfig::with_uri("http://localhost:8080"); - assert!( - config.custom_headers.is_empty(), - "Default custom_headers should be empty" - ); -} - -#[test] -fn test_config_custom_headers_builder() { - use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; - - let mut headers = HashMap::new(); - headers.insert( - HeaderName::from_static("x-test-header"), - HeaderValue::from_static("test-value"), - ); - - let config = StreamableHttpClientTransportConfig::with_uri("http://localhost:8080") - .custom_headers(headers); - - assert_eq!(config.custom_headers.len(), 1); - assert_eq!( - config - .custom_headers - .get(&HeaderName::from_static("x-test-header")), - Some(&HeaderValue::from_static("test-value")) - ); -} - -#[test] -fn test_config_custom_headers_multiple_values() { - use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; - - let mut headers = HashMap::new(); - headers.insert( - HeaderName::from_static("x-header-1"), - HeaderValue::from_static("value-1"), - ); - headers.insert( - HeaderName::from_static("x-header-2"), - HeaderValue::from_static("value-2"), - ); - headers.insert( - HeaderName::from_static("authorization"), - HeaderValue::from_static("Bearer token123"), - ); - - let config = StreamableHttpClientTransportConfig::with_uri("http://localhost:8080") - .custom_headers(headers); - - assert_eq!(config.custom_headers.len(), 3); - assert_eq!( - config - .custom_headers - .get(&HeaderName::from_static("x-header-1")), - Some(&HeaderValue::from_static("value-1")) - ); - assert_eq!( - config - .custom_headers - .get(&HeaderName::from_static("x-header-2")), - Some(&HeaderValue::from_static("value-2")) - ); - assert_eq!( - config - .custom_headers - .get(&HeaderName::from_static("authorization")), - Some(&HeaderValue::from_static("Bearer token123")) - ); -} - -#[test] -fn test_config_auth_header_and_custom_headers_together() { - use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; - - let mut headers = HashMap::new(); - headers.insert( - HeaderName::from_static("x-custom-header"), - HeaderValue::from_static("custom-value"), - ); - - let config = StreamableHttpClientTransportConfig::with_uri("http://localhost:8080") - .auth_header("my-bearer-token") - .custom_headers(headers); - - assert_eq!(config.auth_header, Some("my-bearer-token".to_string())); - assert_eq!( - config - .custom_headers - .get(&HeaderName::from_static("x-custom-header")), - Some(&HeaderValue::from_static("custom-value")) - ); -} - -/// Unit test: post_message should reject reserved header "accept" -#[tokio::test] -#[cfg(feature = "transport-streamable-http-client-reqwest")] -async fn test_post_message_rejects_accept_header() { - use std::sync::Arc; - - use rmcp::{ - model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, - transport::streamable_http_client::{StreamableHttpClient, StreamableHttpError}, - }; - - let client = reqwest::Client::new(); - let mut custom_headers = HashMap::new(); - custom_headers.insert( - HeaderName::from_static("accept"), - HeaderValue::from_static("text/html"), - ); - - let message = ClientJsonRpcMessage::request( - ClientRequest::PingRequest(PingRequest::default()), - RequestId::Number(1), - ); - - let result = client - .post_message( - Arc::from("http://localhost:9999/mcp"), - message, - None, - None, - custom_headers, - ) - .await; - - assert!(result.is_err(), "Should reject 'accept' header"); - match result { - Err(StreamableHttpError::ReservedHeaderConflict(header_name)) => { - assert_eq!( - header_name, "accept", - "Error should indicate 'accept' header" - ); - } - other => panic!("Expected ReservedHeaderConflict error, got: {:?}", other), - } -} - -/// Unit test: post_message should reject reserved header "mcp-session-id" -#[tokio::test] -#[cfg(feature = "transport-streamable-http-client-reqwest")] -async fn test_post_message_rejects_mcp_session_id() { - use std::sync::Arc; - - use rmcp::{ - model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, - transport::streamable_http_client::{StreamableHttpClient, StreamableHttpError}, - }; - - let client = reqwest::Client::new(); - let mut custom_headers = HashMap::new(); - custom_headers.insert( - HeaderName::from_static("mcp-session-id"), - HeaderValue::from_static("my-session"), - ); - - let message = ClientJsonRpcMessage::request( - ClientRequest::PingRequest(PingRequest::default()), - RequestId::Number(1), - ); - - let result = client - .post_message( - Arc::from("http://localhost:9999/mcp"), - message, - None, - None, - custom_headers, - ) - .await; - - assert!(result.is_err(), "Should reject 'mcp-session-id' header"); - match result { - Err(StreamableHttpError::ReservedHeaderConflict(header_name)) => { - assert_eq!( - header_name, "mcp-session-id", - "Error should indicate 'mcp-session-id' header" - ); - } - other => panic!("Expected ReservedHeaderConflict error, got: {:?}", other), - } -} - -/// Unit test: post_message should allow the mcp-protocol-version header through -/// (it is injected by the worker after initialization, not a user-settable custom header) -#[tokio::test] -#[cfg(feature = "transport-streamable-http-client-reqwest")] -async fn test_post_message_allows_mcp_protocol_version() { - use std::sync::Arc; - - use rmcp::{ - model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, - transport::streamable_http_client::StreamableHttpClient, - }; - - let client = reqwest::Client::new(); - let mut custom_headers = HashMap::new(); - custom_headers.insert( - HeaderName::from_static("mcp-protocol-version"), - HeaderValue::from_static("2025-03-26"), - ); - - let message = ClientJsonRpcMessage::request( - ClientRequest::PingRequest(PingRequest::default()), - RequestId::Number(1), - ); - - let result = client - .post_message( - Arc::from("http://localhost:9999/mcp"), - message, - None, - None, - custom_headers, - ) - .await; - - // The header should be allowed through (not rejected as reserved). - // The error should be a connection error (no server at localhost:9999), - // not a ReservedHeaderConflict. - assert!(result.is_err(), "Should fail due to connection error"); - assert!( - !matches!( - &result, - Err(rmcp::transport::streamable_http_client::StreamableHttpError::ReservedHeaderConflict( - _ - )) - ), - "MCP-Protocol-Version should not be rejected as reserved, got: {:?}", - result - ); -} - -/// Unit test: post_message should reject reserved header "last-event-id" -#[tokio::test] -#[cfg(feature = "transport-streamable-http-client-reqwest")] -async fn test_post_message_rejects_last_event_id() { - use std::sync::Arc; - - use rmcp::{ - model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, - transport::streamable_http_client::{StreamableHttpClient, StreamableHttpError}, - }; - - let client = reqwest::Client::new(); - let mut custom_headers = HashMap::new(); - custom_headers.insert( - HeaderName::from_static("last-event-id"), - HeaderValue::from_static("event-123"), - ); - - let message = ClientJsonRpcMessage::request( - ClientRequest::PingRequest(PingRequest::default()), - RequestId::Number(1), - ); - - let result = client - .post_message( - Arc::from("http://localhost:9999/mcp"), - message, - None, - None, - custom_headers, - ) - .await; - - assert!(result.is_err(), "Should reject 'last-event-id' header"); - match result { - Err(StreamableHttpError::ReservedHeaderConflict(header_name)) => { - assert_eq!( - header_name, "last-event-id", - "Error should indicate 'last-event-id' header" - ); - } - other => panic!("Expected ReservedHeaderConflict error, got: {:?}", other), - } -} - -/// Unit test: post_message should do case-insensitive matching for reserved headers -#[tokio::test] -#[cfg(feature = "transport-streamable-http-client-reqwest")] -async fn test_post_message_case_insensitive_matching() { - use std::sync::Arc; - - use rmcp::{ - model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, - transport::streamable_http_client::{StreamableHttpClient, StreamableHttpError}, - }; - - let client = reqwest::Client::new(); - let message = ClientJsonRpcMessage::request( - ClientRequest::PingRequest(PingRequest::default()), - RequestId::Number(1), - ); - - // Test different casings - let test_cases = vec![ - ("Accept", "Should reject 'Accept' (capitalized)"), - ("ACCEPT", "Should reject 'ACCEPT' (uppercase)"), - ("Mcp-Session-Id", "Should reject 'Mcp-Session-Id'"), - ("MCP-SESSION-ID", "Should reject 'MCP-SESSION-ID'"), - ]; - - for (header_name, error_msg) in test_cases { - let mut custom_headers = HashMap::new(); - custom_headers.insert( - HeaderName::from_bytes(header_name.as_bytes()).unwrap(), - HeaderValue::from_static("value"), - ); - - let result = client - .post_message( - Arc::from("http://localhost:9999/mcp"), - message.clone(), - None, - None, - custom_headers, - ) - .await; - - assert!(result.is_err(), "{}", error_msg); - if let Err(StreamableHttpError::ReservedHeaderConflict(_)) = result { - // Success - } else { - panic!( - "{}: Expected ReservedHeaderConflict, got: {:?}", - error_msg, result - ); - } - } -} - -/// Integration test: Verify that custom headers are actually sent in MCP HTTP requests -#[tokio::test] -#[cfg(all( - feature = "transport-streamable-http-client", - feature = "transport-streamable-http-client-reqwest" -))] -async fn test_mcp_custom_headers_sent_to_server() -> anyhow::Result<()> { - use std::{net::SocketAddr, sync::Arc}; - - use axum::{ - Router, body::Bytes, extract::State, http::StatusCode, response::IntoResponse, - routing::post, - }; - use rmcp::{ - ServiceExt, - transport::{ - StreamableHttpClientTransport, - streamable_http_client::StreamableHttpClientTransportConfig, - }, - }; - use serde_json::json; - use tokio::sync::Mutex; - - // State to capture received headers - #[derive(Clone)] - struct ServerState { - received_headers: Arc>>, - initialize_called: Arc, - } - - // Handler that captures headers from MCP requests - async fn mcp_handler( - State(state): State, - headers: http::HeaderMap, - body: Bytes, - ) -> impl IntoResponse { - // Capture all custom headers (starting with x-) - let mut headers_map = HashMap::new(); - for (name, value) in headers.iter() { - let name_str = name.as_str(); - if name_str.starts_with("x-") { - if let Ok(v) = value.to_str() { - headers_map.insert(name_str.to_string(), v.to_string()); - } - } - } - - // Store captured headers - let mut stored = state.received_headers.lock().await; - stored.extend(headers_map); - - // Parse the MCP request - if let Ok(json_body) = serde_json::from_slice::(&body) { - if let Some(method) = json_body.get("method").and_then(|m| m.as_str()) { - if method == "initialize" { - state.initialize_called.notify_one(); - // Return a valid MCP initialize response with session header - let response = json!({ - "jsonrpc": "2.0", - "id": json_body.get("id"), - "result": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "serverInfo": { - "name": "test-server", - "version": "1.0.0" - } - } - }); - return ( - StatusCode::OK, - [ - (http::header::CONTENT_TYPE, "application/json"), - ( - http::HeaderName::from_static("mcp-session-id"), - "test-session-123", - ), - ], - response.to_string(), - ); - } else if method == "notifications/initialized" { - // For initialized notification, return 202 Accepted - return ( - StatusCode::ACCEPTED, - [ - (http::header::CONTENT_TYPE, "application/json"), - ( - http::HeaderName::from_static("mcp-session-id"), - "test-session-123", - ), - ], - String::new(), - ); - } - } - } - - // Default response for other requests - let response = json!({ - "jsonrpc": "2.0", - "id": 1, - "result": {} - }); - ( - StatusCode::OK, - [ - (http::header::CONTENT_TYPE, "application/json"), - ( - http::HeaderName::from_static("mcp-session-id"), - "test-session-123", - ), - ], - response.to_string(), - ) - } - - // Setup test server - let state = ServerState { - received_headers: Arc::new(Mutex::new(HashMap::new())), - initialize_called: Arc::new(tokio::sync::Notify::new()), - }; - - let app = Router::new() - .route("/mcp", post(mcp_handler)) - .with_state(state.clone()); - - let addr = SocketAddr::from(([127, 0, 0, 1], 0)); - let listener = tokio::net::TcpListener::bind(addr).await?; - let port = listener.local_addr()?.port(); - - let server_handle = tokio::spawn(async move { axum::serve(listener, app).await }); - - // Wait for server to be ready - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - - // Create MCP client with custom headers - let mut custom_headers = HashMap::new(); - custom_headers.insert( - HeaderName::from_static("x-test-header"), - HeaderValue::from_static("test-value-123"), - ); - custom_headers.insert( - HeaderName::from_static("x-another-header"), - HeaderValue::from_static("another-value-456"), - ); - custom_headers.insert( - HeaderName::from_static("x-client-id"), - HeaderValue::from_static("test-client"), - ); - - let config = - StreamableHttpClientTransportConfig::with_uri(format!("http://127.0.0.1:{}/mcp", port)) - .custom_headers(custom_headers); - - let transport = StreamableHttpClientTransport::from_config(config); - - // Start MCP client with empty handler (this will trigger initialize request) - let client = ().serve(transport).await.expect("Failed to start client"); - - // Wait for initialize to be called - tokio::time::timeout( - std::time::Duration::from_secs(5), - state.initialize_called.notified(), - ) - .await - .expect("Initialize request should be received"); - - // Verify that custom headers were received - let headers = state.received_headers.lock().await; - - assert_eq!( - headers.get("x-test-header"), - Some(&"test-value-123".to_string()), - "Custom header x-test-header should be sent to MCP server" - ); - assert_eq!( - headers.get("x-another-header"), - Some(&"another-value-456".to_string()), - "Custom header x-another-header should be sent to MCP server" - ); - assert_eq!( - headers.get("x-client-id"), - Some(&"test-client".to_string()), - "Custom header x-client-id should be sent to MCP server" - ); - - // Cleanup - drop(client); - server_handle.abort(); - - Ok(()) -} - -/// Integration test: Verify that MCP-Protocol-Version header is sent on post-init requests -#[tokio::test] -#[cfg(all( - feature = "transport-streamable-http-client", - feature = "transport-streamable-http-client-reqwest" -))] -async fn test_mcp_protocol_version_header_sent_after_init() -> anyhow::Result<()> { - use std::{net::SocketAddr, sync::Arc}; - - use axum::{ - Router, body::Bytes, extract::State, http::StatusCode, response::IntoResponse, - routing::post, - }; - use rmcp::{ - ServiceExt, - transport::{ - StreamableHttpClientTransport, - streamable_http_client::StreamableHttpClientTransportConfig, - }, - }; - use serde_json::json; - use tokio::sync::Mutex; - - type CapturedRequests = Vec<(String, Option)>; - - #[derive(Clone)] - struct ServerState { - /// Captures the MCP-Protocol-Version header value for each request method - protocol_version_by_method: Arc>, - initialized_called: Arc, - } - - async fn mcp_handler( - State(state): State, - headers: http::HeaderMap, - body: Bytes, - ) -> impl IntoResponse { - let protocol_version = headers - .get("mcp-protocol-version") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - - if let Ok(json_body) = serde_json::from_slice::(&body) { - let method = json_body - .get("method") - .and_then(|m| m.as_str()) - .unwrap_or("unknown") - .to_string(); - - state - .protocol_version_by_method - .lock() - .await - .push((method.clone(), protocol_version)); - - if method == "initialize" { - let response = json!({ - "jsonrpc": "2.0", - "id": json_body.get("id"), - "result": { - "protocolVersion": "2025-03-26", - "capabilities": {}, - "serverInfo": { - "name": "test-server", - "version": "1.0.0" - } - } - }); - return ( - StatusCode::OK, - [ - (http::header::CONTENT_TYPE, "application/json"), - ( - http::HeaderName::from_static("mcp-session-id"), - "test-session-456", - ), - ], - response.to_string(), - ); - } else if method == "notifications/initialized" { - state.initialized_called.notify_one(); - return ( - StatusCode::ACCEPTED, - [ - (http::header::CONTENT_TYPE, "application/json"), - ( - http::HeaderName::from_static("mcp-session-id"), - "test-session-456", - ), - ], - String::new(), - ); - } - } - - let response = json!({ - "jsonrpc": "2.0", - "id": 1, - "result": {} - }); - ( - StatusCode::OK, - [ - (http::header::CONTENT_TYPE, "application/json"), - ( - http::HeaderName::from_static("mcp-session-id"), - "test-session-456", - ), - ], - response.to_string(), - ) - } - - let state = ServerState { - protocol_version_by_method: Arc::new(Mutex::new(Vec::new())), - initialized_called: Arc::new(tokio::sync::Notify::new()), - }; - - let app = Router::new() - .route("/mcp", post(mcp_handler)) - .with_state(state.clone()); - - let addr = SocketAddr::from(([127, 0, 0, 1], 0)); - let listener = tokio::net::TcpListener::bind(addr).await?; - let port = listener.local_addr()?.port(); - - let server_handle = tokio::spawn(async move { axum::serve(listener, app).await }); - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - - let config = - StreamableHttpClientTransportConfig::with_uri(format!("http://127.0.0.1:{}/mcp", port)); - - let transport = StreamableHttpClientTransport::from_config(config); - let client = ().serve(transport).await.expect("Failed to start client"); - - tokio::time::timeout( - std::time::Duration::from_secs(5), - state.initialized_called.notified(), - ) - .await - .expect("Initialized notification should be received"); - - // Give time for the initialized notification to be fully processed - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - - let captured = state.protocol_version_by_method.lock().await; - - // The initialize request should NOT have MCP-Protocol-Version - // (the version isn't known yet) - let init_entry = captured - .iter() - .find(|(m, _)| m == "initialize") - .expect("Should have captured initialize request"); - assert_eq!( - init_entry.1, None, - "Initialize request should not have MCP-Protocol-Version header" - ); - - // The initialized notification should HAVE MCP-Protocol-Version - let initialized_entry = captured - .iter() - .find(|(m, _)| m == "notifications/initialized") - .expect("Should have captured initialized notification"); - assert_eq!( - initialized_entry.1, - Some("2025-03-26".to_string()), - "Initialized notification should include MCP-Protocol-Version: 2025-03-26" - ); - - drop(client); - server_handle.abort(); - - Ok(()) -} - -/// Integration test: Verify server rejects unsupported MCP-Protocol-Version with 400 -#[tokio::test] -#[cfg(all(feature = "transport-streamable-http-server", feature = "server",))] -async fn test_server_rejects_unsupported_protocol_version() { - use std::sync::Arc; - - use bytes::Bytes; - use http::{Method, Request, header::CONTENT_TYPE}; - use http_body_util::Full; - use rmcp::{ - handler::server::ServerHandler, - model::{ServerCapabilities, ServerInfo}, - transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, - }, - }; - use serde_json::json; - - #[derive(Clone)] - struct TestHandler; - - impl ServerHandler for TestHandler { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().build()) - } - } - - let session_manager = Arc::new(LocalSessionManager::default()); - let service = StreamableHttpService::new( - || Ok(TestHandler), - session_manager, - StreamableHttpServerConfig::default(), - ); - - // First, send an initialize request to create a session - let init_body = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-03-26", - "capabilities": {}, - "clientInfo": { - "name": "test-client", - "version": "1.0.0" - } - } - }); - - let init_request = Request::builder() - .method(Method::POST) - .header("Accept", "application/json, text/event-stream") - .header(CONTENT_TYPE, "application/json") - .header("Host", "localhost:8080") - .body(Full::new(Bytes::from(init_body.to_string()))) - .unwrap(); - - let response = service.handle(init_request).await; - assert_eq!(response.status(), http::StatusCode::OK); - - // Extract session id from response - let session_id = response - .headers() - .get("mcp-session-id") - .expect("Should have session id") - .to_str() - .unwrap() - .to_string(); - - // Send initialized notification to complete handshake - let initialized_body = json!({ - "jsonrpc": "2.0", - "method": "notifications/initialized" - }); - let initialized_request = Request::builder() - .method(Method::POST) - .header("Accept", "application/json, text/event-stream") - .header(CONTENT_TYPE, "application/json") - .header("Host", "localhost:8080") - .header("mcp-session-id", &session_id) - .header("mcp-protocol-version", "2025-03-26") - .body(Full::new(Bytes::from(initialized_body.to_string()))) - .unwrap(); - - let response = service.handle(initialized_request).await; - assert_eq!(response.status(), http::StatusCode::ACCEPTED); - - // Test 1: Valid protocol version should succeed - let valid_body = json!({ - "jsonrpc": "2.0", - "method": "notifications/initialized" - }); - let valid_request = Request::builder() - .method(Method::POST) - .header("Accept", "application/json, text/event-stream") - .header(CONTENT_TYPE, "application/json") - .header("Host", "localhost:8080") - .header("mcp-session-id", &session_id) - .header("mcp-protocol-version", "2025-03-26") - .body(Full::new(Bytes::from(valid_body.to_string()))) - .unwrap(); - - let response = service.handle(valid_request).await; - assert_eq!( - response.status(), - http::StatusCode::ACCEPTED, - "Valid MCP-Protocol-Version should be accepted" - ); - - // Test 2: Unsupported protocol version should return 400 - let invalid_body = json!({ - "jsonrpc": "2.0", - "method": "notifications/initialized" - }); - let invalid_request = Request::builder() - .method(Method::POST) - .header("Accept", "application/json, text/event-stream") - .header(CONTENT_TYPE, "application/json") - .header("Host", "localhost:8080") - .header("mcp-session-id", &session_id) - .header("mcp-protocol-version", "9999-01-01") - .body(Full::new(Bytes::from(invalid_body.to_string()))) - .unwrap(); - - let response = service.handle(invalid_request).await; - assert_eq!( - response.status(), - http::StatusCode::BAD_REQUEST, - "Unsupported MCP-Protocol-Version should return 400" - ); - - // Test 3: Missing protocol version should succeed (backwards compat) - let no_version_body = json!({ - "jsonrpc": "2.0", - "method": "notifications/initialized" - }); - let no_version_request = Request::builder() - .method(Method::POST) - .header("Accept", "application/json, text/event-stream") - .header(CONTENT_TYPE, "application/json") - .header("Host", "localhost:8080") - .header("mcp-session-id", &session_id) - .body(Full::new(Bytes::from(no_version_body.to_string()))) - .unwrap(); - - let response = service.handle(no_version_request).await; - assert_eq!( - response.status(), - http::StatusCode::ACCEPTED, - "Missing MCP-Protocol-Version should be accepted (backwards compat)" - ); -} - -/// Unit test: ProtocolVersion::as_str and KNOWN_VERSIONS -#[test] -fn test_protocol_version_utilities() { - use rmcp::model::ProtocolVersion; - - assert_eq!(ProtocolVersion::V_2026_07_28.as_str(), "2026-07-28"); - assert_eq!(ProtocolVersion::V_2025_11_25.as_str(), "2025-11-25"); - assert_eq!(ProtocolVersion::V_2025_06_18.as_str(), "2025-06-18"); - assert_eq!(ProtocolVersion::V_2025_03_26.as_str(), "2025-03-26"); - assert_eq!(ProtocolVersion::V_2024_11_05.as_str(), "2024-11-05"); - - assert_eq!(ProtocolVersion::KNOWN_VERSIONS.len(), 5); - assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2024_11_05)); - assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_03_26)); - assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_06_18)); - assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_11_25)); - assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2026_07_28)); -} - -/// Integration test: Verify server validates only the Host header for DNS rebinding protection -#[tokio::test] -#[cfg(all(feature = "transport-streamable-http-server", feature = "server",))] -async fn test_server_validates_host_header_for_dns_rebinding_protection() { - use std::sync::Arc; - - use bytes::Bytes; - use http::{Method, Request, header::CONTENT_TYPE}; - use http_body_util::Full; - use rmcp::{ - handler::server::ServerHandler, - model::{ServerCapabilities, ServerInfo}, - transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, - }, - }; - use serde_json::json; - - #[derive(Clone)] - struct TestHandler; - - impl ServerHandler for TestHandler { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().build()) - } - } - - let service = StreamableHttpService::new( - || Ok(TestHandler), - Arc::new(LocalSessionManager::default()), - StreamableHttpServerConfig::default(), - ); - - let init_body = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-03-26", - "capabilities": {}, - "clientInfo": { - "name": "test-client", - "version": "1.0.0" - } - } - }); - - let allowed_request = Request::builder() - .method(Method::POST) - .header("Accept", "application/json, text/event-stream") - .header(CONTENT_TYPE, "application/json") - .header("Host", "localhost:8080") - .header("Origin", "http://localhost:8080") - .body(Full::new(Bytes::from(init_body.to_string()))) - .unwrap(); - - let response = service.handle(allowed_request).await; - assert_eq!(response.status(), http::StatusCode::OK); - - let bad_host_request = Request::builder() - .method(Method::POST) - .header("Accept", "application/json, text/event-stream") - .header(CONTENT_TYPE, "application/json") - .header("Host", "attacker.example") - .body(Full::new(Bytes::from(init_body.to_string()))) - .unwrap(); - - let response = service.handle(bad_host_request).await; - assert_eq!(response.status(), http::StatusCode::FORBIDDEN); - - let ignored_origin_request = Request::builder() - .method(Method::POST) - .header("Accept", "application/json, text/event-stream") - .header(CONTENT_TYPE, "application/json") - .header("Host", "localhost:8080") - .header("Origin", "http://attacker.example") - .body(Full::new(Bytes::from(init_body.to_string()))) - .unwrap(); - - let response = service.handle(ignored_origin_request).await; - assert_eq!(response.status(), http::StatusCode::OK); -} - -/// Integration test: Verify server can enforce an allowed Host port when configured -#[tokio::test] -#[cfg(all(feature = "transport-streamable-http-server", feature = "server",))] -async fn test_server_validates_host_header_port_for_dns_rebinding_protection() { - use std::sync::Arc; - - use bytes::Bytes; - use http::{Method, Request, header::CONTENT_TYPE}; - use http_body_util::Full; - use rmcp::{ - handler::server::ServerHandler, - model::{ServerCapabilities, ServerInfo}, - transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, - }, - }; - use serde_json::json; - - #[derive(Clone)] - struct TestHandler; - - impl ServerHandler for TestHandler { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().build()) - } - } - - let service = StreamableHttpService::new( - || Ok(TestHandler), - Arc::new(LocalSessionManager::default()), - StreamableHttpServerConfig::default().with_allowed_hosts(["localhost:8080"]), - ); - - let init_body = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-03-26", - "capabilities": {}, - "clientInfo": { - "name": "test-client", - "version": "1.0.0" - } - } - }); - - let allowed_request = Request::builder() - .method(Method::POST) - .header("Accept", "application/json, text/event-stream") - .header(CONTENT_TYPE, "application/json") - .header("Host", "localhost:8080") - .body(Full::new(Bytes::from(init_body.to_string()))) - .unwrap(); - - let response = service.handle(allowed_request).await; - assert_eq!(response.status(), http::StatusCode::OK); - - let wrong_port_request = Request::builder() - .method(Method::POST) - .header("Accept", "application/json, text/event-stream") - .header(CONTENT_TYPE, "application/json") - .header("Host", "localhost:3000") - .body(Full::new(Bytes::from(init_body.to_string()))) - .unwrap(); - - let response = service.handle(wrong_port_request).await; - assert_eq!(response.status(), http::StatusCode::FORBIDDEN); -} - -/// Integration test: Verify the validator falls back to the URI authority when -/// the Host header is absent (HTTP/2 :authority pseudo-header scenario). -#[tokio::test] -#[cfg(all(feature = "transport-streamable-http-server", feature = "server",))] -async fn test_server_falls_back_to_uri_authority_when_host_header_missing() { - use std::sync::Arc; - - use bytes::Bytes; - use http::{Method, Request, header::CONTENT_TYPE}; - use http_body_util::Full; - use rmcp::{ - handler::server::ServerHandler, - model::{ServerCapabilities, ServerInfo}, - transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, - }, - }; - use serde_json::json; - - #[derive(Clone)] - struct TestHandler; - - impl ServerHandler for TestHandler { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().build()) - } - } - - let service = StreamableHttpService::new( - || Ok(TestHandler), - Arc::new(LocalSessionManager::default()), - StreamableHttpServerConfig::default(), - ); - - let init_body = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-03-26", - "capabilities": {}, - "clientInfo": { - "name": "test-client", - "version": "1.0.0" - } - } - }); - - // Allowed authority via URI only — no Host header. - let allowed_request = Request::builder() - .method(Method::POST) - .uri("http://localhost:8080/") - .header("Accept", "application/json, text/event-stream") - .header(CONTENT_TYPE, "application/json") - .body(Full::new(Bytes::from(init_body.to_string()))) - .unwrap(); - assert!(allowed_request.headers().get("Host").is_none()); - - let response = service.handle(allowed_request).await; - assert_eq!(response.status(), http::StatusCode::OK); - - // Disallowed authority via URI only — no Host header. - let bad_request = Request::builder() - .method(Method::POST) - .uri("http://attacker.example/") - .header("Accept", "application/json, text/event-stream") - .header(CONTENT_TYPE, "application/json") - .body(Full::new(Bytes::from(init_body.to_string()))) - .unwrap(); - assert!(bad_request.headers().get("Host").is_none()); - - let response = service.handle(bad_request).await; - assert_eq!(response.status(), http::StatusCode::FORBIDDEN); - - // Neither Host header nor URI authority — still a 400. - let missing_request = Request::builder() - .method(Method::POST) - .uri("/") - .header("Accept", "application/json, text/event-stream") - .header(CONTENT_TYPE, "application/json") - .body(Full::new(Bytes::from(init_body.to_string()))) - .unwrap(); - assert!(missing_request.headers().get("Host").is_none()); - assert!(missing_request.uri().authority().is_none()); - - let response = service.handle(missing_request).await; - assert_eq!(response.status(), http::StatusCode::BAD_REQUEST); -} - -#[cfg(all(feature = "transport-streamable-http-server", feature = "server"))] -mod origin_validation { - use std::sync::Arc; - - use bytes::Bytes; - use http::{Method, Request, header::CONTENT_TYPE}; - use http_body_util::Full; - use rmcp::{ - handler::server::ServerHandler, - model::{ServerCapabilities, ServerInfo}, - transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, - }, - }; - use serde_json::json; - - #[derive(Clone)] - struct TestHandler; - - impl ServerHandler for TestHandler { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().build()) - } - } - - fn service_with_allowed_origins( - origins: &[&str], - ) -> StreamableHttpService { - StreamableHttpService::new( - || Ok(TestHandler), - Arc::new(LocalSessionManager::default()), - StreamableHttpServerConfig::default().with_allowed_origins(origins.iter().copied()), - ) - } - - fn init_request(origin: Option<&str>) -> Request> { - let init_body = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-03-26", - "capabilities": {}, - "clientInfo": {"name": "test-client", "version": "1.0.0"} - } - }); - let mut builder = Request::builder() - .method(Method::POST) - .header("Accept", "application/json, text/event-stream") - .header(CONTENT_TYPE, "application/json") - .header("Host", "localhost:8080"); - if let Some(origin) = origin { - builder = builder.header("Origin", origin); - } - builder - .body(Full::new(Bytes::from(init_body.to_string()))) - .unwrap() - } - - #[tokio::test] - async fn allowlisted_origin_is_allowed() { - let service = service_with_allowed_origins(&["http://localhost:8080"]); - let response = service - .handle(init_request(Some("http://localhost:8080"))) - .await; - assert_eq!(response.status(), http::StatusCode::OK); - } - - #[tokio::test] - async fn non_allowlisted_origin_is_forbidden() { - let service = service_with_allowed_origins(&["http://localhost:8080"]); - let response = service - .handle(init_request(Some("http://attacker.example"))) - .await; - assert_eq!(response.status(), http::StatusCode::FORBIDDEN); - } - - #[tokio::test] - async fn missing_origin_passes_through() { - let service = service_with_allowed_origins(&["http://localhost:8080"]); - let response = service.handle(init_request(None)).await; - assert_eq!(response.status(), http::StatusCode::OK); - } - - #[tokio::test] - async fn scheme_mismatch_is_forbidden() { - let service = service_with_allowed_origins(&["http://localhost:8080"]); - let response = service - .handle(init_request(Some("https://localhost:8080"))) - .await; - assert_eq!(response.status(), http::StatusCode::FORBIDDEN); - } - - #[tokio::test] - async fn null_origin_is_allowed_when_allowlisted() { - let service = service_with_allowed_origins(&["null"]); - let response = service.handle(init_request(Some("null"))).await; - assert_eq!(response.status(), http::StatusCode::OK); - } - - #[tokio::test] - async fn null_origin_is_forbidden_when_not_allowlisted() { - let service = service_with_allowed_origins(&["http://localhost:8080"]); - let response = service.handle(init_request(Some("null"))).await; - assert_eq!(response.status(), http::StatusCode::FORBIDDEN); - } -} diff --git a/vendor/rmcp/tests/test_custom_request.rs b/vendor/rmcp/tests/test_custom_request.rs deleted file mode 100644 index 66ee1ff99..000000000 --- a/vendor/rmcp/tests/test_custom_request.rs +++ /dev/null @@ -1,190 +0,0 @@ -#![cfg(not(feature = "local"))] -use std::sync::Arc; - -use rmcp::{ - ClientHandler, ServerHandler, ServiceExt, - model::{ - ClientRequest, ClientResult, CustomRequest, CustomResult, ServerRequest, ServerResult, - }, -}; -use serde_json::json; -use tokio::sync::{Mutex, Notify}; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -type CustomRequestPayload = (String, Option); - -struct CustomRequestServer { - receive_signal: Arc, - payload: Arc>>, -} - -impl ServerHandler for CustomRequestServer { - async fn on_custom_request( - &self, - request: CustomRequest, - _context: rmcp::service::RequestContext, - ) -> Result { - let CustomRequest { method, params, .. } = request; - *self.payload.lock().await = Some((method, params)); - self.receive_signal.notify_one(); - Ok(CustomResult::new(json!({ "status": "ok" }))) - } -} - -#[tokio::test] -async fn test_custom_client_request_reaches_server() -> anyhow::Result<()> { - let _ = tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "debug".to_string().into()), - ) - .with(tracing_subscriber::fmt::layer()) - .try_init(); - - let (server_transport, client_transport) = tokio::io::duplex(4096); - let receive_signal = Arc::new(Notify::new()); - let payload = Arc::new(Mutex::new(None)); - - { - let receive_signal = receive_signal.clone(); - let payload = payload.clone(); - tokio::spawn(async move { - let server = CustomRequestServer { - receive_signal, - payload, - } - .serve(server_transport) - .await?; - server.waiting().await?; - anyhow::Ok(()) - }); - } - - let client = ().serve(client_transport).await?; - - let response = client - .send_request(ClientRequest::CustomRequest(CustomRequest::new( - "requests/custom-test", - Some(json!({ "foo": "bar" })), - ))) - .await?; - - tokio::time::timeout(std::time::Duration::from_secs(5), receive_signal.notified()).await?; - - let (method, params) = payload.lock().await.take().expect("payload set"); - assert_eq!("requests/custom-test", method); - assert_eq!(Some(json!({ "foo": "bar" })), params); - - match response { - ServerResult::CustomResult(result) => { - assert_eq!(result.0, json!({ "status": "ok" })); - } - other => panic!("Expected custom result, got: {other:?}"), - } - - client.cancel().await?; - Ok(()) -} - -struct CustomRequestClient { - receive_signal: Arc, - payload: Arc>>, -} - -impl ClientHandler for CustomRequestClient { - async fn on_custom_request( - &self, - request: CustomRequest, - _context: rmcp::service::RequestContext, - ) -> Result { - let CustomRequest { method, params, .. } = request; - *self.payload.lock().await = Some((method, params)); - self.receive_signal.notify_one(); - Ok(CustomResult::new(json!({ "status": "ok" }))) - } -} - -struct CustomRequestServerNotifier { - receive_signal: Arc, - response: Arc>>>, -} - -impl ServerHandler for CustomRequestServerNotifier { - async fn on_initialized(&self, context: rmcp::service::NotificationContext) { - let peer = context.peer.clone(); - let receive_signal = self.receive_signal.clone(); - let response = self.response.clone(); - tokio::spawn(async move { - let result = peer - .send_request(ServerRequest::CustomRequest(CustomRequest::new( - "requests/custom-server", - Some(json!({ "ping": "pong" })), - ))) - .await; - let payload = match result { - Ok(ClientResult::CustomResult(result)) => Ok(result.0), - Ok(other) => Err(format!("Unexpected response: {other:?}")), - Err(err) => Err(format!("Failed to send request: {err:?}")), - }; - *response.lock().await = Some(payload); - receive_signal.notify_one(); - }); - } -} - -#[tokio::test] -async fn test_custom_server_request_reaches_client() -> anyhow::Result<()> { - let _ = tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "debug".to_string().into()), - ) - .with(tracing_subscriber::fmt::layer()) - .try_init(); - - let (server_transport, client_transport) = tokio::io::duplex(4096); - let response_signal = Arc::new(Notify::new()); - let response = Arc::new(Mutex::new(None)); - tokio::spawn({ - let response_signal = response_signal.clone(); - let response = response.clone(); - async move { - let server = CustomRequestServerNotifier { - receive_signal: response_signal, - response, - } - .serve(server_transport) - .await?; - server.waiting().await?; - anyhow::Ok(()) - } - }); - - let receive_signal = Arc::new(Notify::new()); - let payload = Arc::new(Mutex::new(None)); - - let client = CustomRequestClient { - receive_signal: receive_signal.clone(), - payload: payload.clone(), - } - .serve(client_transport) - .await?; - - tokio::time::timeout(std::time::Duration::from_secs(5), receive_signal.notified()).await?; - tokio::time::timeout( - std::time::Duration::from_secs(5), - response_signal.notified(), - ) - .await?; - - let (method, params) = payload.lock().await.take().expect("payload set"); - assert_eq!("requests/custom-server", method); - assert_eq!(Some(json!({ "ping": "pong" })), params); - - let response = response.lock().await.take().expect("response set"); - let response = response.expect("custom request response ok"); - assert_eq!(response, json!({ "status": "ok" })); - - client.cancel().await?; - Ok(()) -} diff --git a/vendor/rmcp/tests/test_deserialization.rs b/vendor/rmcp/tests/test_deserialization.rs deleted file mode 100644 index 857077015..000000000 --- a/vendor/rmcp/tests/test_deserialization.rs +++ /dev/null @@ -1,238 +0,0 @@ -use rmcp::model::{JsonRpcResponse, ServerJsonRpcMessage, ServerResult}; -#[test] -fn test_tool_list_result() { - let json = std::fs::read("tests/test_deserialization/tool_list_result.json").unwrap(); - let result: ServerJsonRpcMessage = serde_json::from_slice(&json).unwrap(); - println!("{result:#?}"); - - assert!(matches!( - result, - ServerJsonRpcMessage::Response(JsonRpcResponse { - result: ServerResult::ListToolsResult(_), - .. - }) - )); -} - -/// Regression tests for `#[serde(untagged)]` deserialization of `ServerResult`. -/// -/// `ServerResult` is an untagged enum, so serde tries each variant in declaration -/// order, with `CustomResult(Value)` acting as the catch-all. If variant ordering -/// changes, these tests will catch the regression. -mod untagged_server_result { - use rmcp::model::{CallToolResult, JsonRpcResponse, ServerJsonRpcMessage, ServerResult}; - use serde_json::json; - - /// Helper: wrap a result value in a JSON-RPC response envelope. - fn wrap_response(result: serde_json::Value) -> serde_json::Value { - json!({ - "jsonrpc": "2.0", - "id": 1, - "result": result - }) - } - - /// Parse a JSON-RPC response and return the inner `ServerResult`. - fn parse_result(json: serde_json::Value) -> ServerResult { - let msg: ServerJsonRpcMessage = serde_json::from_value(json).unwrap(); - match msg { - ServerJsonRpcMessage::Response(JsonRpcResponse { result, .. }) => result, - other => panic!("expected Response, got {other:?}"), - } - } - - #[test] - fn initialize_result_deserializes_to_correct_variant() { - let result = parse_result(wrap_response(json!({ - "protocolVersion": "2025-03-26", - "capabilities": {}, - "serverInfo": { - "name": "test-server", - "version": "1.0.0" - } - }))); - assert!( - matches!(result, ServerResult::InitializeResult(_)), - "expected InitializeResult, got {result:?}" - ); - } - - #[test] - fn call_tool_result_deserializes_to_correct_variant() { - let result = parse_result(wrap_response(json!({ - "content": [ - { "type": "text", "text": "hello" } - ] - }))); - assert!( - matches!(result, ServerResult::CallToolResult(_)), - "expected CallToolResult, got {result:?}" - ); - } - - #[test] - fn input_required_result_with_meta_deserializes_to_correct_variant() { - let result = parse_result(wrap_response(json!({ - "resultType": "input_required", - "inputRequests": { - "username": { - "method": "elicitation/create", - "params": { - "message": "Please provide your username", - "requestedSchema": { - "type": "object", - "properties": { - "username": { "type": "string" } - }, - "required": ["username"] - } - } - } - }, - "requestState": "opaque-state", - "_meta": { - "io.modelcontextprotocol/serverInfo": { - "name": "test-server", - "version": "1.0.0" - } - } - }))); - - let ServerResult::InputRequiredResult(result) = result else { - panic!("expected InputRequiredResult, got {result:?}"); - }; - assert!( - result.input_requests.is_some_and(|requests| { - requests.len() == 1 && requests.contains_key("username") - }) - ); - assert_eq!(result.request_state.as_deref(), Some("opaque-state")); - assert_eq!( - result - .meta - .as_ref() - .and_then(|meta| meta.get("io.modelcontextprotocol/serverInfo")), - Some(&json!({ - "name": "test-server", - "version": "1.0.0" - })) - ); - } - - #[test] - fn call_tool_result_rejects_input_required_discriminator() { - assert!( - serde_json::from_value::(json!({ - "resultType": "input_required", - "requestState": "opaque-state", - "_meta": {} - })) - .is_err() - ); - } - - #[test] - fn invalid_input_required_result_falls_through_to_custom_result() { - let payload = json!({ - "resultType": "input_required", - "_meta": {} - }); - let result = parse_result(wrap_response(payload.clone())); - - let ServerResult::CustomResult(result) = result else { - panic!("expected CustomResult, got {result:?}"); - }; - assert_eq!(result.0, payload); - } - - #[test] - fn empty_object_deserializes_to_empty_result() { - let result = parse_result(wrap_response(json!({}))); - assert!( - matches!(result, ServerResult::EmptyResult(_)), - "expected EmptyResult, got {result:?}" - ); - } - - #[test] - fn unknown_shape_falls_through_to_custom_result() { - // A value that doesn't match any known result type should land in - // CustomResult. - let result = parse_result(wrap_response(json!({ - "some_unknown_field": "some_value", - "number": 42 - }))); - assert!( - matches!(result, ServerResult::CustomResult(_)), - "expected CustomResult, got {result:?}" - ); - } - - #[test] - fn result_type_bearing_objects_do_not_match_task_ack() { - // TaskAckResult carries only `resultType` (+ optional `_meta`), so it - // must not greedily swallow arbitrary results that happen to include - // a `resultType` key inside the untagged ServerResult union. - let result = parse_result(wrap_response(json!({ - "resultType": "weird-custom", - "payload": { "a": 1 } - }))); - assert!( - matches!(result, ServerResult::CustomResult(_)), - "expected CustomResult, got {result:?}" - ); - - let result = parse_result(wrap_response(json!({ - "resultType": "complete", - "customField": 42 - }))); - assert!( - matches!(result, ServerResult::CustomResult(_)), - "expected CustomResult, got {result:?}" - ); - - // A bare complete ack (the actual tasks/update / tasks/cancel ack - // shape) still parses as TaskAckResult. - let result = parse_result(wrap_response(json!({ "resultType": "complete" }))); - assert!( - matches!(result, ServerResult::TaskAckResult(_)), - "expected TaskAckResult, got {result:?}" - ); - } - - #[test] - fn arbitrary_json_value_falls_through_to_custom_result() { - // Any bare JSON value must fall through to CustomResult. - for value in [json!(42), json!("hello"), json!(null), json!([1, 2, 3])] { - let result = parse_result(wrap_response(value.clone())); - assert!( - matches!(result, ServerResult::CustomResult(_)), - "value {value} should deserialize as CustomResult, got {result:?}" - ); - } - } - - #[test] - fn round_trip_initialize_result_preserves_variant() { - let json = json!({ - "protocolVersion": "2025-03-26", - "capabilities": {}, - "serverInfo": { "name": "test", "version": "1.0" } - }); - // Parse as ServerResult, serialize back, parse again — must stay InitializeResult. - let result = parse_result(wrap_response(json.clone())); - assert!(matches!(&result, ServerResult::InitializeResult(_))); - let reserialized = serde_json::to_value(&result).unwrap(); - let result2 = parse_result(wrap_response(reserialized)); - assert!(matches!(result2, ServerResult::InitializeResult(_))); - } - - #[test] - fn round_trip_call_tool_result_preserves_variant() { - let original = - CallToolResult::success(vec![rmcp::model::ContentBlock::text("hello world")]); - let json = serde_json::to_value(&original).unwrap(); - let result = parse_result(wrap_response(json)); - assert!(matches!(result, ServerResult::CallToolResult(_))); - } -} diff --git a/vendor/rmcp/tests/test_deserialization/tool_list_result.json b/vendor/rmcp/tests/test_deserialization/tool_list_result.json deleted file mode 100644 index 38f7b34f5..000000000 --- a/vendor/rmcp/tests/test_deserialization/tool_list_result.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "result": { - "tools": [ - { - "name": "add", - "inputSchema": { - "type": "object", - "properties": { - "a": { - "type": "number" - }, - "b": { - "type": "number" - } - }, - "required": [ - "a", - "b" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "execution": { - "taskSupport": "optional" - } - } - ] - }, - "jsonrpc": "2.0", - "id": 2 -} \ No newline at end of file diff --git a/vendor/rmcp/tests/test_discover_http_client_startup.rs b/vendor/rmcp/tests/test_discover_http_client_startup.rs deleted file mode 100644 index c6051a047..000000000 --- a/vendor/rmcp/tests/test_discover_http_client_startup.rs +++ /dev/null @@ -1,137 +0,0 @@ -#![cfg(all( - not(feature = "local"), - feature = "client", - feature = "reqwest", - feature = "transport-streamable-http-server" -))] - -use std::borrow::Cow; - -use rmcp::{ - ClientLifecycleMode, ClientServiceExt, ServerHandler, - model::{ClientInfo, DiscoverResult, ErrorCode, ErrorData, ProtocolVersion}, - service::{MaybeSendFuture, RequestContext, RoleServer}, - transport::{ - StreamableHttpClientTransport, - streamable_http_client::StreamableHttpClientTransportConfig, - streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, - }, - }, -}; -use tokio_util::sync::CancellationToken; - -#[derive(Clone, Default)] -struct DiscoverHttpServer; - -impl ServerHandler for DiscoverHttpServer { - fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { - Cow::Borrowed(&[ProtocolVersion::V_2026_07_28]) - } -} - -#[derive(Clone, Default)] -struct LegacyHttpServer; - -impl ServerHandler for LegacyHttpServer { - fn discover( - &self, - _context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Err(ErrorData::new( - ErrorCode::METHOD_NOT_FOUND, - "Method not found", - None, - ))) - } -} - -#[tokio::test] -async fn discover_http_client_bootstraps_headers_without_initialize() { - let ct = CancellationToken::new(); - let service: StreamableHttpService = - StreamableHttpService::new( - || Ok(DiscoverHttpServer), - Default::default(), - StreamableHttpServerConfig::default() - .with_legacy_session_mode(false) - .with_json_response(true) - .with_cancellation_token(ct.child_token()), - ); - let router = axum::Router::new().nest_service("/mcp", service); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("listener should bind"); - let address = listener.local_addr().expect("listener address"); - let server = tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - - let transport = StreamableHttpClientTransport::from_config( - StreamableHttpClientTransportConfig::with_uri(format!("http://{address}/mcp")), - ); - let client = ClientInfo::default() - .serve_with_lifecycle( - transport, - ClientLifecycleMode::Discover { - preferred_versions: vec![ProtocolVersion::V_2026_07_28], - }, - ) - .await - .expect("discover HTTP client should start"); - client.list_tools(None).await.expect("list tools"); - client.cancel().await.expect("cancel client"); - - ct.cancel(); - server.await.expect("server task"); -} - -#[tokio::test] -async fn auto_http_client_falls_back_to_stateful_legacy_startup() { - let ct = CancellationToken::new(); - let service: StreamableHttpService = - StreamableHttpService::new( - || Ok(LegacyHttpServer), - Default::default(), - StreamableHttpServerConfig::default() - .with_json_response(true) - .with_cancellation_token(ct.child_token()), - ); - let router = axum::Router::new().nest_service("/mcp", service); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("listener should bind"); - let address = listener.local_addr().expect("listener address"); - let server = tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - - let transport = StreamableHttpClientTransport::from_config( - StreamableHttpClientTransportConfig::with_uri(format!("http://{address}/mcp")), - ); - let client = ClientInfo::default() - .serve_with_lifecycle( - transport, - ClientLifecycleMode::Auto { - preferred_versions: vec![ProtocolVersion::V_2026_07_28], - legacy_version: Some(ProtocolVersion::V_2025_11_25), - }, - ) - .await - .expect("auto HTTP client should fall back"); - client.list_tools(None).await.expect("list tools"); - client.cancel().await.expect("cancel client"); - - ct.cancel(); - server.await.expect("server task"); -} diff --git a/vendor/rmcp/tests/test_elicitation.rs b/vendor/rmcp/tests/test_elicitation.rs deleted file mode 100644 index 953648c0e..000000000 --- a/vendor/rmcp/tests/test_elicitation.rs +++ /dev/null @@ -1,2077 +0,0 @@ -//cargo test --test test_elicitation --features "client server" - -use rmcp::{model::*, object, service::*}; -// For typed elicitation tests -#[cfg(feature = "schemars")] -use schemars::JsonSchema; -#[cfg(feature = "schemars")] -use serde::{Deserialize, Serialize}; -use serde_json::json; - -/// Test that elicitation data structures can be serialized and deserialized correctly -/// This ensures JSON-RPC compatibility with MCP 2025-06-18 specification -#[tokio::test] -async fn test_elicitation_serialization() { - // Test ElicitationAction enum serialization - let accept = ElicitationAction::Accept; - let decline = ElicitationAction::Decline; - let cancel = ElicitationAction::Cancel; - - assert_eq!(serde_json::to_string(&accept).unwrap(), "\"accept\""); - assert_eq!(serde_json::to_string(&decline).unwrap(), "\"decline\""); - assert_eq!(serde_json::to_string(&cancel).unwrap(), "\"cancel\""); - - // Test deserialization - assert_eq!( - serde_json::from_str::("\"accept\"").unwrap(), - ElicitationAction::Accept - ); - assert_eq!( - serde_json::from_str::("\"decline\"").unwrap(), - ElicitationAction::Decline - ); - assert_eq!( - serde_json::from_str::("\"cancel\"").unwrap(), - ElicitationAction::Cancel - ); -} - -/// Test ElicitRequestParams structure serialization/deserialization -#[tokio::test] -async fn test_elicitation_request_param_serialization() { - let schema = ElicitationSchema::builder() - .required_property( - "email", - PrimitiveSchemaDefinition::String(StringSchema::email()), - ) - .build() - .unwrap(); - - let request_param = ElicitRequestParams::FormElicitationParams { - meta: None, - message: "Please provide your email address".to_string(), - requested_schema: schema, - }; - - // Test serialization - let json = serde_json::to_value(&request_param).unwrap(); - let expected = json!({ - "mode": "form", - "message": "Please provide your email address", - "requestedSchema": { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email" - } - }, - "required": ["email"] - } - }); - - assert_eq!(json, expected); - - // Test deserialization - let deserialized: ElicitRequestParams = serde_json::from_value(expected).unwrap(); - match (&deserialized, &request_param) { - ( - ElicitRequestParams::FormElicitationParams { - meta: None, - message: msg1, - requested_schema: schema1, - }, - ElicitRequestParams::FormElicitationParams { - meta: None, - message: msg2, - requested_schema: schema2, - }, - ) => { - assert_eq!(msg1, msg2); - assert_eq!(schema1, schema2); - } - _ => panic!("Expected FormElicitationParams variant"), - } -} - -/// Test ElicitResult structure with different action types -#[tokio::test] -async fn test_elicitation_result_serialization() { - // Test Accept with content - let accept_result = ElicitResult::new(ElicitationAction::Accept) - .with_content(json!({"email": "user@example.com"})); - - let json = serde_json::to_value(&accept_result).unwrap(); - let expected = json!({ - "action": "accept", - "content": {"email": "user@example.com"} - }); - assert_eq!(json, expected); - - // Test Decline without content - let decline_result = ElicitResult::new(ElicitationAction::Decline); - - let json = serde_json::to_value(&decline_result).unwrap(); - let expected = json!({ - "action": "decline" - // content should be omitted when None due to skip_serializing_if - }); - assert_eq!(json, expected); - - // Test deserialization - let deserialized: ElicitResult = serde_json::from_value(expected).unwrap(); - assert_eq!(deserialized.action, ElicitationAction::Decline); - assert_eq!(deserialized.content, None); - assert_eq!(deserialized.meta, None); - - // Test protocol-level metadata round-trips as _meta. - let meta_result = ElicitResult::new(ElicitationAction::Accept).with_meta(MetaObject(object!({ - "traceId": "elicitation-123" - }))); - - let json = serde_json::to_value(&meta_result).unwrap(); - let expected = json!({ - "action": "accept", - "_meta": {"traceId": "elicitation-123"} - }); - assert_eq!(json, expected); - - let deserialized: ElicitResult = serde_json::from_value(expected).unwrap(); - assert_eq!( - deserialized.meta, - Some(MetaObject(object!({ "traceId": "elicitation-123" }))) - ); -} - -/// Test that elicitation requests can be created and handled through the JSON-RPC protocol -#[tokio::test] -async fn test_elicitation_json_rpc_protocol() { - let schema = ElicitationSchema::builder() - .required_property( - "confirmation", - PrimitiveSchemaDefinition::Boolean(BooleanSchema::new()), - ) - .build() - .unwrap(); - - // Create a complete JSON-RPC request for elicitation - let request = JsonRpcRequest { - jsonrpc: JsonRpcVersion2_0, - id: RequestId::Number(1), - request: ElicitRequest::new(ElicitRequestParams::FormElicitationParams { - meta: None, - message: "Do you want to continue?".to_string(), - requested_schema: schema, - }), - }; - - // Test serialization of complete request - let json = serde_json::to_value(&request).unwrap(); - assert_eq!(json["jsonrpc"], "2.0"); - assert_eq!(json["id"], 1); - assert_eq!(json["method"], "elicitation/create"); - assert_eq!(json["params"]["message"], "Do you want to continue?"); - - // Test deserialization - let deserialized: JsonRpcRequest = serde_json::from_value(json).unwrap(); - assert_eq!(deserialized.id, RequestId::Number(1)); - match &deserialized.request.params { - ElicitRequestParams::FormElicitationParams { message, .. } => { - assert_eq!(message, "Do you want to continue?"); - } - _ => panic!("Expected FormElicitationParams variant"), - } -} - -/// Test elicitation action types and their expected behavior -#[tokio::test] -async fn test_elicitation_action_types() { - // Test all three action types - let actions = [ - ElicitationAction::Accept, - ElicitationAction::Decline, - ElicitationAction::Cancel, - ]; - - // Each action should have a unique string representation - let serialized: Vec = actions - .iter() - .map(|action| serde_json::to_string(action).unwrap()) - .collect(); - - assert_eq!(serialized.len(), 3); - assert!(serialized.contains(&"\"accept\"".to_string())); - assert!(serialized.contains(&"\"decline\"".to_string())); - assert!(serialized.contains(&"\"cancel\"".to_string())); - - // Test round-trip serialization - for action in actions { - let json = serde_json::to_string(&action).unwrap(); - let deserialized: ElicitationAction = serde_json::from_str(&json).unwrap(); - assert_eq!(action, deserialized); - } -} - -/// Test MCP 2025-06-18 specification compliance -/// Ensures our implementation matches the latest MCP spec -#[tokio::test] -async fn test_elicitation_spec_compliance() { - // Test that method names match the specification - assert_eq!(ElicitationCreateRequestMethod::VALUE, "elicitation/create"); - assert_eq!( - ElicitationResponseNotificationMethod::VALUE, - "notifications/elicitation/response" - ); - - // Test that enum values match specification - let actions = [ - ElicitationAction::Accept, - ElicitationAction::Decline, - ElicitationAction::Cancel, - ]; - - let serialized: Vec = actions - .iter() - .map(|a| serde_json::to_string(a).unwrap()) - .collect(); - - assert_eq!(serialized, vec!["\"accept\"", "\"decline\"", "\"cancel\""]); -} - -/// Test error handling and edge cases for elicitation -#[tokio::test] -async fn test_elicitation_error_handling() { - // Test minimal schema handling (empty properties is technically valid) - let minimal_schema_request = ElicitRequestParams::FormElicitationParams { - meta: None, - message: "Test message".to_string(), - requested_schema: ElicitationSchema::builder().build().unwrap(), - }; - - // Should serialize without error - let _json = serde_json::to_value(&minimal_schema_request).unwrap(); - - // Test empty message - let empty_message_request = ElicitRequestParams::FormElicitationParams { - meta: None, - message: "".to_string(), - requested_schema: ElicitationSchema::builder() - .property( - "value", - PrimitiveSchemaDefinition::String(StringSchema::new()), - ) - .build() - .unwrap(), - }; - - // Should serialize without error (validation is up to the implementation) - let _json = serde_json::to_value(&empty_message_request).unwrap(); - - // Test that we can deserialize invalid action types (should fail) - let invalid_action_json = json!("invalid_action"); - let result = serde_json::from_value::(invalid_action_json); - assert!(result.is_err()); -} - -/// Benchmark-style test for elicitation performance -#[tokio::test] -async fn test_elicitation_performance() { - let schema = ElicitationSchema::builder() - .property( - "data", - PrimitiveSchemaDefinition::String(StringSchema::new()), - ) - .build() - .unwrap(); - - let request = ElicitRequestParams::FormElicitationParams { - meta: None, - message: "Performance test message".to_string(), - requested_schema: schema, - }; - - let start = std::time::Instant::now(); - - // Serialize/deserialize 1000 times - for _ in 0..1000 { - let json = serde_json::to_value(&request).unwrap(); - let _deserialized: ElicitRequestParams = serde_json::from_value(json).unwrap(); - } - - let duration = start.elapsed(); - println!( - "1000 elicitation serialization/deserialization cycles took: {:?}", - duration - ); - - // Should complete in reasonable time (less than 100ms on modern hardware) - assert!( - duration.as_millis() < 1000, - "Performance test took too long: {:?}", - duration - ); -} - -/// Test elicitation capabilities integration -/// Ensures that elicitation capability can be properly configured and serialized -#[tokio::test] -async fn test_elicitation_capabilities() { - use rmcp::model::{ClientCapabilities, ElicitationCapability}; - - // Test basic elicitation capability - let mut elicitation_cap = ElicitationCapability::default(); - assert_eq!(elicitation_cap.form, None); - assert_eq!(elicitation_cap.url, None); - - // Test with schema validation enabled - elicitation_cap.form = Some(FormElicitationCapability::new().with_schema_validation(true)); - - // Test serialization - let json = serde_json::to_value(&elicitation_cap).unwrap(); - let expected = json!({"form":{"schemaValidation": true}}); - assert_eq!(json, expected); - - // Test deserialization - let deserialized: ElicitationCapability = serde_json::from_value(expected).unwrap(); - assert_eq!( - deserialized.form.as_ref().unwrap().schema_validation, - Some(true) - ); - - // Test ClientCapabilities builder with elicitation - let client_caps = ClientCapabilities::builder() - .enable_elicitation() - .enable_elicitation_schema_validation() - .build(); - - assert!(client_caps.elicitation.is_some()); - assert_eq!( - client_caps - .elicitation - .as_ref() - .unwrap() - .form - .as_ref() - .unwrap() - .schema_validation, - Some(true) - ); - - // Test full client capabilities serialization - let json = serde_json::to_value(&client_caps).unwrap(); - assert!( - json["elicitation"]["form"]["schemaValidation"] - .as_bool() - .unwrap_or(false) - ); -} - -/// Test convenience methods for common elicitation scenarios -/// This ensures the helper methods create proper requests with expected schemas -#[tokio::test] -async fn test_elicitation_convenience_methods() { - // Test that convenience methods produce the expected request parameters - - // Test confirmation schema - let confirmation_schema = serde_json::json!({ - "type": "boolean", - "description": "User confirmation (true for yes, false for no)" - }); - - // Verify the schema structure for boolean confirmation - assert_eq!(confirmation_schema["type"], "boolean"); - assert!(confirmation_schema["description"].is_string()); - - // Test text input schema (non-required) - let text_schema = serde_json::json!({ - "type": "string", - "description": "User text input" - }); - - assert_eq!(text_schema["type"], "string"); - assert!(text_schema.get("minLength").is_none()); - - // Test text input schema (required) - let required_text_schema = serde_json::json!({ - "type": "string", - "description": "User text input", - "minLength": 1 - }); - - assert_eq!(required_text_schema["minLength"], 1); - - // Test choice schema - let options = ["Option A", "Option B", "Option C"]; - let choice_schema = serde_json::json!({ - "type": "integer", - "minimum": 0, - "maximum": options.len() - 1, - "description": format!("Choose an option: {}", options.join(", ")) - }); - - assert_eq!(choice_schema["type"], "integer"); - assert_eq!(choice_schema["minimum"], 0); - assert_eq!(choice_schema["maximum"], 2); - assert!( - choice_schema["description"] - .as_str() - .unwrap() - .contains("Option A") - ); - - // Test that ElicitRequestParams can be created with type-safe schemas - let confirmation_request = ElicitRequestParams::FormElicitationParams { - meta: None, - message: "Test confirmation".to_string(), - requested_schema: ElicitationSchema::builder() - .property( - "confirmed", - PrimitiveSchemaDefinition::Boolean( - BooleanSchema::new() - .description("User confirmation (true for yes, false for no)"), - ), - ) - .build() - .unwrap(), - }; - - // Test serialization of convenience method request - let json = serde_json::to_value(&confirmation_request).unwrap(); - assert_eq!(json["message"], "Test confirmation"); - assert_eq!(json["requestedSchema"]["type"], "object"); - assert_eq!( - json["requestedSchema"]["properties"]["confirmed"]["type"], - "boolean" - ); -} - -/// Test structured input with multiple primitive properties -/// Ensures that schemas with multiple primitive properties work correctly with elicitation -#[tokio::test] -async fn test_elicitation_structured_schemas() { - // Test schema with multiple primitive properties - let schema = ElicitationSchema::builder() - .required_string_with("name", |s| s.length(1, 100)) - .required_email("email") - .required_integer("age", 0, 150) - .optional_bool("newsletter", false) - .required_enum_schema( - "country", - EnumSchema::builder(vec!["US".to_string(), "UK".to_string(), "CA".to_string()]).build(), - ) - .description("User registration information") - .build() - .unwrap(); - - let request = ElicitRequestParams::FormElicitationParams { - meta: None, - message: "Please provide your user information".to_string(), - requested_schema: schema, - }; - - // Test that complex schemas serialize/deserialize correctly - let json = serde_json::to_value(&request).unwrap(); - let deserialized: ElicitRequestParams = serde_json::from_value(json).unwrap(); - - match deserialized { - ElicitRequestParams::FormElicitationParams { - message, - requested_schema, - .. - } => { - assert_eq!(message, "Please provide your user information"); - assert_eq!(requested_schema.properties.len(), 5); - assert!(requested_schema.properties.contains_key("name")); - assert!(requested_schema.properties.contains_key("email")); - assert!(requested_schema.properties.contains_key("age")); - assert!(requested_schema.properties.contains_key("newsletter")); - assert!(requested_schema.properties.contains_key("country")); - assert_eq!( - requested_schema.required, - Some(vec![ - "name".to_string(), - "email".to_string(), - "age".to_string(), - "country".to_string() - ]) - ); - } - _ => panic!("Expected FormElicitationParams variant"), - } -} - -// Typed elicitation tests using the API with schemars -#[cfg(feature = "schemars")] -mod typed_elicitation_tests { - use super::*; - - /// Simple user confirmation with reason - #[derive(Debug, Serialize, Deserialize, JsonSchema, PartialEq)] - #[schemars(description = "User confirmation with optional reasoning")] - struct UserConfirmation { - #[schemars(description = "User's decision (true for yes, false for no)")] - confirmed: bool, - - #[schemars(description = "Optional reason for the decision")] - reason: Option, - } - - /// User profile with validation constraints - #[derive(Debug, Serialize, Deserialize, JsonSchema, PartialEq)] - #[schemars(description = "Complete user profile information")] - struct UserProfile { - #[schemars(description = "Full name")] - name: String, - - #[schemars(description = "Email address")] - email: String, - - #[schemars(description = "Age in years")] - age: u8, - - #[schemars(description = "User preferences")] - preferences: UserPreferences, - } - - /// User preferences - #[derive(Debug, Serialize, Deserialize, JsonSchema, PartialEq)] - struct UserPreferences { - #[schemars(description = "UI theme preference")] - theme: Theme, - - #[schemars(description = "Enable notifications")] - notifications: bool, - - #[schemars(description = "Language preference")] - language: String, - } - - /// UI theme options - #[derive(Debug, Serialize, Deserialize, JsonSchema, PartialEq)] - #[schemars(description = "Available UI themes")] - enum Theme { - #[schemars(description = "Light theme")] - Light, - #[schemars(description = "Dark theme")] - Dark, - #[schemars(description = "Auto-detect based on system")] - Auto, - } - - // Mark types as safe for elicitation (they generate object schemas) - rmcp::elicit_safe!(UserConfirmation, UserProfile, UserPreferences); - - /// Test automatic schema generation for simple types - #[tokio::test] - async fn test_typed_elicitation_simple_schema() { - // Test that schema generation works for simple types - let schema = rmcp::handler::server::tool::schema_for_type::(); - - // Verify schema contains expected fields - assert!(schema.contains_key("type")); - assert_eq!(schema.get("type"), Some(&json!("object"))); - assert!(schema.contains_key("properties")); - - if let Some(properties) = schema.get("properties") { - assert!(properties.is_object()); - let props = properties.as_object().unwrap(); - assert!(props.contains_key("confirmed")); - assert!(props.contains_key("reason")); - - // Check confirmed field is boolean - if let Some(confirmed_schema) = props.get("confirmed") { - let confirmed_obj = confirmed_schema.as_object().unwrap(); - assert_eq!(confirmed_obj.get("type"), Some(&json!("boolean"))); - } - - // Check reason field is optional string - if let Some(reason_schema) = props.get("reason") { - assert!(reason_schema.is_object()); - } - } - } - - /// Test automatic schema generation for complex nested types - #[tokio::test] - async fn test_typed_elicitation_complex_schema() { - // Test complex nested structure schema generation - let schema = rmcp::handler::server::tool::schema_for_type::(); - - // Verify schema structure - assert!(schema.contains_key("type")); - assert_eq!(schema.get("type"), Some(&json!("object"))); - - if let Some(properties) = schema.get("properties") { - let props = properties.as_object().unwrap(); - - // Check required fields exist - assert!(props.contains_key("name")); - assert!(props.contains_key("email")); - assert!(props.contains_key("age")); - assert!(props.contains_key("preferences")); - - // Check validation constraints for name - if let Some(name_schema) = props.get("name") { - let name_obj = name_schema.as_object().unwrap(); - assert_eq!(name_obj.get("type"), Some(&json!("string"))); - // Note: schemars might generate constraints differently - // The exact structure depends on schemars version - } - - // Check email format constraint - if let Some(email_schema) = props.get("email") { - let email_obj = email_schema.as_object().unwrap(); - assert_eq!(email_obj.get("type"), Some(&json!("string"))); - } - - // Check age numeric constraints - if let Some(age_schema) = props.get("age") { - let age_obj = age_schema.as_object().unwrap(); - assert_eq!(age_obj.get("type"), Some(&json!("integer"))); - } - } - } - - /// Test enum schema generation - #[tokio::test] - async fn test_enum_schema_generation() { - // Test enum schema generation - let schema = rmcp::handler::server::tool::schema_for_type::(); - - // Verify enum schema structure - schemars might use oneOf or enum depending on version - assert!( - schema.contains_key("type") - || schema.contains_key("oneOf") - || schema.contains_key("enum") - ); - - // The exact structure depends on schemars configuration, but it should be valid - let json = serde_json::to_string(&schema).unwrap(); - assert!(!json.is_empty()); - } - - /// Test that the schema generation for nested structures works - #[tokio::test] - async fn test_nested_structure_schema() { - // Test that nested structures generate proper schemas - let preferences_schema = rmcp::handler::server::tool::schema_for_type::(); - - // Verify basic structure - assert!(preferences_schema.contains_key("type")); - assert_eq!(preferences_schema.get("type"), Some(&json!("object"))); - - if let Some(properties) = preferences_schema.get("properties") { - let props = properties.as_object().unwrap(); - assert!(props.contains_key("theme")); - assert!(props.contains_key("notifications")); - assert!(props.contains_key("language")); - } - } -} - -// ============================================================================= -// ELICITATION ENUM TESTS (MCP 2025-11-25 COMPLIANCE) -// ============================================================================= - -/// Test elicitation with multi-select enum schema -#[tokio::test] -async fn test_elicitation_multi_select_enum() { - let enum_schema = EnumSchema::builder(vec!["A".into(), "B".into(), "C".into()]) - .multiselect() - .enum_titles(vec![ - "A name".to_string(), - "B name".to_string(), - "C name".to_string(), - ]) - .expect("Number of title must match number of enum values") - .min_items(1) - .expect("Min items must be less than or equal to number of enum values") - .max_items(2) - .expect("Max items must be less than or equal to number of enum values") - .build(); - - let schema = ElicitationSchema::builder() - .required_enum_schema("choices", enum_schema) - .build() - .unwrap(); - - let request = ElicitRequestParams::FormElicitationParams { - meta: None, - message: "Please provide your user information".to_string(), - requested_schema: schema, - }; - - // Test that complex schemas serialize/deserialize correctly - let json = serde_json::to_value(&request).unwrap(); - let deserialized: ElicitRequestParams = serde_json::from_value(json).unwrap(); - - match deserialized { - ElicitRequestParams::FormElicitationParams { - message, - requested_schema, - .. - } => { - assert_eq!(message, "Please provide your user information"); - assert_eq!(requested_schema.properties.len(), 1); - assert!(requested_schema.properties.contains_key("choices")); - assert_eq!(requested_schema.required, Some(vec!["choices".to_string()])); - - assert!(matches!( - requested_schema.properties.get("choices").unwrap(), - PrimitiveSchemaDefinition::Enum(EnumSchema::Multi(_)) - )); - - if let Some(PrimitiveSchemaDefinition::Enum(schema)) = - requested_schema.properties.get("choices") - { - assert_eq!( - schema, - &EnumSchema::Multi(MultiSelectEnumSchema::Titled( - TitledMultiSelectEnumSchema::new(TitledItems::new(vec![ - ConstTitle::new("A", "A name"), - ConstTitle::new("B", "B name"), - ConstTitle::new("C", "C name"), - ])) - .with_min_items(1) - .with_max_items(2) - )) - ) - } - } - _ => panic!("Expected FormElicitationParams variant"), - } -} - -/// Test elicitation with single-select enum schema -#[tokio::test] -async fn test_elicitation_single_select_enum() { - let enum_schema = EnumSchema::builder(vec!["A".into(), "B".into(), "C".into()]) - .enum_titles(vec![ - "A name".to_string(), - "B name".to_string(), - "C name".to_string(), - ]) - .expect("Number of title must match number of enum values") - .build(); - - let schema = ElicitationSchema::builder() - .required_enum_schema("choices", enum_schema) - .build() - .unwrap(); - - let request = ElicitRequestParams::FormElicitationParams { - meta: None, - message: "Please provide your user information".to_string(), - requested_schema: schema, - }; - - // Test that complex schemas serialize/deserialize correctly - let json = serde_json::to_value(&request).unwrap(); - let deserialized: ElicitRequestParams = serde_json::from_value(json).unwrap(); - - match deserialized { - ElicitRequestParams::FormElicitationParams { - message, - requested_schema, - .. - } => { - assert_eq!(message, "Please provide your user information"); - assert_eq!(requested_schema.properties.len(), 1); - assert!(requested_schema.properties.contains_key("choices")); - assert_eq!(requested_schema.required, Some(vec!["choices".to_string()])); - assert!(matches!( - requested_schema.properties.get("choices").unwrap(), - PrimitiveSchemaDefinition::Enum(EnumSchema::Single(_)) - )); - - if let Some(PrimitiveSchemaDefinition::Enum(schema)) = - requested_schema.properties.get("choices") - { - assert_eq!( - schema, - &EnumSchema::Single(SingleSelectEnumSchema::Titled( - TitledSingleSelectEnumSchema::new(vec![ - ConstTitle::new("A", "A name"), - ConstTitle::new("B", "B name"), - ConstTitle::new("C", "C name"), - ]) - )) - ) - } - } - _ => panic!("Expected FormElicitationParams variant"), - } -} - -// ============================================================================= -// ELICITATION DIRECTION TESTS (MCP 2025-06-18 COMPLIANCE) -// ============================================================================= - -/// Test that elicitation requests flow from server to client (not client to server) -/// This verifies compliance with MCP 2025-06-18 specification -#[cfg(all(feature = "client", feature = "server"))] -#[tokio::test] -async fn test_elicitation_direction_server_to_client() { - use rmcp::model::*; - use serde_json::json; - - // Test that server can create elicitation requests - let schema = ElicitationSchema::builder() - .property( - "name", - PrimitiveSchemaDefinition::String(StringSchema::new().description("Enter your name")), - ) - .build() - .unwrap(); - - let elicitation_request = ElicitRequestParams::FormElicitationParams { - meta: None, - message: "Please enter your name".to_string(), - requested_schema: schema, - }; - - // Verify request can be serialized - let serialized = serde_json::to_value(&elicitation_request).unwrap(); - assert_eq!(serialized["message"], "Please enter your name"); - assert_eq!(serialized["requestedSchema"]["type"], "object"); - - // Test that elicitation requests are part of ServerRequest - let _server_request = ServerRequest::ElicitRequest(ElicitRequest::new(elicitation_request)); - - // Test that client can respond with elicitation results - let client_result = ClientResult::ElicitResult( - ElicitResult::new(ElicitationAction::Accept).with_content(json!("John Doe")), - ); - - // Verify client result can be serialized - match client_result { - ClientResult::ElicitResult(result) => { - assert_eq!(result.action, ElicitationAction::Accept); - assert_eq!(result.content, Some(json!("John Doe"))); - } - _ => panic!("ElicitResult should be part of ClientResult"), - } -} - -/// Test complete JSON-RPC message flow: Server → Client → Server -#[cfg(all(feature = "client", feature = "server"))] -#[tokio::test] -async fn test_elicitation_json_rpc_direction() { - use rmcp::model::*; - use serde_json::json; - - let schema = ElicitationSchema::builder() - .property( - "continue", - PrimitiveSchemaDefinition::Boolean( - BooleanSchema::new().description("Do you want to continue?"), - ), - ) - .build() - .unwrap(); - - // 1. Server creates elicitation request - let server_request = ServerJsonRpcMessage::request( - ServerRequest::ElicitRequest(ElicitRequest::new( - ElicitRequestParams::FormElicitationParams { - meta: None, - message: "Do you want to continue?".to_string(), - requested_schema: schema, - }, - )), - RequestId::Number(1), - ); - - // Serialize server request - let server_json = serde_json::to_value(&server_request).unwrap(); - assert_eq!(server_json["method"], "elicitation/create"); - assert_eq!(server_json["id"], 1); - assert_eq!(server_json["params"]["message"], "Do you want to continue?"); - - // 2. Client responds with elicitation result - let client_response = ClientJsonRpcMessage::response( - ClientResult::ElicitResult( - ElicitResult::new(ElicitationAction::Accept).with_content(json!(true)), - ), - RequestId::Number(1), - ); - - // Serialize client response - let client_json = serde_json::to_value(&client_response).unwrap(); - assert_eq!(client_json["id"], 1); - if let Some(result) = client_json["result"].as_object() { - assert_eq!(result["action"], "accept"); - assert_eq!(result["content"], true); - } else { - panic!("Client response should contain result"); - } -} - -/// Test all three elicitation actions according to MCP spec -#[cfg(all(feature = "client", feature = "server"))] -#[tokio::test] -async fn test_elicitation_actions_compliance() { - use rmcp::model::*; - - // Test all three elicitation actions according to MCP spec - let actions = [ - ElicitationAction::Accept, - ElicitationAction::Decline, - ElicitationAction::Cancel, - ]; - - for action in actions { - let result = { - let r = ElicitResult::new(action.clone()); - match action { - ElicitationAction::Accept => r.with_content(serde_json::json!("some data")), - _ => r, - } - }; - - let json = serde_json::to_value(&result).unwrap(); - - match action { - ElicitationAction::Accept => { - assert_eq!(json["action"], "accept"); - assert!(json["content"].is_string()); - } - ElicitationAction::Decline => { - assert_eq!(json["action"], "decline"); - assert!(json.get("content").is_none() || json["content"].is_null()); - } - ElicitationAction::Cancel => { - assert_eq!(json["action"], "cancel"); - assert!(json.get("content").is_none() || json["content"].is_null()); - } - _ => {} - } - } -} - -/// Test that ElicitResult IS in ClientResult (response compliance) -#[tokio::test] -async fn test_elicitation_result_in_client_result() { - use rmcp::model::*; - - // Test that clients can return elicitation results - let result = ClientResult::ElicitResult(ElicitResult::new(ElicitationAction::Decline)); - - match result { - ClientResult::ElicitResult(elicit_result) => { - assert_eq!(elicit_result.action, ElicitationAction::Decline); - assert_eq!(elicit_result.content, None); - } - _ => panic!("ElicitResult should be part of ClientResult"), - } -} - -// ============================================================================= -// ELICITATION CAPABILITIES TESTS -// ============================================================================= - -/// Test ElicitationCapability structure and serialization -#[tokio::test] -async fn test_elicitation_capability_structure() { - // Test default ElicitationCapability - let default_cap = ElicitationCapability::default(); - assert!(default_cap.form.is_none()); - assert!(default_cap.url.is_none()); - - // Test ElicitationCapability with schema validation enabled - let cap_with_validation = ElicitationCapability::new() - .with_form(FormElicitationCapability::new().with_schema_validation(true)); - assert_eq!( - cap_with_validation.form.as_ref().unwrap().schema_validation, - Some(true) - ); - - // Test ElicitationCapability with schema validation disabled - let cap_without_validation = ElicitationCapability::new() - .with_form(FormElicitationCapability::new().with_schema_validation(false)); - assert_eq!( - cap_without_validation - .form - .as_ref() - .unwrap() - .schema_validation, - Some(false) - ); - - // Test JSON serialization - let json = serde_json::to_value(&cap_with_validation).unwrap(); - assert_eq!( - json, - serde_json::json!({ - "form": { - "schemaValidation": true - } - }) - ); - - // Test JSON deserialization - let deserialized: ElicitationCapability = serde_json::from_value(json).unwrap(); - assert_eq!( - deserialized.form.as_ref().unwrap().schema_validation, - Some(true) - ); -} - -/// Test ClientCapabilities with elicitation capability -#[tokio::test] -async fn test_client_capabilities_with_elicitation() { - // Test ClientCapabilities with elicitation capability - let capabilities = ClientCapabilities::builder() - .enable_elicitation_with( - ElicitationCapability::new() - .with_form(FormElicitationCapability::new().with_schema_validation(true)), - ) - .build(); - - // Verify elicitation capability is present - assert!(capabilities.elicitation.is_some()); - assert_eq!( - capabilities - .elicitation - .as_ref() - .unwrap() - .form - .as_ref() - .unwrap() - .schema_validation, - Some(true) - ); - - // Test JSON serialization - let json = serde_json::to_value(&capabilities).unwrap(); - assert!( - json["elicitation"]["form"]["schemaValidation"] - .as_bool() - .unwrap_or(false) - ); - - // Test ClientCapabilities without elicitation - let capabilities_without = ClientCapabilities::default(); - - assert!(capabilities_without.elicitation.is_none()); -} - -#[tokio::test] -async fn test_initialize_request_with_elicitation() { - let init_param = InitializeRequestParams::new( - ClientCapabilities::builder() - .enable_elicitation_with( - ElicitationCapability::new() - .with_form(FormElicitationCapability::new().with_schema_validation(true)), - ) - .build(), - Implementation::new("test-client", "1.0.0"), - ); - - // Verify the structure - assert!(init_param.capabilities.elicitation.is_some()); - assert_eq!( - init_param - .capabilities - .elicitation - .as_ref() - .unwrap() - .form - .as_ref() - .unwrap() - .schema_validation, - Some(true) - ); - - // Test JSON serialization - let json = serde_json::to_value(&init_param).unwrap(); - assert!( - json["capabilities"]["elicitation"]["form"]["schemaValidation"] - .as_bool() - .unwrap_or(false) - ); -} - -/// Test capability checking logic (simulated) -#[tokio::test] -async fn test_capability_checking_logic() { - // Simulate the logic that would be used in supports_elicitation() - - // Case 1: Client with elicitation capability - let client_with_capability = InitializeRequestParams::new( - ClientCapabilities::builder() - .enable_elicitation_with( - ElicitationCapability::new() - .with_form(FormElicitationCapability::new().with_schema_validation(true)), - ) - .build(), - Implementation::new("test-client", "1.0.0"), - ); - - // Simulate supports_elicitation() logic - let supports_elicitation = client_with_capability.capabilities.elicitation.is_some(); - assert!(supports_elicitation); - - // Case 2: Client without elicitation capability - let client_without_capability = InitializeRequestParams::new( - ClientCapabilities::default(), - Implementation::new("test-client", "1.0.0"), - ); - let supports_elicitation = client_without_capability.capabilities.elicitation.is_some(); - assert!(!supports_elicitation); -} - -/// Test CapabilityNotSupported error message formatting -#[tokio::test] -async fn test_capability_not_supported_error_message() { - let error = ElicitationError::CapabilityNotSupported; - let message = format!("{}", error); - - assert_eq!( - message, - "Client does not support elicitation - capability not declared during initialization" - ); -} - -/// Test all ElicitationError variants and their messages -#[tokio::test] -async fn test_elicitation_error_variants() { - // Test CapabilityNotSupported - let capability_error = ElicitationError::CapabilityNotSupported; - assert_eq!( - format!("{}", capability_error), - "Client does not support elicitation - capability not declared during initialization" - ); - - // Test UserDeclined - let user_declined = ElicitationError::UserDeclined; - assert_eq!( - format!("{}", user_declined), - "User explicitly declined the request" - ); - - // Test UserCancelled - let user_cancelled = ElicitationError::UserCancelled; - assert_eq!( - format!("{}", user_cancelled), - "User cancelled/dismissed the request" - ); - - // Test NoContent - let no_content = ElicitationError::NoContent; - assert_eq!(format!("{}", no_content), "No response content provided"); - - // Test Service error - let service_error = ElicitationError::Service(ServiceError::UnexpectedResponse); - let message = format!("{}", service_error); - assert!(message.starts_with("Service error:")); - - // Test ParseError - let json_error = serde_json::from_str::("\"not_an_integer\"").unwrap_err(); - let data = serde_json::json!({"key": "value"}); - let parse_error = ElicitationError::ParseError { - error: json_error, - data: data.clone(), - }; - let message = format!("{}", parse_error); - assert!(message.starts_with("Failed to parse response data:")); - assert!(message.contains("Received data:")); - - // Test error matching - match capability_error { - ElicitationError::CapabilityNotSupported => {} // Expected - _ => panic!("Should match CapabilityNotSupported"), - } - - match user_declined { - ElicitationError::UserDeclined => {} // Expected - _ => panic!("Should match UserDeclined"), - } - - match user_cancelled { - ElicitationError::UserCancelled => {} // Expected - _ => panic!("Should match UserCancelled"), - } - - match no_content { - ElicitationError::NoContent => {} // Expected - _ => panic!("Should match NoContent"), - } -} - -/// Test ElicitationCapability serialization with schema validation -#[tokio::test] -async fn test_elicitation_capability_serialization() { - use rmcp::model::ElicitationCapability; - - // Test default capability (no schema validation) - let default_cap = ElicitationCapability::default(); - let json = serde_json::to_value(&default_cap).unwrap(); - - // Should serialize to empty object when no fields are set - assert_eq!(json, serde_json::json!({})); - - // Test capability with schema validation enabled - let cap_with_validation = ElicitationCapability::new() - .with_form(FormElicitationCapability::new().with_schema_validation(true)); - let json = serde_json::to_value(&cap_with_validation).unwrap(); - - assert_eq!( - json, - serde_json::json!({ - "form": { - "schemaValidation": true - } - }) - ); - - // Test capability with schema validation disabled - let cap_without_validation = ElicitationCapability::new() - .with_form(FormElicitationCapability::new().with_schema_validation(false)); - let json = serde_json::to_value(&cap_without_validation).unwrap(); - - assert_eq!( - json, - serde_json::json!({ - "form": { - "schemaValidation": false - } - }) - ); - - // Test deserialization - let deserialized: ElicitationCapability = serde_json::from_value(serde_json::json!({ - "form":{"schemaValidation": true} - })) - .unwrap(); - - assert_eq!(deserialized.form.unwrap().schema_validation, Some(true)); -} - -/// Test ClientCapabilities builder with elicitation capability methods -#[tokio::test] -async fn test_client_capabilities_elicitation_builder() { - use rmcp::model::{ClientCapabilities, ElicitationCapability}; - - // Test enabling elicitation capability - let caps = ClientCapabilities::builder().enable_elicitation().build(); - - assert!(caps.elicitation.is_some()); - assert_eq!(caps.elicitation.as_ref().unwrap().form, None); - - // Test enabling elicitation with schema validation - let caps_with_validation = ClientCapabilities::builder() - .enable_elicitation() - .enable_elicitation_schema_validation() - .build(); - - assert!(caps_with_validation.elicitation.is_some()); - assert_eq!( - caps_with_validation - .elicitation - .as_ref() - .unwrap() - .form - .as_ref() - .unwrap() - .schema_validation, - Some(true) - ); - - // Test enabling elicitation with custom capability - let custom_elicitation = ElicitationCapability::new() - .with_form(FormElicitationCapability::new().with_schema_validation(false)); - - let caps_custom = ClientCapabilities::builder() - .enable_elicitation_with(custom_elicitation.clone()) - .build(); - - assert!(caps_custom.elicitation.is_some()); - assert_eq!( - caps_custom.elicitation.as_ref().unwrap(), - &custom_elicitation - ); -} - -// ============================================================================= -// TIMEOUT TESTS -// ============================================================================= - -/// Test basic timeout functionality for create_elicitation_with_timeout -#[tokio::test] -async fn test_create_elicitation_with_timeout_basic() { - use std::time::Duration; - - // This test verifies that the method accepts timeout parameter - let schema = ElicitationSchema::builder() - .required_property( - "name", - PrimitiveSchemaDefinition::String(StringSchema::new()), - ) - .required_property( - "email", - PrimitiveSchemaDefinition::String(StringSchema::new()), - ) - .build() - .unwrap(); - - let _params = ElicitRequestParams::FormElicitationParams { - meta: None, - message: "Enter your details".to_string(), - requested_schema: schema, - }; - - // Test different timeout values - let timeout_short = Duration::from_millis(100); - let timeout_long = Duration::from_secs(30); - let timeout_none: Option = None; - - // Verify timeout parameter types are correct - assert!(!timeout_short.is_zero()); - assert!(!timeout_long.is_zero()); - assert!(timeout_none.is_none()); - - // Verify timeout values are reasonable - assert_eq!(timeout_short.as_millis(), 100); - assert_eq!(timeout_long.as_secs(), 30); -} - -/// Test timeout behavior with elicit_with_timeout method -#[tokio::test] -async fn test_elicit_with_timeout_method_signature() { - use std::time::Duration; - - // Test that method signature works with different timeout values - let timeout_values = vec![ - None, - Some(Duration::from_millis(500)), - Some(Duration::from_secs(1)), - Some(Duration::from_secs(30)), - Some(Duration::from_secs(60)), - ]; - - for timeout in timeout_values { - // Verify timeout value is properly handled - match timeout { - None => assert!(timeout.is_none()), - Some(duration) => { - assert!(duration > Duration::from_millis(0)); - assert!(duration <= Duration::from_secs(300)); // Max 5 minutes - } - } - } -} - -/// Test timeout value validation -#[tokio::test] -async fn test_timeout_value_validation() { - use std::time::Duration; - - // Test valid timeout ranges - let valid_timeouts = vec![ - Duration::from_millis(1), // Minimum - Duration::from_millis(100), // Short - Duration::from_secs(1), // 1 second - Duration::from_secs(30), // 30 seconds - Duration::from_secs(300), // 5 minutes - ]; - - for timeout in valid_timeouts { - assert!(timeout >= Duration::from_millis(1)); - assert!(timeout <= Duration::from_secs(300)); - } - - // Test edge cases - let zero_timeout = Duration::from_millis(0); - let very_long_timeout = Duration::from_secs(3600); // 1 hour - - // Zero timeout should be handled gracefully - assert_eq!(zero_timeout, Duration::from_millis(0)); - - // Very long timeout should work but may not be practical - assert!(very_long_timeout > Duration::from_secs(300)); -} - -/// Test timeout error message formatting -#[tokio::test] -async fn test_timeout_error_formatting() { - use std::time::Duration; - - let timeout = Duration::from_secs(30); - - // Simulate a timeout error - let timeout_error = ServiceError::Timeout { timeout }; - - // Verify error contains timeout information - let error_string = format!("{}", timeout_error); - assert!(error_string.contains("timeout")); - assert!(error_string.contains("30")); -} - -/// Test elicitation error handling with timeout -#[tokio::test] -async fn test_elicitation_timeout_error_conversion() { - use std::time::Duration; - - let timeout = Duration::from_millis(500); - let service_timeout_error = ServiceError::Timeout { timeout }; - let elicitation_error = ElicitationError::Service(service_timeout_error); - - // Verify error chain is preserved - match elicitation_error { - ElicitationError::Service(ServiceError::Timeout { timeout: t }) => { - assert_eq!(t, timeout); - } - _ => panic!("Expected timeout error"), - } -} - -/// Test timeout parameter pass-through in PeerRequestOptions -#[tokio::test] -async fn test_peer_request_options_timeout() { - use std::time::Duration; - - let timeout = Some(Duration::from_secs(15)); - - let mut options = PeerRequestOptions::default(); - options.timeout = timeout; - - // Verify timeout is properly stored - assert_eq!(options.timeout, timeout); - assert!(options.meta.is_none()); - - // Test with no timeout - let options_no_timeout = PeerRequestOptions::default(); - - assert!(options_no_timeout.timeout.is_none()); -} - -/// Test realistic timeout scenarios -#[tokio::test] -async fn test_realistic_timeout_scenarios() { - use std::time::Duration; - - // Test common timeout scenarios users might encounter - - // Quick response (5 seconds) - let quick_timeout = Duration::from_secs(5); - assert!(quick_timeout >= Duration::from_secs(1)); - assert!(quick_timeout <= Duration::from_secs(10)); - - // Normal interaction (30 seconds) - let normal_timeout = Duration::from_secs(30); - assert!(normal_timeout >= Duration::from_secs(10)); - assert!(normal_timeout <= Duration::from_secs(60)); - - // Long form input (2 minutes) - let long_timeout = Duration::from_secs(120); - assert!(long_timeout >= Duration::from_secs(60)); - assert!(long_timeout <= Duration::from_secs(300)); -} - -/// Test that different ElicitationAction values map to correct error types -#[tokio::test] -async fn test_elicitation_action_error_mapping() { - use rmcp::{model::ElicitationAction, service::ElicitationError}; - - // Test that each action type produces the expected error - let test_cases = vec![ - (ElicitationAction::Decline, "UserDeclined"), - (ElicitationAction::Cancel, "UserCancelled"), - ]; - - for (action, _expected_error_type) in test_cases { - // Verify that the action exists and has the expected semantics - match action { - ElicitationAction::Accept => { - // Accept should not produce an error (it provides content) - } - ElicitationAction::Decline => { - // Should map to UserDeclined error - let error = ElicitationError::UserDeclined; - assert!(format!("{}", error).contains("explicitly declined")); - } - ElicitationAction::Cancel => { - // Should map to UserCancelled error - let error = ElicitationError::UserCancelled; - assert!(format!("{}", error).contains("cancelled/dismissed")); - } - _ => {} - } - } -} - -/// Test elicitation action semantics according to MCP specification -#[tokio::test] -async fn test_elicitation_action_semantics() { - use rmcp::model::ElicitationAction; - - // According to MCP spec: - // - Accept: User explicitly approved and submitted with data - // - Decline: User explicitly declined the request - // - Cancel: User dismissed without making an explicit choice - - // Test that all three actions are available - let actions = vec![ - ElicitationAction::Accept, - ElicitationAction::Decline, - ElicitationAction::Cancel, - ]; - - assert_eq!(actions.len(), 3); - - // Test serialization/deserialization - for action in actions { - let serialized = serde_json::to_string(&action).expect("Should serialize"); - let deserialized: ElicitationAction = - serde_json::from_str(&serialized).expect("Should deserialize"); - - // Actions should round-trip correctly - match (action, deserialized) { - (ElicitationAction::Accept, ElicitationAction::Accept) => {} - (ElicitationAction::Decline, ElicitationAction::Decline) => {} - (ElicitationAction::Cancel, ElicitationAction::Cancel) => {} - _ => panic!("Action serialization round-trip failed"), - } - } -} - -/// Test compile-time type safety for elicitation -#[tokio::test] -async fn test_elicitation_type_safety() { - use rmcp::service::ElicitationSafe; - use schemars::JsonSchema; - - // Test that our types implement ElicitationSafe - #[derive(serde::Serialize, serde::Deserialize, JsonSchema)] - struct SafeType { - name: String, - value: i32, - } - - rmcp::elicit_safe!(SafeType); - - // Verify that SafeType implements the required traits - fn assert_elicitation_safe() {} - assert_elicitation_safe::(); - - // Test that SafeType can generate schema (compile-time check) - let _schema = schemars::schema_for!(SafeType); -} - -/// Test that elicit_safe! macro works with multiple types -#[tokio::test] -async fn test_elicit_safe_macro() { - use schemars::JsonSchema; - - #[derive(serde::Serialize, serde::Deserialize, JsonSchema)] - struct TypeA { - field_a: String, - } - - #[derive(serde::Serialize, serde::Deserialize, JsonSchema)] - struct TypeB { - field_b: i32, - } - - #[derive(serde::Serialize, serde::Deserialize, JsonSchema)] - struct TypeC { - field_c: bool, - } - - // Test macro with multiple types - rmcp::elicit_safe!(TypeA, TypeB, TypeC); - - // All should implement ElicitationSafe - fn assert_all_safe() {} - assert_all_safe::(); - assert_all_safe::(); - assert_all_safe::(); -} - -/// Test ElicitationSafe trait behavior -#[tokio::test] -async fn test_elicitation_safe_trait() { - use schemars::JsonSchema; - - // Test object type validation - #[derive(serde::Serialize, serde::Deserialize, JsonSchema)] - struct ObjectType { - name: String, - count: usize, - active: bool, - } - - rmcp::elicit_safe!(ObjectType); - - // Test that ObjectType can generate schema (compile-time check) - let _schema = schemars::schema_for!(ObjectType); -} - -/// Test documentation examples compile correctly -#[tokio::test] -async fn test_elicitation_examples_compile() { - use schemars::JsonSchema; - use serde::{Deserialize, Serialize}; - - // Example from trait documentation - #[allow(dead_code)] - #[derive(Serialize, Deserialize, JsonSchema)] - struct UserProfile { - name: String, - email: String, - } - - rmcp::elicit_safe!(UserProfile); - - // This should compile and work - fn _example_usage() { - fn _assert_safe() {} - _assert_safe::(); - } -} - -// ============================================================================= -// BUILD-TIME VALIDATION TESTS -// ============================================================================= - -/// Test that build() validates required fields exist in properties -#[tokio::test] -async fn test_build_validation_required_field_not_in_properties() { - // Try to mark a field as required that doesn't exist in properties - let result = ElicitationSchema::builder() - .property( - "email", - PrimitiveSchemaDefinition::String(StringSchema::email()), - ) - .mark_required("nonexistent_field") - .build(); - - // Should return an error - assert!(result.is_err()); - assert_eq!( - result.unwrap_err(), - "Required field does not exist in properties" - ); -} - -/// Test that build() succeeds when all required fields exist -#[tokio::test] -async fn test_build_validation_required_field_exists() { - let result = ElicitationSchema::builder() - .property( - "email", - PrimitiveSchemaDefinition::String(StringSchema::email()), - ) - .property( - "name", - PrimitiveSchemaDefinition::String(StringSchema::new()), - ) - .mark_required("email") - .mark_required("name") - .build(); - - // Should succeed - assert!(result.is_ok()); - let schema = result.unwrap(); - assert_eq!(schema.properties.len(), 2); - assert_eq!( - schema.required, - Some(vec!["email".to_string(), "name".to_string()]) - ); -} - -/// Test that build_unchecked() panics on validation errors -#[tokio::test] -#[should_panic(expected = "Invalid elicitation schema")] -async fn test_build_unchecked_panics_on_invalid() { - // build_unchecked validates but panics instead of returning Result - let _schema = ElicitationSchema::builder() - .property( - "email", - PrimitiveSchemaDefinition::String(StringSchema::email()), - ) - .mark_required("nonexistent_field") - .build_unchecked(); -} - -/// Test convenience methods handle validation correctly -#[tokio::test] -async fn test_convenience_methods_validation() { - // required_string_property should add both property and mark as required - let result = ElicitationSchema::builder() - .required_string_property("name", |s| s) - .required_email("email") - .build(); - - assert!(result.is_ok()); - let schema = result.unwrap(); - assert_eq!(schema.properties.len(), 2); - assert!( - schema - .required - .as_ref() - .unwrap() - .contains(&"name".to_string()) - ); - assert!( - schema - .required - .as_ref() - .unwrap() - .contains(&"email".to_string()) - ); -} - -/// Test typed property methods work correctly -#[tokio::test] -async fn test_typed_property_methods() { - let result = ElicitationSchema::builder() - .string_property("name", |s| s.length(1, 100)) - .number_property("price", |n| n.range(0.0, 1000.0)) - .integer_property("quantity", |i| i.range(1, 100)) - .bool_property("in_stock", |b| b.with_default(true)) - .build(); - - assert!(result.is_ok()); - let schema = result.unwrap(); - assert_eq!(schema.properties.len(), 4); - - // Verify types are correct - if let Some(PrimitiveSchemaDefinition::String(_)) = schema.properties.get("name") { - // Expected - } else { - panic!("name should be StringSchema"); - } - - if let Some(PrimitiveSchemaDefinition::Number(_)) = schema.properties.get("price") { - // Expected - } else { - panic!("price should be NumberSchema"); - } - - if let Some(PrimitiveSchemaDefinition::Integer(_)) = schema.properties.get("quantity") { - // Expected - } else { - panic!("quantity should be IntegerSchema"); - } - - if let Some(PrimitiveSchemaDefinition::Boolean(_)) = schema.properties.get("in_stock") { - // Expected - } else { - panic!("in_stock should be BooleanSchema"); - } -} - -/// Test required typed property methods -#[tokio::test] -async fn test_required_typed_property_methods() { - let result = ElicitationSchema::builder() - .required_string_property("name", |s| s) - .required_number_property("price", |n| n) - .required_integer_property("age", |i| i) - .required_bool_property("active", |b| b) - .build(); - - assert!(result.is_ok()); - let schema = result.unwrap(); - assert_eq!(schema.properties.len(), 4); - assert_eq!(schema.required.as_ref().unwrap().len(), 4); - - // All should be marked as required - let required = schema.required.as_ref().unwrap(); - assert!(required.contains(&"name".to_string())); - assert!(required.contains(&"price".to_string())); - assert!(required.contains(&"age".to_string())); - assert!(required.contains(&"active".to_string())); -} - -// ============================================================================= -// URL ELICITATION TESTS -// ============================================================================= - -/// Test URL elicitation request parameter serialization/deserialization -#[tokio::test] -async fn test_url_elicitation_request_param_serialization() { - let request_param = ElicitRequestParams::UrlElicitationParams { - meta: None, - message: "Please visit the following URL to complete verification".to_string(), - url: "https://example.com/verify".to_string(), - elicitation_id: "elicit-123".to_string(), - }; - - // Test serialization - let json = serde_json::to_value(&request_param).unwrap(); - let expected = json!({ - "mode": "url", - "message": "Please visit the following URL to complete verification", - "url": "https://example.com/verify", - "elicitationId": "elicit-123" - }); - - assert_eq!(json, expected); - - // Test deserialization - let deserialized: ElicitRequestParams = serde_json::from_value(expected).unwrap(); - match deserialized { - ElicitRequestParams::UrlElicitationParams { - message, - url, - elicitation_id, - .. - } => { - assert_eq!( - message, - "Please visit the following URL to complete verification" - ); - assert_eq!(url, "https://example.com/verify"); - assert_eq!(elicitation_id, "elicit-123"); - } - _ => panic!("Expected UrlElicitationParams variant"), - } -} - -/// Test URL elicitation request in JSON-RPC protocol -#[tokio::test] -async fn test_url_elicitation_json_rpc_protocol() { - // Create a complete JSON-RPC request for URL elicitation - let request = JsonRpcRequest { - jsonrpc: JsonRpcVersion2_0, - id: RequestId::Number(1), - request: ElicitRequest::new(ElicitRequestParams::UrlElicitationParams { - meta: None, - message: "Please authorize this action at the following URL".to_string(), - url: "https://auth.example.com/authorize/abc123".to_string(), - elicitation_id: "auth-request-456".to_string(), - }), - }; - - // Test serialization of complete request - let json = serde_json::to_value(&request).unwrap(); - assert_eq!(json["jsonrpc"], "2.0"); - assert_eq!(json["id"], 1); - assert_eq!(json["method"], "elicitation/create"); - assert_eq!(json["params"]["mode"], "url"); - assert_eq!( - json["params"]["message"], - "Please authorize this action at the following URL" - ); - assert_eq!( - json["params"]["url"], - "https://auth.example.com/authorize/abc123" - ); - assert_eq!(json["params"]["elicitationId"], "auth-request-456"); - - // Test deserialization - let deserialized: JsonRpcRequest = serde_json::from_value(json).unwrap(); - assert_eq!(deserialized.id, RequestId::Number(1)); - match &deserialized.request.params { - ElicitRequestParams::UrlElicitationParams { - message, - url, - elicitation_id, - .. - } => { - assert_eq!(message, "Please authorize this action at the following URL"); - assert_eq!(url, "https://auth.example.com/authorize/abc123"); - assert_eq!(elicitation_id, "auth-request-456"); - } - _ => panic!("Expected UrlElicitationParams variant"), - } -} - -/// Test UrlElicitationCapability structure and serialization -#[tokio::test] -async fn test_url_elicitation_capability() { - // Test default UrlElicitationCapability - let url_cap = UrlElicitationCapability::default(); - - // Test serialization - should be empty object - let json = serde_json::to_value(&url_cap).unwrap(); - assert_eq!(json, json!({})); - - // Test deserialization - let deserialized: UrlElicitationCapability = serde_json::from_value(json!({})).unwrap(); - assert_eq!(deserialized, url_cap); - - // Test ElicitationCapability with URL mode enabled - let elicitation_cap = ElicitationCapability::new().with_url(UrlElicitationCapability::new()); - - let json = serde_json::to_value(&elicitation_cap).unwrap(); - assert_eq!( - json, - json!({ - "url": {} - }) - ); - - // Test ElicitationCapability with both form and URL modes - let both_cap = ElicitationCapability::new() - .with_form(FormElicitationCapability::new().with_schema_validation(true)) - .with_url(UrlElicitationCapability::new()); - - let json = serde_json::to_value(&both_cap).unwrap(); - assert_eq!( - json, - json!({ - "form": { - "schemaValidation": true - }, - "url": {} - }) - ); -} - -/// Test backward compatibility: ElicitRequestParams without mode tag -#[tokio::test] -async fn test_elicitation_backward_compatibility_no_mode() { - let json_without_mode = json!({ - "message": "Please enter your details", - "requestedSchema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - }); - - let deserialized: ElicitRequestParams = serde_json::from_value(json_without_mode).unwrap(); - - match deserialized { - ElicitRequestParams::FormElicitationParams { - message, - requested_schema, - .. - } => { - assert_eq!(message, "Please enter your details"); - assert_eq!(requested_schema.properties.len(), 1); - assert!(requested_schema.properties.contains_key("name")); - } - _ => panic!("Expected FormElicitationParams for backward compatibility"), - } -} - -/// Test both form and URL elicitation modes in the same test -#[tokio::test] -async fn test_elicitation_both_modes() { - // Form mode - let form_schema = ElicitationSchema::builder() - .required_property( - "email", - PrimitiveSchemaDefinition::String(StringSchema::email()), - ) - .build() - .unwrap(); - - let form_request = ElicitRequestParams::FormElicitationParams { - meta: None, - message: "Enter email".to_string(), - requested_schema: form_schema, - }; - - let form_json = serde_json::to_value(&form_request).unwrap(); - assert_eq!(form_json["mode"], "form"); - assert!(form_json.get("requestedSchema").is_some()); - assert!(form_json.get("url").is_none()); - - // URL mode - let url_request = ElicitRequestParams::UrlElicitationParams { - meta: None, - message: "Visit URL".to_string(), - url: "https://example.com".to_string(), - elicitation_id: "id-123".to_string(), - }; - - let url_json = serde_json::to_value(&url_request).unwrap(); - assert_eq!(url_json["mode"], "url"); - assert!(url_json.get("url").is_some()); - assert!(url_json.get("elicitationId").is_some()); - assert!(url_json.get("requestedSchema").is_none()); -} - -/// Test ClientCapabilities with different elicitation mode combinations -#[tokio::test] -async fn test_client_capabilities_elicitation_modes() { - // Test with form-only capability - let form_only_caps = ClientCapabilities::builder() - .enable_elicitation_with( - ElicitationCapability::new() - .with_form(FormElicitationCapability::new().with_schema_validation(true)), - ) - .build(); - - let json = serde_json::to_value(&form_only_caps).unwrap(); - assert!(json["elicitation"]["form"].is_object()); - assert!( - json["elicitation"]["url"].is_null() - || !json["elicitation"].as_object().unwrap().contains_key("url") - ); - - // Test with URL-only capability - let url_only_caps = ClientCapabilities::builder() - .enable_elicitation_with( - ElicitationCapability::new().with_url(UrlElicitationCapability::new()), - ) - .build(); - - let json = serde_json::to_value(&url_only_caps).unwrap(); - assert!(json["elicitation"]["url"].is_object()); - assert!( - json["elicitation"]["form"].is_null() - || !json["elicitation"] - .as_object() - .unwrap() - .contains_key("form") - ); - - // Test with both capabilities - let both_caps = ClientCapabilities::builder() - .enable_elicitation_with( - ElicitationCapability::new() - .with_form(FormElicitationCapability::new().with_schema_validation(false)) - .with_url(UrlElicitationCapability::new()), - ) - .build(); - - let json = serde_json::to_value(&both_caps).unwrap(); - assert!(json["elicitation"]["form"].is_object()); - assert!(json["elicitation"]["url"].is_object()); -} - -/// Test ElicitationAction with URL elicitation workflow -#[tokio::test] -async fn test_url_elicitation_action_workflow() { - // Test Accept action for URL elicitation (user visited URL and confirmed) - let accept_result = ElicitResult::new(ElicitationAction::Accept); - - let json = serde_json::to_value(&accept_result).unwrap(); - assert_eq!(json["action"], "accept"); - // content should be omitted when None - assert!(json.get("content").is_none() || json["content"].is_null()); - - // Test Decline action for URL elicitation - let decline_result = ElicitResult::new(ElicitationAction::Decline); - - let json = serde_json::to_value(&decline_result).unwrap(); - assert_eq!(json["action"], "decline"); - - // Test Cancel action for URL elicitation - let cancel_result = ElicitResult::new(ElicitationAction::Cancel); - - let json = serde_json::to_value(&cancel_result).unwrap(); - assert_eq!(json["action"], "cancel"); -} - -/// Test method constants for URL elicitation -#[tokio::test] -async fn test_elicitation_method_constants() { - // Test existing methods - assert_eq!(ElicitationCreateRequestMethod::VALUE, "elicitation/create"); - assert_eq!( - ElicitationResponseNotificationMethod::VALUE, - "notifications/elicitation/response" - ); -} diff --git a/vendor/rmcp/tests/test_embedded_resource_meta.rs b/vendor/rmcp/tests/test_embedded_resource_meta.rs deleted file mode 100644 index 5854f3f54..000000000 --- a/vendor/rmcp/tests/test_embedded_resource_meta.rs +++ /dev/null @@ -1,114 +0,0 @@ -use rmcp::model::{ContentBlock, EmbeddedResource, MetaObject, ResourceContents}; -use serde_json::json; - -#[test] -fn serialize_embedded_text_resource_with_meta() { - let mut resource_content_meta = MetaObject::new(); - resource_content_meta.insert("inner".to_string(), json!(2)); - - let mut resource_meta = MetaObject::new(); - resource_meta.insert("top".to_string(), json!(1)); - - let content = ContentBlock::Resource( - EmbeddedResource::new(ResourceContents::TextResourceContents { - uri: "str://example".to_string(), - mime_type: Some("text/plain".to_string()), - text: "hello".to_string(), - meta: Some(resource_content_meta), - }) - .with_meta(resource_meta), - ); - - let v = serde_json::to_value(&content).unwrap(); - - let expected = json!({ - "type": "resource", - "_meta": {"top": 1}, - "resource": { - "uri": "str://example", - "mimeType": "text/plain", - "text": "hello", - "_meta": {"inner": 2} - } - }); - - assert_eq!(v, expected); -} - -#[test] -fn serialize_embedded_text_resource_without_meta_omits_fields() { - let content = ContentBlock::Resource(EmbeddedResource::new( - ResourceContents::TextResourceContents { - uri: "str://no-meta".to_string(), - mime_type: Some("text/plain".to_string()), - text: "hi".to_string(), - meta: None, - }, - )); - - let v = serde_json::to_value(&content).unwrap(); - - assert_eq!(v.get("_meta"), None); - let inner = v.get("resource").and_then(|r| r.as_object()).unwrap(); - assert_eq!(inner.get("_meta"), None); -} - -#[test] -fn deserialize_embedded_text_resource_with_meta() { - let raw = json!({ - "type": "resource", - "_meta": {"x": true}, - "resource": { - "uri": "str://from-json", - "text": "ok", - "_meta": {"y": 42} - } - }); - - let content: ContentBlock = serde_json::from_value(raw).unwrap(); - - let er = match &content { - ContentBlock::Resource(er) => er, - _ => panic!("expected resource"), - }; - - let top = er.meta.as_ref().expect("top-level meta missing"); - assert_eq!(top.get("x").unwrap(), &json!(true)); - - match &er.resource { - ResourceContents::TextResourceContents { - meta, uri, text, .. - } => { - assert_eq!(uri, "str://from-json"); - assert_eq!(text, "ok"); - let inner = meta.as_ref().expect("inner meta missing"); - assert_eq!(inner.get("y").unwrap(), &json!(42)); - } - _ => panic!("expected text resource contents"), - } -} - -#[test] -fn serialize_embedded_blob_resource_with_meta() { - let mut resource_content_meta = MetaObject::new(); - resource_content_meta.insert("blob_inner".to_string(), json!(true)); - - let mut resource_meta = MetaObject::new(); - resource_meta.insert("blob_top".to_string(), json!("t")); - - let content = ContentBlock::Resource( - EmbeddedResource::new(ResourceContents::BlobResourceContents { - uri: "str://blob".to_string(), - mime_type: Some("application/octet-stream".to_string()), - blob: "Zm9v".to_string(), - meta: Some(resource_content_meta), - }) - .with_meta(resource_meta), - ); - - let v = serde_json::to_value(&content).unwrap(); - - assert_eq!(v.get("_meta").unwrap(), &json!({"blob_top": "t"})); - let inner = v.get("resource").unwrap(); - assert_eq!(inner.get("_meta").unwrap(), &json!({"blob_inner": true})); -} diff --git a/vendor/rmcp/tests/test_handler_wrappers.rs b/vendor/rmcp/tests/test_handler_wrappers.rs deleted file mode 100644 index 18ec242ba..000000000 --- a/vendor/rmcp/tests/test_handler_wrappers.rs +++ /dev/null @@ -1,31 +0,0 @@ -// cargo test --test test_handler_wrappers --features "client server" - -mod common; - -use std::sync::Arc; - -use common::handlers::TestServer; -use rmcp::ServerHandler; - -#[test] -fn test_wrapped_server_handlers() { - // This test asserts that, when T: ServerHandler, both Box and Arc also implement ServerHandler. - fn accepts_server_handler(_handler: H) {} - - accepts_server_handler(Box::new(TestServer::new())); - accepts_server_handler(Arc::new(TestServer::new())); -} - -#[cfg(feature = "client")] -#[test] -fn test_wrapped_client_handlers() { - use common::handlers::TestClientHandler; - use rmcp::ClientHandler; - // This test asserts that, when T: ClientHandler, both Box and Arc also implement ClientHandler. - fn accepts_client_handler(_handler: H) {} - - let client = TestClientHandler::new(false, false); - - accepts_client_handler(Box::new(client.clone())); - accepts_client_handler(Arc::new(client)); -} diff --git a/vendor/rmcp/tests/test_inflight_response_drain.rs b/vendor/rmcp/tests/test_inflight_response_drain.rs deleted file mode 100644 index c75e17c42..000000000 --- a/vendor/rmcp/tests/test_inflight_response_drain.rs +++ /dev/null @@ -1,159 +0,0 @@ -#![cfg(all(feature = "client", feature = "server", not(feature = "local")))] -// cargo test --test test_inflight_response_drain --features "client server" - -use std::{ - pin::Pin, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - task::{Context, Poll}, - time::Duration, -}; - -use rmcp::{ - ServerHandler, ServiceExt, - handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{CallToolRequestParams, ClientInfo, ServerCapabilities, ServerInfo}, - service::QuitReason, - tool, tool_handler, tool_router, -}; -use tokio::io::{AsyncRead, ReadBuf}; - -// A slow tool server that sleeps before returning a response. -#[derive(Debug, Clone)] -struct SlowToolServer { - #[expect(dead_code, reason = "tool_handler macro accesses this router field")] - tool_router: ToolRouter, -} - -impl SlowToolServer { - fn new() -> Self { - Self { - tool_router: Self::tool_router(), - } - } -} - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -struct SlowToolRequest { - #[schemars(description = "how long to sleep in milliseconds")] - sleep_ms: u64, -} - -#[tool_router] -impl SlowToolServer { - #[tool(description = "A tool that sleeps then returns")] - async fn slow_tool( - &self, - Parameters(SlowToolRequest { sleep_ms }): Parameters, - ) -> String { - tokio::time::sleep(Duration::from_millis(sleep_ms)).await; - format!("done after {}ms", sleep_ms) - } -} - -#[tool_handler] -impl ServerHandler for SlowToolServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - } -} - -#[derive(Debug, Clone, Default)] -struct DummyClientHandler; - -impl rmcp::ClientHandler for DummyClientHandler { - fn get_info(&self) -> ClientInfo { - ClientInfo::default() - } -} - -/// An `AsyncRead` wrapper that delegates to the inner reader until signalled, -/// then returns EOF (read 0 bytes). -struct ClosableReader { - inner: R, - eof_flag: Arc, -} - -impl AsyncRead for ClosableReader { - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - if self.eof_flag.load(Ordering::Acquire) { - return Poll::Ready(Ok(())); - } - Pin::new(&mut self.inner).poll_read(cx, buf) - } -} - -/// When the server's input stream returns EOF while a tool handler is still -/// in-flight, the drain phase should flush pending responses before closing. -#[tokio::test] -async fn test_inflight_response_drain_on_eof() -> anyhow::Result<()> { - // Two unidirectional channels: - // client_write → server_read (client sends requests to server) - // server_write → client_read (server sends responses to client) - let (client_write, server_read) = tokio::io::duplex(4096); - let (server_write, client_read) = tokio::io::duplex(4096); - - // Wrap the server's read side so we can signal EOF from the test. - let eof_flag = Arc::new(AtomicBool::new(false)); - let closable_read = ClosableReader { - inner: server_read, - eof_flag: eof_flag.clone(), - }; - - let server_transport = (closable_read, server_write); - let client_transport = (client_read, client_write); - - // Start server with slow tool handler - let server_handle = tokio::spawn(async move { - let server = SlowToolServer::new(); - let running = server.serve(server_transport).await?; - let reason = running.waiting().await?; - assert!( - matches!(reason, QuitReason::Closed), - "expected Closed quit reason, got {:?}", - reason, - ); - anyhow::Ok(()) - }); - - // Start client - let client = DummyClientHandler.serve(client_transport).await?; - - // Call the slow tool (200ms sleep). Concurrently, signal the server's - // read side to return EOF after the request has been sent but before - // the handler finishes. - let tool_future = client.call_tool( - CallToolRequestParams::new("slow_tool").with_arguments( - serde_json::json!({ "sleep_ms": 200 }) - .as_object() - .unwrap() - .clone(), - ), - ); - - let (tool_result, _) = tokio::join!(tool_future, async { - // Wait for the request to be sent and received by the server, - // then signal EOF on the server's read side. - tokio::time::sleep(Duration::from_millis(50)).await; - eof_flag.store(true, Ordering::Release); - }); - - // The tool result should still arrive thanks to the drain phase. - let result = tool_result?; - let text = result - .content - .first() - .and_then(|c| c.as_text()) - .map(|t| t.text.as_str()) - .expect("expected text content in tool result"); - assert_eq!(text, "done after 200ms"); - - server_handle.await??; - Ok(()) -} diff --git a/vendor/rmcp/tests/test_json_schema_detection.rs b/vendor/rmcp/tests/test_json_schema_detection.rs deleted file mode 100644 index e13125c40..000000000 --- a/vendor/rmcp/tests/test_json_schema_detection.rs +++ /dev/null @@ -1,188 +0,0 @@ -#![allow(clippy::exhaustive_structs)] -//cargo test --test test_json_schema_detection --features "client server macros" -use rmcp::{ - Json, ServerHandler, handler::server::router::tool::ToolRouter, tool, tool_handler, tool_router, -}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize, JsonSchema)] -pub struct TestData { - pub value: String, -} - -#[tool_handler(router = self.tool_router)] -impl ServerHandler for TestServer {} - -#[derive(Debug, Clone)] -pub struct TestServer { - tool_router: ToolRouter, -} - -impl Default for TestServer { - fn default() -> Self { - Self::new() - } -} - -#[tool_router(router = tool_router)] -impl TestServer { - pub fn new() -> Self { - Self { - tool_router: Self::tool_router(), - } - } - - /// Tool that returns Json - should have output schema - #[tool(name = "with-json")] - pub async fn with_json(&self) -> Result, String> { - Ok(Json(TestData { - value: "test".to_string(), - })) - } - - /// Tool that returns regular type - should NOT have output schema - #[tool(name = "without-json")] - pub async fn without_json(&self) -> Result { - Ok("test".to_string()) - } - - /// Tool that returns Result with inner Json - should have output schema - #[tool(name = "result-with-json")] - pub async fn result_with_json(&self) -> Result, rmcp::ErrorData> { - Ok(Json(TestData { - value: "test".to_string(), - })) - } - - /// Tool with explicit output_schema attribute - should have output schema - #[tool(name = "explicit-schema", output_schema = rmcp::handler::server::tool::schema_for_type::())] - pub async fn explicit_schema(&self) -> Result { - Ok("test".to_string()) - } - - /// Tool that returns Json> - array output schema - #[tool(name = "with-json-array")] - pub async fn with_json_array(&self) -> Result>, String> { - Ok(Json(vec![TestData { - value: "test".to_string(), - }])) - } - - /// Tool that returns Result>, ErrorData> - array output schema - #[tool(name = "result-with-json-array")] - pub async fn result_with_json_array(&self) -> Result>, rmcp::ErrorData> { - Ok(Json(vec![TestData { - value: "test".to_string(), - }])) - } - - /// Tool that returns Json - string output schema - #[tool(name = "with-json-string")] - pub async fn with_json_string(&self) -> Result, String> { - Ok(Json("test".to_string())) - } -} - -#[tokio::test] -async fn test_json_type_generates_schema() { - let server = TestServer::new(); - let tools = server.tool_router.list_all(); - - // Find the with-json tool - let json_tool = tools.iter().find(|t| t.name == "with-json").unwrap(); - assert!( - json_tool.output_schema.is_some(), - "Json return type should generate output schema" - ); -} - -#[tokio::test] -async fn test_non_json_type_no_schema() { - let server = TestServer::new(); - let tools = server.tool_router.list_all(); - - // Find the without-json tool - let non_json_tool = tools.iter().find(|t| t.name == "without-json").unwrap(); - assert!( - non_json_tool.output_schema.is_none(), - "Regular return type should NOT generate output schema" - ); -} - -#[tokio::test] -async fn test_result_with_json_generates_schema() { - let server = TestServer::new(); - let tools = server.tool_router.list_all(); - - // Find the result-with-json tool - let result_json_tool = tools.iter().find(|t| t.name == "result-with-json").unwrap(); - assert!( - result_json_tool.output_schema.is_some(), - "Result, E> return type should generate output schema" - ); -} - -#[tokio::test] -async fn test_explicit_schema_override() { - let server = TestServer::new(); - let tools = server.tool_router.list_all(); - - // Find the explicit-schema tool - let explicit_tool = tools.iter().find(|t| t.name == "explicit-schema").unwrap(); - assert!( - explicit_tool.output_schema.is_some(), - "Explicit output_schema attribute should work" - ); -} - -#[tokio::test] -async fn test_json_array_type_generates_schema() { - let server = TestServer::new(); - let tools = server.tool_router.list_all(); - - let array_tool = tools.iter().find(|t| t.name == "with-json-array").unwrap(); - assert!( - array_tool.output_schema.is_some(), - "Json> return type should generate output schema" - ); - let schema = array_tool.output_schema.as_ref().unwrap(); - assert_eq!( - schema.get("type").and_then(|v| v.as_str()), - Some("array"), - "Json> should produce an array schema" - ); -} - -#[tokio::test] -async fn test_result_with_json_array_generates_schema() { - let server = TestServer::new(); - let tools = server.tool_router.list_all(); - - let result_array_tool = tools - .iter() - .find(|t| t.name == "result-with-json-array") - .unwrap(); - assert!( - result_array_tool.output_schema.is_some(), - "Result>, ErrorData> return type should generate output schema" - ); -} - -#[tokio::test] -async fn test_json_string_type_generates_schema() { - let server = TestServer::new(); - let tools = server.tool_router.list_all(); - - let string_tool = tools.iter().find(|t| t.name == "with-json-string").unwrap(); - assert!( - string_tool.output_schema.is_some(), - "Json return type should generate output schema" - ); - let schema = string_tool.output_schema.as_ref().unwrap(); - assert_eq!( - schema.get("type").and_then(|v| v.as_str()), - Some("string"), - "Json should produce a string schema" - ); -} diff --git a/vendor/rmcp/tests/test_list_tools_result.rs b/vendor/rmcp/tests/test_list_tools_result.rs deleted file mode 100644 index 4d8dc70aa..000000000 --- a/vendor/rmcp/tests/test_list_tools_result.rs +++ /dev/null @@ -1,48 +0,0 @@ -#![cfg(all(feature = "server", feature = "macros", not(feature = "local")))] - -use rmcp::{ - Json, - handler::server::wrapper::Parameters, - model::{ListToolsResult, NumberOrString, ServerJsonRpcMessage, ServerResult}, -}; - -/// Parameters for adding two numbers. -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -struct AddRequest { - /// The left-hand number. - a: f64, - /// The right-hand number. - b: f64, -} - -/// Result of adding two numbers. -#[derive(Debug, serde::Serialize, schemars::JsonSchema)] -struct AddResult { - /// The sum of the two numbers. - sum: f64, -} - -/// Add two numbers. -#[rmcp::tool] -fn add(Parameters(AddRequest { a, b }): Parameters) -> Json { - Json(AddResult { sum: a + b }) -} - -#[test] -fn list_tools_result_matches_expected_json() { - let expected_json = std::fs::read("tests/test_list_tools_result/list_tools_result.json") - .expect("missing expected list tools result JSON fixture"); - let expected: serde_json::Value = - serde_json::from_slice(&expected_json).expect("invalid expected JSON fixture"); - - assert_eq!(add(Parameters(AddRequest { a: 1.0, b: 2.0 })).0.sum, 3.0); - - let result = ListToolsResult::with_all_items(vec![add_tool_attr()]); - let response = ServerJsonRpcMessage::response( - ServerResult::ListToolsResult(result), - NumberOrString::Number(2), - ); - - let actual = serde_json::to_value(response).expect("failed to serialize list tools response"); - assert_eq!(actual, expected); -} diff --git a/vendor/rmcp/tests/test_list_tools_result/list_tools_result.json b/vendor/rmcp/tests/test_list_tools_result/list_tools_result.json deleted file mode 100644 index c6616b596..000000000 --- a/vendor/rmcp/tests/test_list_tools_result/list_tools_result.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "result": { - "resultType": "complete", - "tools": [ - { - "name": "add", - "description": "Add two numbers.", - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "a": { - "description": "The left-hand number.", - "format": "double", - "type": "number" - }, - "b": { - "description": "The right-hand number.", - "format": "double", - "type": "number" - } - }, - "required": [ - "a", - "b" - ] - }, - "outputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "sum": { - "description": "The sum of the two numbers.", - "format": "double", - "type": "number" - } - }, - "required": [ - "sum" - ] - } - } - ] - }, - "jsonrpc": "2.0", - "id": 2 -} diff --git a/vendor/rmcp/tests/test_local_client_handler.rs b/vendor/rmcp/tests/test_local_client_handler.rs deleted file mode 100644 index e85d3d8c9..000000000 --- a/vendor/rmcp/tests/test_local_client_handler.rs +++ /dev/null @@ -1,16 +0,0 @@ -use std::rc::Rc; - -use rmcp::ClientHandler; - -struct LocalClientHandler { - _state: Rc<()>, -} - -impl ClientHandler for LocalClientHandler {} - -#[test] -fn client_handler_accepts_non_send_sync_state_with_local_feature() { - fn assert_client_handler() {} - - assert_client_handler::(); -} diff --git a/vendor/rmcp/tests/test_logging.rs b/vendor/rmcp/tests/test_logging.rs deleted file mode 100644 index bf00352bc..000000000 --- a/vendor/rmcp/tests/test_logging.rs +++ /dev/null @@ -1,344 +0,0 @@ -// cargo test --features "server client" --package rmcp test_logging -#![cfg(not(feature = "local"))] -#![allow(deprecated)] -mod common; - -use std::sync::{Arc, Mutex}; - -use common::handlers::{TestClientHandler, TestServer}; -use rmcp::{ - ServiceExt, - model::{LoggingLevel, LoggingMessageNotificationParam, SetLevelRequestParams}, -}; -use serde_json::json; -use tokio::sync::Notify; - -#[tokio::test] -async fn test_logging_spec_compliance() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let receive_signal = Arc::new(Notify::new()); - let received_messages = Arc::new(Mutex::new(Vec::::new())); - - // Start server in a separate task - let server_handle = tokio::spawn(async move { - let server = TestServer::new().serve(server_transport).await?; - - // Test server can send messages before level is set - server - .peer() - .notify_logging_message( - LoggingMessageNotificationParam::new( - LoggingLevel::Info, - serde_json::json!({ - "message": "Server initiated message", - "timestamp": chrono::Utc::now().to_rfc3339(), - }), - ) - .with_logger("test_server"), - ) - .await?; - - server.waiting().await?; - anyhow::Ok(()) - }); - - let client = TestClientHandler::with_notification( - true, - true, - receive_signal.clone(), - received_messages.clone(), - ) - .serve(client_transport) - .await?; - - // Wait for the initial server message - receive_signal.notified().await; - { - let mut messages = received_messages.lock().unwrap(); - assert_eq!(messages.len(), 1, "Should receive server-initiated message"); - messages.clear(); - } - - // Test level filtering and message format - for level in [ - LoggingLevel::Emergency, - LoggingLevel::Warning, - LoggingLevel::Debug, - ] { - client - .peer() - .set_level(SetLevelRequestParams::new(level)) - .await?; - - // Wait for each message response - receive_signal.notified().await; - - let mut messages = received_messages.lock().unwrap(); - let msg = messages.last().unwrap(); - - // Verify required fields - assert_eq!(msg.level, level); - assert!(msg.data.is_object()); - - // Verify data format - let data = msg.data.as_object().unwrap(); - assert!(data.contains_key("message")); - assert!(data.contains_key("timestamp")); - - // Verify timestamp - let timestamp = data["timestamp"].as_str().unwrap(); - chrono::DateTime::parse_from_rfc3339(timestamp).expect("RFC3339 timestamp"); - - messages.clear(); - } - - // Important: Cancel the client before ending the test - client.cancel().await?; - - // Wait for server to complete - server_handle.await??; - - Ok(()) -} - -#[tokio::test] -async fn test_logging_user_scenarios() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let receive_signal = Arc::new(Notify::new()); - let received_messages = Arc::new(Mutex::new(Vec::::new())); - - let server_handle = tokio::spawn(async move { - let server = TestServer::new().serve(server_transport).await?; - server.waiting().await?; - anyhow::Ok(()) - }); - - let client = TestClientHandler::with_notification( - true, - true, - receive_signal.clone(), - received_messages.clone(), - ) - .serve(client_transport) - .await?; - - // Test 1: Error reporting scenario - client - .peer() - .set_level(SetLevelRequestParams::new(LoggingLevel::Error)) - .await?; - receive_signal.notified().await; // Wait for response - { - let messages = received_messages.lock().unwrap(); - let msg = &messages[0]; - let data = msg.data.as_object().unwrap(); - assert!( - data.contains_key("error_code"), - "Error should have an error code" - ); - assert!( - data.contains_key("error_details"), - "Error should have details" - ); - assert!( - data.contains_key("timestamp"), - "Should know when error occurred" - ); - } - - // Test 2: Debug scenario - client - .peer() - .set_level(SetLevelRequestParams::new(LoggingLevel::Debug)) - .await?; - receive_signal.notified().await; // Wait for response - { - let messages = received_messages.lock().unwrap(); - let msg = messages.last().unwrap(); - let data = msg.data.as_object().unwrap(); - assert!( - data.contains_key("function"), - "Debug should show function name" - ); - assert!(data.contains_key("line"), "Debug should show line number"); - assert!( - data.contains_key("context"), - "Debug should show execution context" - ); - } - - // Test 3: Production monitoring scenario - client - .peer() - .set_level(SetLevelRequestParams::new(LoggingLevel::Info)) - .await?; - receive_signal.notified().await; // Wait for response - { - let messages = received_messages.lock().unwrap(); - let msg = messages.last().unwrap(); - let data = msg.data.as_object().unwrap(); - assert!(data.contains_key("status"), "Should show system status"); - assert!(data.contains_key("metrics"), "Should include metrics"); - } - - // Important: Cancel client and wait for server before ending - client.cancel().await?; - server_handle.await??; - - Ok(()) -} - -#[test] -fn test_logging_level_serialization() { - // Test all levels match spec exactly - let test_cases = [ - (LoggingLevel::Alert, "alert"), - (LoggingLevel::Critical, "critical"), - (LoggingLevel::Debug, "debug"), - (LoggingLevel::Emergency, "emergency"), - (LoggingLevel::Error, "error"), - (LoggingLevel::Info, "info"), - (LoggingLevel::Notice, "notice"), - (LoggingLevel::Warning, "warning"), - ]; - - for (level, expected) in test_cases { - let serialized = serde_json::to_string(&level).unwrap(); - // Remove quotes from serialized string - let serialized = serialized.trim_matches('"'); - assert_eq!( - serialized, expected, - "LoggingLevel::{:?} should serialize to \"{}\"", - level, expected - ); - } - - // Test deserialization from spec strings - for (level, spec_string) in test_cases { - let deserialized: LoggingLevel = - serde_json::from_str(&format!("\"{}\"", spec_string)).unwrap(); - assert_eq!( - deserialized, level, - "\"{}\" should deserialize to LoggingLevel::{:?}", - spec_string, level - ); - } -} - -#[tokio::test] -async fn test_logging_edge_cases() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let receive_signal = Arc::new(Notify::new()); - let received_messages = Arc::new(Mutex::new(Vec::::new())); - - let server_handle = tokio::spawn(async move { - let server = TestServer::new().serve(server_transport).await?; - server.waiting().await?; - anyhow::Ok(()) - }); - - let client = TestClientHandler::with_notification( - true, - true, - receive_signal.clone(), - received_messages.clone(), - ) - .serve(client_transport) - .await?; - - // Test all logging levels from spec - for level in [ - LoggingLevel::Alert, - LoggingLevel::Critical, - LoggingLevel::Notice, // These weren't tested before - ] { - client - .peer() - .set_level(SetLevelRequestParams::new(level)) - .await?; - receive_signal.notified().await; - - let messages = received_messages.lock().unwrap(); - let msg = messages.last().unwrap(); - assert_eq!(msg.level, level); - } - - client.cancel().await?; - server_handle.await??; - Ok(()) -} - -#[tokio::test] -async fn test_logging_optional_fields() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let receive_signal = Arc::new(Notify::new()); - let received_messages = Arc::new(Mutex::new(Vec::::new())); - - let server_handle = tokio::spawn(async move { - let server = TestServer::new().serve(server_transport).await?; - - // Test message with and without optional logger field - for (level, has_logger) in [(LoggingLevel::Info, true), (LoggingLevel::Debug, false)] { - let mut param = LoggingMessageNotificationParam::new(level, json!({"test": "data"})); - param.logger = has_logger.then(|| "test_logger".to_string()); - server.peer().notify_logging_message(param).await?; - } - - server.waiting().await?; - anyhow::Ok(()) - }); - - let client = TestClientHandler::with_notification( - true, - true, - receive_signal.clone(), - received_messages.clone(), - ) - .serve(client_transport) - .await?; - - // Wait for the initial server message - receive_signal.notified().await; - { - let mut messages = received_messages.lock().unwrap(); - assert_eq!(messages.len(), 2, "Should receive two messages"); - messages.clear(); - } - - // Test level filtering and message format - for level in [LoggingLevel::Info, LoggingLevel::Debug] { - client - .peer() - .set_level(SetLevelRequestParams::new(level)) - .await?; - - // Wait for each message response - receive_signal.notified().await; - - let mut messages = received_messages.lock().unwrap(); - let msg = messages.last().unwrap(); - - // Verify required fields - assert_eq!(msg.level, level); - assert!(msg.data.is_object()); - - // Verify data format - let data = msg.data.as_object().unwrap(); - assert!(data.contains_key("message")); - assert!(data.contains_key("timestamp")); - - // Verify timestamp - let timestamp = data["timestamp"].as_str().unwrap(); - chrono::DateTime::parse_from_rfc3339(timestamp).expect("RFC3339 timestamp"); - - messages.clear(); - } - - // Important: Cancel the client before ending the test - client.cancel().await?; - - // Wait for server to complete - server_handle.await??; - - Ok(()) -} diff --git a/vendor/rmcp/tests/test_message_protocol.rs b/vendor/rmcp/tests/test_message_protocol.rs deleted file mode 100644 index f42cfe0a1..000000000 --- a/vendor/rmcp/tests/test_message_protocol.rs +++ /dev/null @@ -1,426 +0,0 @@ -//cargo test --test test_message_protocol --features "client server" -#![cfg(not(feature = "local"))] -#![expect(deprecated)] // exercises SEP-2577-deprecated Sampling/Roots/Logging types - -mod common; -use common::handlers::{TestClientHandler, TestServer}; -use rmcp::{ - ServiceExt, - model::*, - service::{RequestContext, Service}, -}; - -// Tests start here -#[tokio::test] -async fn test_message_roles() { - let messages = vec![ - SamplingMessage::user_text("user message"), - SamplingMessage::assistant_text("assistant message"), - ]; - - // Verify all roles can be serialized/deserialized correctly - let json = serde_json::to_string(&messages).unwrap(); - let deserialized: Vec = serde_json::from_str(&json).unwrap(); - assert_eq!(messages, deserialized); -} - -#[tokio::test] -async fn test_context_inclusion_integration() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - // Start server - let server_handle = tokio::spawn(async move { - let server = TestServer::new().serve(server_transport).await?; - server.waiting().await?; - anyhow::Ok(()) - }); - - // Start client that honors context requests - let handler = TestClientHandler::new(true, true); - let client = handler.clone().serve(client_transport).await?; - - // Test ThisServer context inclusion - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( - CreateMessageRequestParams::new(vec![SamplingMessage::user_text("test message")], 100) - .with_include_context(ContextInclusion::ThisServer), - )); - - let result = handler - .handle_request( - request.clone(), - RequestContext::new(NumberOrString::Number(1), client.peer().clone()), - ) - .await?; - - if let ClientResult::CreateMessageResult(result) = result { - let text = result - .message - .content - .first() - .unwrap() - .as_text() - .unwrap() - .text - .as_str(); - assert!( - text.contains("test context"), - "Response should include context for ThisServer" - ); - } else { - panic!("Expected CreateMessageResult"); - } - - // Test AllServers context inclusion - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( - CreateMessageRequestParams::new(vec![SamplingMessage::user_text("test message")], 100) - .with_include_context(ContextInclusion::AllServers), - )); - - let result = handler - .handle_request( - request.clone(), - RequestContext::new(NumberOrString::Number(2), client.peer().clone()), - ) - .await?; - - if let ClientResult::CreateMessageResult(result) = result { - let text = result - .message - .content - .first() - .unwrap() - .as_text() - .unwrap() - .text - .as_str(); - assert!( - text.contains("test context"), - "Response should include context for AllServers" - ); - } else { - panic!("Expected CreateMessageResult"); - } - - // Test No context inclusion - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( - CreateMessageRequestParams::new(vec![SamplingMessage::user_text("test message")], 100) - .with_include_context(ContextInclusion::None), - )); - - let result = handler - .handle_request( - request.clone(), - RequestContext::new(NumberOrString::Number(3), client.peer().clone()), - ) - .await?; - - if let ClientResult::CreateMessageResult(result) = result { - let text = result - .message - .content - .first() - .unwrap() - .as_text() - .unwrap() - .text - .as_str(); - assert!( - !text.contains("test context"), - "Response should not include context for None" - ); - } else { - panic!("Expected CreateMessageResult"); - } - - client.cancel().await?; - server_handle.await??; - Ok(()) -} - -#[tokio::test] -async fn test_context_inclusion_ignored_integration() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - // Start server - let server_handle = tokio::spawn(async move { - let server = TestServer::new().serve(server_transport).await?; - server.waiting().await?; - anyhow::Ok(()) - }); - - // Start client that ignores context requests - let handler = TestClientHandler::new(false, false); - let client = handler.clone().serve(client_transport).await?; - - // Test that context requests are ignored - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( - CreateMessageRequestParams::new(vec![SamplingMessage::user_text("test message")], 100) - .with_include_context(ContextInclusion::ThisServer), - )); - - let result = handler - .handle_request( - request.clone(), - RequestContext::new(NumberOrString::Number(1), client.peer().clone()), - ) - .await?; - - if let ClientResult::CreateMessageResult(result) = result { - let text = result - .message - .content - .first() - .unwrap() - .as_text() - .unwrap() - .text - .as_str(); - assert!( - !text.contains("test context"), - "Context should be ignored when client chooses not to honor requests" - ); - } else { - panic!("Expected CreateMessageResult"); - } - - client.cancel().await?; - server_handle.await??; - Ok(()) -} - -#[tokio::test] -async fn test_message_sequence_integration() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - // Start server - let server_handle = tokio::spawn(async move { - let server = TestServer::new().serve(server_transport).await?; - server.waiting().await?; - anyhow::Ok(()) - }); - - // Start client - let handler = TestClientHandler::new(true, true); - let client = handler.clone().serve(client_transport).await?; - - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( - CreateMessageRequestParams::new( - vec![ - SamplingMessage::user_text("first message"), - SamplingMessage::assistant_text("second message"), - ], - 100, - ) - .with_include_context(ContextInclusion::ThisServer), - )); - - let result = handler - .handle_request( - request.clone(), - RequestContext::new(NumberOrString::Number(1), client.peer().clone()), - ) - .await?; - - if let ClientResult::CreateMessageResult(result) = result { - let text = result - .message - .content - .first() - .unwrap() - .as_text() - .unwrap() - .text - .as_str(); - assert!( - text.contains("test context"), - "Response should include context when ThisServer is specified" - ); - assert_eq!(result.model, "test-model"); - assert_eq!( - result.stop_reason, - Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()) - ); - } else { - panic!("Expected CreateMessageResult"); - } - - client.cancel().await?; - server_handle.await??; - Ok(()) -} - -#[tokio::test] -async fn test_message_sequence_validation_integration() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - let server_handle = tokio::spawn(async move { - let server = TestServer::new().serve(server_transport).await?; - server.waiting().await?; - anyhow::Ok(()) - }); - - let handler = TestClientHandler::new(true, true); - let client = handler.clone().serve(client_transport).await?; - - // Test valid sequence: User -> Assistant -> User - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( - CreateMessageRequestParams::new( - vec![ - SamplingMessage::user_text("first user message"), - SamplingMessage::assistant_text("first assistant response"), - SamplingMessage::user_text("second user message"), - ], - 100, - ), - )); - - let result = handler - .handle_request( - request.clone(), - RequestContext::new(NumberOrString::Number(1), client.peer().clone()), - ) - .await?; - - assert!(matches!(result, ClientResult::CreateMessageResult(_))); - - // Test invalid: No user message - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( - CreateMessageRequestParams::new( - vec![SamplingMessage::assistant_text("assistant message")], - 100, - ), - )); - - let result = handler - .handle_request( - request.clone(), - RequestContext::new(NumberOrString::Number(2), client.peer().clone()), - ) - .await; - - assert!(result.is_err()); - - client.cancel().await?; - server_handle.await??; - Ok(()) -} - -#[tokio::test] -async fn test_selective_context_handling_integration() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - let server_handle = tokio::spawn(async move { - let server = TestServer::new().serve(server_transport).await?; - server.waiting().await?; - anyhow::Ok(()) - }); - - // Client that only honors ThisServer but ignores AllServers - let handler = TestClientHandler::new(true, false); - let client = handler.clone().serve(client_transport).await?; - - // Test ThisServer is honored - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( - CreateMessageRequestParams::new(vec![SamplingMessage::user_text("test message")], 100) - .with_include_context(ContextInclusion::ThisServer), - )); - - let result = handler - .handle_request( - request.clone(), - RequestContext::new(NumberOrString::Number(1), client.peer().clone()), - ) - .await?; - - if let ClientResult::CreateMessageResult(result) = result { - let text = result - .message - .content - .first() - .unwrap() - .as_text() - .unwrap() - .text - .as_str(); - assert!( - text.contains("test context"), - "ThisServer context request should be honored" - ); - } - - // Test AllServers is ignored - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( - CreateMessageRequestParams::new(vec![SamplingMessage::user_text("test message")], 100) - .with_include_context(ContextInclusion::AllServers), - )); - - let result = handler - .handle_request( - request.clone(), - RequestContext::new(NumberOrString::Number(2), client.peer().clone()), - ) - .await?; - - if let ClientResult::CreateMessageResult(result) = result { - let text = result - .message - .content - .first() - .unwrap() - .as_text() - .unwrap() - .text - .as_str(); - assert!( - !text.contains("test context"), - "AllServers context request should be ignored" - ); - } - - client.cancel().await?; - server_handle.await??; - Ok(()) -} - -#[tokio::test] -async fn test_context_inclusion() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let server_handle = tokio::spawn(async move { - let server = TestServer::new().serve(server_transport).await?; - server.waiting().await?; - anyhow::Ok(()) - }); - - let handler = TestClientHandler::new(true, true); - let client = handler.clone().serve(client_transport).await?; - - // Test context handling - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( - CreateMessageRequestParams::new(vec![SamplingMessage::user_text("test")], 100) - .with_include_context(ContextInclusion::ThisServer), - )); - - let result = handler - .handle_request( - request.clone(), - RequestContext::new(NumberOrString::Number(1), client.peer().clone()), - ) - .await?; - - if let ClientResult::CreateMessageResult(result) = result { - let text = result - .message - .content - .first() - .unwrap() - .as_text() - .unwrap() - .text - .as_str(); - assert!(text.contains("test context")); - } - - client.cancel().await?; - server_handle.await??; - Ok(()) -} diff --git a/vendor/rmcp/tests/test_message_schema.rs b/vendor/rmcp/tests/test_message_schema.rs deleted file mode 100644 index 027a2cc86..000000000 --- a/vendor/rmcp/tests/test_message_schema.rs +++ /dev/null @@ -1,139 +0,0 @@ -mod tests { - use rmcp::model::{ClientJsonRpcMessage, ServerJsonRpcMessage}; - use schemars::generate::SchemaSettings; - - fn compare_schemas(name: &str, actual: &str, expected_file: &str) { - let expected = match std::fs::read_to_string(expected_file) { - Ok(content) => content, - Err(e) => { - panic!( - "Failed to read expected schema file {}: {}", - expected_file, e - ); - } - }; - - let actual_json: serde_json::Value = - serde_json::from_str(actual).expect("Failed to parse actual schema as JSON"); - let expected_json: serde_json::Value = - serde_json::from_str(&expected).expect("Failed to parse expected schema as JSON"); - - if actual_json == expected_json { - println!("{} schema matches expected", name); - return; - } - - // Write current schema to file for comparison - let current_file = expected_file.replace(".json", "_current.json"); - std::fs::write(¤t_file, actual).expect("Failed to write current schema"); - - println!("{} schema differs from expected", name); - println!("Expected: {}", expected_file); - println!("Current: {}", current_file); - println!( - "Run 'diff {} {}' to see differences", - expected_file, current_file - ); - - // UPDATE_SCHEMA=1 cargo test -p rmcp --test test_message_schema --features="server client schemars" - if std::env::var("UPDATE_SCHEMA").is_ok() { - println!("UPDATE_SCHEMA is set, updating expected file"); - std::fs::write(expected_file, actual).expect("Failed to update expected schema file"); - println!("Updated {}", expected_file); - } else { - println!("Set UPDATE_SCHEMA=1 to auto-update expected schemas"); - panic!("Schema validation failed"); - } - } - - #[test] - fn test_client_json_rpc_message_schema() { - let settings = SchemaSettings::draft07(); - let schema = settings - .into_generator() - .into_root_schema_for::(); - let schema_str = serde_json::to_string_pretty(&schema).expect("Failed to serialize schema"); - - compare_schemas( - "ClientJsonRpcMessage", - &schema_str, - "tests/test_message_schema/client_json_rpc_message_schema.json", - ); - } - - /// The three metadata definitions must expose the MCP 2026-07-28 - /// vocabulary: `MetaObject` is an open map, `RequestMetaObject` reserves - /// `progressToken` plus the SEP-2575 keys, and `NotificationMetaObject` - /// reserves `io.modelcontextprotocol/subscriptionId`. Version-specific - /// required keys stay optional because rmcp generates one schema shared - /// by every supported protocol version; current-version validation is - /// a runtime concern (`RequestMetaObject::missing_required_keys`). - #[test] - fn test_metadata_definitions_match_2026_07_28_schema() { - let settings = SchemaSettings::draft07(); - let schema = settings - .into_generator() - .into_root_schema_for::(); - let schema = serde_json::to_value(&schema).expect("Failed to serialize schema"); - let definitions = &schema["definitions"]; - - assert_eq!( - definitions["MetaObject"], - serde_json::json!({ - "description": "See [MCP general fields](https://modelcontextprotocol.io/specification/2026-07-28/basic#general-fields) for notes on _meta usage.", - "type": "object", - "additionalProperties": true, - }) - ); - - assert_eq!( - definitions["RequestMetaObject"], - serde_json::json!({ - "description": "Metadata reserved by MCP on requests. Extension keys are also allowed.", - "type": "object", - "properties": { - "progressToken": { "$ref": "#/definitions/ProgressToken" }, - "io.modelcontextprotocol/protocolVersion": { "type": "string" }, - "io.modelcontextprotocol/clientInfo": { "$ref": "#/definitions/Implementation" }, - "io.modelcontextprotocol/clientCapabilities": { "$ref": "#/definitions/ClientCapabilities" }, - "io.modelcontextprotocol/logLevel": { "$ref": "#/definitions/LoggingLevel" }, - }, - "additionalProperties": true, - }) - ); - - assert_eq!( - definitions["NotificationMetaObject"], - serde_json::json!({ - "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", - "type": "object", - "properties": { - "io.modelcontextprotocol/subscriptionId": { "$ref": "#/definitions/NumberOrString" }, - }, - "additionalProperties": true, - }) - ); - } - - #[test] - fn test_server_json_rpc_message_schema() { - let settings = SchemaSettings::draft07(); - let schema = settings - .into_generator() - .into_root_schema_for::(); - let schema_value = - serde_json::to_value(&schema).expect("Failed to serialize server schema"); - let discover_result = &schema_value["definitions"]["DiscoverResult"]; - assert!( - discover_result["properties"].get("serverInfo").is_none(), - "DiscoverResult serverInfo belongs in namespaced _meta" - ); - let schema_str = serde_json::to_string_pretty(&schema).expect("Failed to serialize schema"); - - compare_schemas( - "ServerJsonRpcMessage", - &schema_str, - "tests/test_message_schema/server_json_rpc_message_schema.json", - ); - } -} diff --git a/vendor/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/vendor/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json deleted file mode 100644 index 760d9e18c..000000000 --- a/vendor/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ /dev/null @@ -1,2447 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "JsonRpcMessage", - "description": "Represents any JSON-RPC message that can be sent or received.\n\nThis enum covers all possible message types in the JSON-RPC protocol:\nindividual requests/responses, notifications, and errors.\nIt serves as the top-level message container for MCP communication.", - "anyOf": [ - { - "description": "A single request expecting a response", - "allOf": [ - { - "$ref": "#/definitions/JsonRpcRequest" - } - ] - }, - { - "description": "A response to a previous request", - "allOf": [ - { - "$ref": "#/definitions/JsonRpcResponse" - } - ] - }, - { - "description": "A one-way notification (no response expected)", - "allOf": [ - { - "$ref": "#/definitions/JsonRpcNotification" - } - ] - }, - { - "description": "An error response", - "allOf": [ - { - "$ref": "#/definitions/JsonRpcError" - } - ] - } - ], - "definitions": { - "Annotations": { - "description": "Optional annotations for the client. The client can use annotations to inform how objects are\nused or displayed.", - "type": "object", - "properties": { - "audience": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Role" - } - }, - "lastModified": { - "type": [ - "string", - "null" - ] - }, - "priority": { - "type": [ - "number", - "null" - ], - "format": "float" - } - } - }, - "ArgumentInfo": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": [ - "name", - "value" - ] - }, - "AudioContent": { - "description": "Audio content with base64-encoded data (spec `AudioContent`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this content.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "data": { - "description": "The base64-encoded audio data.", - "type": "string" - }, - "mimeType": { - "description": "The MIME type of the audio (e.g. `audio/wav`).", - "type": "string" - } - }, - "required": [ - "data", - "mimeType" - ] - }, - "CallToolRequestMethod": { - "type": "string", - "format": "const", - "const": "tools/call" - }, - "CallToolRequestParams": { - "description": "Parameters for calling a tool provided by an MCP server.\n\nContains the tool name and optional arguments needed to execute\nthe tool operation.", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "arguments": { - "description": "Arguments to pass to the tool (must match the tool's input schema)", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "inputResponses": { - "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`]. Present only when retrying after an incomplete result.", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "name": { - "description": "The name of the tool to call", - "type": "string" - }, - "requestState": { - "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].\nClients MUST return this value exactly as received.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "name" - ] - }, - "CancelTaskMethod": { - "type": "string", - "format": "const", - "const": "tasks/cancel" - }, - "CancelTaskParams": { - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "taskId": { - "type": "string" - } - }, - "required": [ - "taskId" - ] - }, - "CancelledNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/cancelled" - }, - "CancelledNotificationParam": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "reason": { - "type": [ - "string", - "null" - ] - }, - "requestId": { - "anyOf": [ - { - "$ref": "#/definitions/NumberOrString" - }, - { - "type": "null" - } - ] - } - } - }, - "ClientCapabilities": { - "title": "Builder", - "description": "```rust\n# use rmcp::model::ClientCapabilities;\nlet cap = ClientCapabilities::builder()\n .enable_experimental()\n .build();\n```", - "type": "object", - "properties": { - "elicitation": { - "description": "Capability to handle elicitation requests from servers for interactive user input", - "anyOf": [ - { - "$ref": "#/definitions/ElicitationCapability" - }, - { - "type": "null" - } - ] - }, - "experimental": { - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": "object", - "additionalProperties": true - } - }, - "extensions": { - "description": "Optional MCP extensions that the client supports (SEP-1724).\nKeys are extension identifiers (e.g., `\"io.modelcontextprotocol/ui\"`),\nvalues are per-extension settings objects. An empty object indicates\nsupport with no settings.", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": "object", - "additionalProperties": true - } - }, - "roots": { - "description": "Capability for filesystem roots (deprecated by SEP-2577).", - "anyOf": [ - { - "$ref": "#/definitions/RootsCapabilities" - }, - { - "type": "null" - } - ] - }, - "sampling": { - "description": "Capability for LLM sampling requests (SEP-1577, deprecated by SEP-2577).", - "anyOf": [ - { - "$ref": "#/definitions/SamplingCapability" - }, - { - "type": "null" - } - ] - } - } - }, - "ClientResult": { - "anyOf": [ - { - "$ref": "#/definitions/CreateMessageResult" - }, - { - "$ref": "#/definitions/ListRootsResult" - }, - { - "$ref": "#/definitions/ElicitResult" - }, - { - "$ref": "#/definitions/EmptyObject" - }, - { - "$ref": "#/definitions/CustomResult" - } - ] - }, - "CompleteRequestMethod": { - "type": "string", - "format": "const", - "const": "completion/complete" - }, - "CompleteRequestParams": { - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "argument": { - "$ref": "#/definitions/ArgumentInfo" - }, - "context": { - "description": "Optional context containing previously resolved argument values", - "anyOf": [ - { - "$ref": "#/definitions/CompletionContext" - }, - { - "type": "null" - } - ] - }, - "ref": { - "$ref": "#/definitions/Reference" - } - }, - "required": [ - "ref", - "argument" - ] - }, - "CompletionContext": { - "description": "Context for completion requests providing previously resolved arguments.\n\nThis enables context-aware completion where subsequent argument completions\ncan take into account the values of previously resolved arguments.", - "type": "object", - "properties": { - "arguments": { - "description": "Previously resolved argument values that can inform completion suggestions", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": "string" - } - } - } - }, - "ContentBlock": { - "description": "Unified content block union (spec `ContentBlock`).\n\n`text | image | audio | resource_link | resource`", - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - } - }, - "allOf": [ - { - "$ref": "#/definitions/TextContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "image" - } - }, - "allOf": [ - { - "$ref": "#/definitions/ImageContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "audio" - } - }, - "allOf": [ - { - "$ref": "#/definitions/AudioContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "resource" - } - }, - "allOf": [ - { - "$ref": "#/definitions/EmbeddedResource" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "resource_link" - } - }, - "allOf": [ - { - "$ref": "#/definitions/Resource" - } - ], - "required": [ - "type" - ] - } - ] - }, - "CreateMessageResult": { - "description": "Result of sampling/createMessage (SEP-1577).\nThe result of a sampling/createMessage request containing the generated response.\n\nThis structure contains the generated message along with metadata about\nhow the generation was performed and why it stopped.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "content": { - "description": "The actual content of the message (text, image, audio, tool use, or tool result)", - "allOf": [ - { - "$ref": "#/definitions/SamplingContent" - } - ] - }, - "model": { - "description": "The identifier of the model that generated the response", - "type": "string" - }, - "role": { - "description": "The role of the message sender (User or Assistant)", - "allOf": [ - { - "$ref": "#/definitions/Role" - } - ] - }, - "stopReason": { - "description": "The reason why generation stopped (e.g., \"endTurn\", \"maxTokens\")", - "type": [ - "string", - "null" - ] - } - }, - "deprecated": true, - "required": [ - "model", - "role", - "content" - ] - }, - "CustomNotification": { - "description": "A catch-all notification either side can use to send custom messages to its peer.\n\nThis preserves the raw `method` name and `params` payload so handlers can\ndeserialize them into domain-specific types.", - "type": "object", - "properties": { - "method": { - "type": "string" - }, - "params": true - }, - "required": [ - "method" - ] - }, - "CustomRequest": { - "description": "A catch-all request either side can use to send custom messages to its peer.\n\nThis preserves the raw `method` name and `params` payload so handlers can\ndeserialize them into domain-specific types.", - "type": "object", - "properties": { - "method": { - "type": "string" - }, - "params": true - }, - "required": [ - "method" - ] - }, - "CustomResult": { - "description": "A catch-all response either side can use for custom requests." - }, - "DiscoverRequestMethod": { - "type": "string", - "format": "const", - "const": "server/discover" - }, - "DiscoverRequestParams": { - "type": "object", - "properties": { - "_meta": { - "$ref": "#/definitions/RequestMetaObject" - } - }, - "required": [ - "_meta" - ] - }, - "ElicitResult": { - "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this result.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "action": { - "description": "The user's decision on how to handle the elicitation request", - "allOf": [ - { - "$ref": "#/definitions/ElicitationAction" - } - ] - }, - "content": { - "description": "The actual data provided by the user, if they accepted the request.\nMust conform to the JSON schema specified in the original request.\nOnly present when action is Accept." - } - }, - "required": [ - "action" - ] - }, - "ElicitationAction": { - "description": "Represents the possible actions a user can take in response to an elicitation request.\n\nWhen a server requests user input through elicitation, the user can:\n- Accept: Provide the requested information and continue\n- Decline: Refuse to provide the information but continue the operation\n- Cancel: Stop the entire operation", - "oneOf": [ - { - "description": "User accepts the request and provides the requested information", - "type": "string", - "const": "accept" - }, - { - "description": "User declines to provide the information but allows the operation to continue", - "type": "string", - "const": "decline" - }, - { - "description": "User cancels the entire operation", - "type": "string", - "const": "cancel" - } - ] - }, - "ElicitationCapability": { - "description": "Elicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.", - "type": "object", - "properties": { - "form": { - "description": "Whether client supports form-based elicitation.", - "anyOf": [ - { - "$ref": "#/definitions/FormElicitationCapability" - }, - { - "type": "null" - } - ] - }, - "url": { - "description": "Whether client supports URL-based elicitation.", - "anyOf": [ - { - "$ref": "#/definitions/UrlElicitationCapability" - }, - { - "type": "null" - } - ] - } - } - }, - "EmbeddedResource": { - "description": "Embedded resource content (spec `EmbeddedResource`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this content.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "resource": { - "description": "The embedded resource contents (text or blob).", - "allOf": [ - { - "$ref": "#/definitions/ResourceContents" - } - ] - } - }, - "required": [ - "resource" - ] - }, - "EmptyObject": { - "description": "This is commonly used for representing empty objects in MCP messages.\n\nwithout returning any specific data.", - "type": "object", - "additionalProperties": false - }, - "ErrorCode": { - "description": "Standard JSON-RPC error codes used throughout the MCP protocol.\n\nThese codes follow the JSON-RPC 2.0 specification and provide\nstandardized error reporting across all MCP implementations.", - "type": "integer", - "format": "int32" - }, - "ErrorData": { - "description": "Error information for JSON-RPC error responses.\n\nThis structure follows the JSON-RPC 2.0 specification for error reporting,\nproviding a standardized way to communicate errors between clients and servers.", - "type": "object", - "properties": { - "code": { - "description": "The error type that occurred (using standard JSON-RPC error codes)", - "allOf": [ - { - "$ref": "#/definitions/ErrorCode" - } - ] - }, - "data": { - "description": "Additional information about the error. The value of this member is defined by the\nsender (e.g. detailed error information, nested errors etc.)." - }, - "message": { - "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", - "type": "string" - } - }, - "required": [ - "code", - "message" - ] - }, - "FormElicitationCapability": { - "description": "Capability for handling elicitation requests from servers.\nElicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.\n\nCapability for form mode elicitation.", - "type": "object", - "properties": { - "schemaValidation": { - "description": "Whether the client supports JSON Schema validation for elicitation responses.\nWhen true, the client will validate user input against the requested_schema\nbefore sending the response back to the server.", - "type": [ - "boolean", - "null" - ] - } - } - }, - "GetPromptRequestMethod": { - "type": "string", - "format": "const", - "const": "prompts/get" - }, - "GetPromptRequestParams": { - "description": "Parameters for retrieving a specific prompt", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "arguments": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "inputResponses": { - "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`].", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "name": { - "type": "string" - }, - "requestState": { - "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "name" - ] - }, - "GetTaskMethod": { - "type": "string", - "format": "const", - "const": "tasks/get" - }, - "GetTaskParams": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "taskId": { - "description": "Identifier of the task to query.", - "type": "string" - } - }, - "required": [ - "taskId" - ] - }, - "Icon": { - "description": "A URL pointing to an icon resource or a base64-encoded data URI.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- image/png - PNG images (safe, universal compatibility)\n- image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- image/svg+xml - SVG images (scalable but requires security precautions)\n- image/webp - WebP images (modern, efficient format)", - "type": "object", - "properties": { - "mimeType": { - "description": "Optional override if the server's MIME type is missing or generic", - "type": [ - "string", - "null" - ] - }, - "sizes": { - "description": "Size specification, each string should be in WxH format (e.g., `\\\"48x48\\\"`, `\\\"96x96\\\"`) or `\\\"any\\\"` for scalable formats like SVG", - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "src": { - "description": "A standard URI pointing to an icon resource", - "type": "string" - }, - "theme": { - "description": "Optional specifier for the theme this icon is designed for\nIf not provided, the client should assume the icon can be used with any theme.", - "anyOf": [ - { - "$ref": "#/definitions/IconTheme" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "src" - ] - }, - "IconTheme": { - "description": "Icon themes supported by the MCP specification", - "oneOf": [ - { - "description": "Indicates the icon is designed to be used with a light background", - "type": "string", - "const": "light" - }, - { - "description": "Indicates the icon is designed to be used with a dark background", - "type": "string", - "const": "dark" - } - ] - }, - "ImageContent": { - "description": "Image content with base64-encoded data (spec `ImageContent`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this content.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "data": { - "description": "The base64-encoded image data.", - "type": "string" - }, - "mimeType": { - "description": "The MIME type of the image (e.g. `image/png`).", - "type": "string" - } - }, - "required": [ - "data", - "mimeType" - ] - }, - "Implementation": { - "type": "object", - "properties": { - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "name": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "version": { - "type": "string" - }, - "websiteUrl": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "name", - "version" - ] - }, - "InitializeRequestParams": { - "description": "Parameters sent by a client when initializing a connection to an MCP server.\n\nThis contains the client's protocol version, capabilities, and implementation\ninformation, allowing the server to understand what the client supports.", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "capabilities": { - "description": "The capabilities this client supports (sampling, roots, etc.)", - "allOf": [ - { - "$ref": "#/definitions/ClientCapabilities" - } - ] - }, - "clientInfo": { - "description": "Information about the client implementation", - "allOf": [ - { - "$ref": "#/definitions/Implementation" - } - ] - }, - "protocolVersion": { - "description": "The MCP protocol version this client supports", - "allOf": [ - { - "$ref": "#/definitions/ProtocolVersion" - } - ] - } - }, - "required": [ - "protocolVersion", - "capabilities", - "clientInfo" - ] - }, - "InitializeResultMethod": { - "type": "string", - "format": "const", - "const": "initialize" - }, - "InitializedNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/initialized" - }, - "JsonRpcError": { - "type": "object", - "properties": { - "error": { - "$ref": "#/definitions/ErrorData" - }, - "id": { - "anyOf": [ - { - "$ref": "#/definitions/NumberOrString" - }, - { - "type": "null" - } - ] - }, - "jsonrpc": { - "$ref": "#/definitions/JsonRpcVersion2_0" - } - }, - "required": [ - "jsonrpc", - "error" - ] - }, - "JsonRpcNotification": { - "type": "object", - "properties": { - "jsonrpc": { - "$ref": "#/definitions/JsonRpcVersion2_0" - } - }, - "anyOf": [ - { - "$ref": "#/definitions/Notification" - }, - { - "$ref": "#/definitions/Notification2" - }, - { - "$ref": "#/definitions/NotificationNoParam" - }, - { - "$ref": "#/definitions/NotificationNoParam2" - }, - { - "$ref": "#/definitions/CustomNotification" - } - ], - "required": [ - "jsonrpc" - ] - }, - "JsonRpcRequest": { - "type": "object", - "properties": { - "id": { - "$ref": "#/definitions/NumberOrString" - }, - "jsonrpc": { - "$ref": "#/definitions/JsonRpcVersion2_0" - } - }, - "anyOf": [ - { - "$ref": "#/definitions/RequestNoParam" - }, - { - "$ref": "#/definitions/Request" - }, - { - "$ref": "#/definitions/Request2" - }, - { - "$ref": "#/definitions/Request3" - }, - { - "$ref": "#/definitions/Request4" - }, - { - "$ref": "#/definitions/Request5" - }, - { - "$ref": "#/definitions/RequestOptionalParam" - }, - { - "$ref": "#/definitions/RequestOptionalParam2" - }, - { - "$ref": "#/definitions/RequestOptionalParam3" - }, - { - "$ref": "#/definitions/Request6" - }, - { - "$ref": "#/definitions/Request7" - }, - { - "$ref": "#/definitions/Request8" - }, - { - "$ref": "#/definitions/Request9" - }, - { - "$ref": "#/definitions/Request10" - }, - { - "$ref": "#/definitions/RequestOptionalParam4" - }, - { - "$ref": "#/definitions/Request11" - }, - { - "$ref": "#/definitions/Request12" - }, - { - "$ref": "#/definitions/Request13" - }, - { - "$ref": "#/definitions/CustomRequest" - } - ], - "required": [ - "jsonrpc", - "id" - ] - }, - "JsonRpcResponse": { - "type": "object", - "properties": { - "id": { - "$ref": "#/definitions/NumberOrString" - }, - "jsonrpc": { - "$ref": "#/definitions/JsonRpcVersion2_0" - }, - "result": { - "$ref": "#/definitions/ClientResult" - } - }, - "required": [ - "jsonrpc", - "id", - "result" - ] - }, - "JsonRpcVersion2_0": { - "type": "string", - "format": "const", - "const": "2.0" - }, - "ListPromptsRequestMethod": { - "type": "string", - "format": "const", - "const": "prompts/list" - }, - "ListResourceTemplatesRequestMethod": { - "type": "string", - "format": "const", - "const": "resources/templates/list" - }, - "ListResourcesRequestMethod": { - "type": "string", - "format": "const", - "const": "resources/list" - }, - "ListRootsResult": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "roots": { - "type": "array", - "items": { - "$ref": "#/definitions/Root" - } - } - }, - "deprecated": true, - "required": [ - "roots" - ] - }, - "ListToolsRequestMethod": { - "type": "string", - "format": "const", - "const": "tools/list" - }, - "LoggingLevel": { - "description": "Logging levels supported by the MCP protocol", - "type": "string", - "deprecated": true, - "enum": [ - "debug", - "info", - "notice", - "warning", - "error", - "critical", - "alert", - "emergency" - ] - }, - "MetaObject": { - "description": "See [MCP general fields](https://modelcontextprotocol.io/specification/2026-07-28/basic#general-fields) for notes on _meta usage.", - "type": "object", - "additionalProperties": true - }, - "Notification": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/CancelledNotificationMethod" - }, - "params": { - "$ref": "#/definitions/CancelledNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, - "Notification2": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ProgressNotificationMethod" - }, - "params": { - "$ref": "#/definitions/ProgressNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, - "NotificationMetaObject": { - "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", - "type": "object", - "properties": { - "io.modelcontextprotocol/subscriptionId": { - "$ref": "#/definitions/NumberOrString" - } - }, - "additionalProperties": true - }, - "NotificationNoParam": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/InitializedNotificationMethod" - } - }, - "required": [ - "method" - ] - }, - "NotificationNoParam2": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/RootsListChangedNotificationMethod" - } - }, - "required": [ - "method" - ] - }, - "NumberOrString": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string" - } - ] - }, - "PaginatedRequestParams": { - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "cursor": { - "type": [ - "string", - "null" - ] - } - } - }, - "PingRequestMethod": { - "type": "string", - "format": "const", - "const": "ping" - }, - "ProgressNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/progress" - }, - "ProgressNotificationParam": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "message": { - "description": "An optional message describing the current progress.", - "type": [ - "string", - "null" - ] - }, - "progress": { - "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", - "type": "number", - "format": "double" - }, - "progressToken": { - "$ref": "#/definitions/ProgressToken" - }, - "total": { - "description": "Total number of items to process (or total progress required), if known", - "type": [ - "number", - "null" - ], - "format": "double" - } - }, - "required": [ - "progressToken", - "progress" - ] - }, - "ProgressToken": { - "description": "A token used to track the progress of long-running operations.\n\nProgress tokens allow clients and servers to associate progress notifications\nwith specific requests, enabling real-time updates on operation status.", - "allOf": [ - { - "$ref": "#/definitions/NumberOrString" - } - ] - }, - "PromptReference": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "name" - ] - }, - "ProtocolVersion": { - "description": "Represents the MCP protocol version used for communication.\n\nThis ensures compatibility between clients and servers by specifying\nwhich version of the Model Context Protocol is being used.", - "type": "string" - }, - "ReadResourceRequestMethod": { - "type": "string", - "format": "const", - "const": "resources/read" - }, - "ReadResourceRequestParams": { - "description": "Parameters for reading a specific resource", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "inputResponses": { - "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`].", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "requestState": { - "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].", - "type": [ - "string", - "null" - ] - }, - "uri": { - "description": "The URI of the resource to read", - "type": "string" - } - }, - "required": [ - "uri" - ] - }, - "Reference": { - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "ref/resource" - } - }, - "allOf": [ - { - "$ref": "#/definitions/ResourceTemplateReference" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "ref/prompt" - } - }, - "allOf": [ - { - "$ref": "#/definitions/PromptReference" - } - ], - "required": [ - "type" - ] - } - ] - }, - "Request": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/InitializeResultMethod" - }, - "params": { - "$ref": "#/definitions/InitializeRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request10": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/CallToolRequestMethod" - }, - "params": { - "$ref": "#/definitions/CallToolRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request11": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/GetTaskMethod" - }, - "params": { - "$ref": "#/definitions/GetTaskParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request12": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/UpdateTaskMethod" - }, - "params": { - "$ref": "#/definitions/UpdateTaskParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request13": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/CancelTaskMethod" - }, - "params": { - "$ref": "#/definitions/CancelTaskParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request2": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/DiscoverRequestMethod" - }, - "params": { - "$ref": "#/definitions/DiscoverRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request3": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/CompleteRequestMethod" - }, - "params": { - "$ref": "#/definitions/CompleteRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request4": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/SetLevelRequestMethod" - }, - "params": { - "$ref": "#/definitions/SetLevelRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request5": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/GetPromptRequestMethod" - }, - "params": { - "$ref": "#/definitions/GetPromptRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request6": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ReadResourceRequestMethod" - }, - "params": { - "$ref": "#/definitions/ReadResourceRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request7": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/SubscriptionsListenRequestMethod" - }, - "params": { - "$ref": "#/definitions/SubscriptionsListenRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request8": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/SubscribeRequestMethod" - }, - "params": { - "$ref": "#/definitions/SubscribeRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request9": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/UnsubscribeRequestMethod" - }, - "params": { - "$ref": "#/definitions/UnsubscribeRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "RequestMetaObject": { - "description": "Metadata reserved by MCP on requests. Extension keys are also allowed.", - "type": "object", - "properties": { - "io.modelcontextprotocol/clientCapabilities": { - "$ref": "#/definitions/ClientCapabilities" - }, - "io.modelcontextprotocol/clientInfo": { - "$ref": "#/definitions/Implementation" - }, - "io.modelcontextprotocol/logLevel": { - "$ref": "#/definitions/LoggingLevel" - }, - "io.modelcontextprotocol/protocolVersion": { - "type": "string" - }, - "progressToken": { - "$ref": "#/definitions/ProgressToken" - } - }, - "additionalProperties": true - }, - "RequestNoParam": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/PingRequestMethod" - } - }, - "required": [ - "method" - ] - }, - "RequestOptionalParam": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ListPromptsRequestMethod" - }, - "params": { - "anyOf": [ - { - "$ref": "#/definitions/PaginatedRequestParams" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "method" - ] - }, - "RequestOptionalParam2": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ListResourcesRequestMethod" - }, - "params": { - "anyOf": [ - { - "$ref": "#/definitions/PaginatedRequestParams" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "method" - ] - }, - "RequestOptionalParam3": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ListResourceTemplatesRequestMethod" - }, - "params": { - "anyOf": [ - { - "$ref": "#/definitions/PaginatedRequestParams" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "method" - ] - }, - "RequestOptionalParam4": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ListToolsRequestMethod" - }, - "params": { - "anyOf": [ - { - "$ref": "#/definitions/PaginatedRequestParams" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "method" - ] - }, - "Resource": { - "description": "A known resource that the server is capable of reading (spec `Resource`).\n\nAlso used as the inner type of `ContentBlock::ResourceLink` (spec `ResourceLink extends Resource`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this resource.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this resource.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "description": { - "description": "Optional description of what this resource represents.", - "type": [ - "string", - "null" - ] - }, - "icons": { - "description": "Optional set of icons the client may display for this resource.", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "mimeType": { - "description": "The MIME type of this resource, if known.", - "type": [ - "string", - "null" - ] - }, - "name": { - "description": "The programmatic name of the resource.", - "type": "string" - }, - "size": { - "description": "The size of the raw resource content in bytes (before base64/tokenization), if known.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "title": { - "description": "Optional human-readable display title.", - "type": [ - "string", - "null" - ] - }, - "uri": { - "description": "The URI of this resource (e.g. `file:///path/to/file`).", - "type": "string" - } - }, - "required": [ - "uri", - "name" - ] - }, - "ResourceContents": { - "description": "The contents of a specific resource or sub-resource.", - "anyOf": [ - { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "text": { - "type": "string" - }, - "uri": { - "type": "string" - } - }, - "required": [ - "uri", - "text" - ] - }, - { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "blob": { - "type": "string" - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "uri": { - "type": "string" - } - }, - "required": [ - "uri", - "blob" - ] - } - ] - }, - "ResourceTemplateReference": { - "type": "object", - "properties": { - "uri": { - "type": "string" - } - }, - "required": [ - "uri" - ] - }, - "Role": { - "description": "Represents the role of a participant in a conversation or message exchange.\n\nUsed in sampling and chat contexts to distinguish between different\ntypes of message senders in the conversation flow.", - "oneOf": [ - { - "description": "A human user or client making a request", - "type": "string", - "const": "user" - }, - { - "description": "An AI assistant or server providing a response", - "type": "string", - "const": "assistant" - } - ] - }, - "Root": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "name": { - "type": [ - "string", - "null" - ] - }, - "uri": { - "type": "string" - } - }, - "deprecated": true, - "required": [ - "uri" - ] - }, - "RootsCapabilities": { - "description": "Roots capability. Deprecated by SEP-2577; remains functional and will be\nremoved in a future release.\nSee .", - "type": "object", - "properties": { - "listChanged": { - "type": [ - "boolean", - "null" - ] - } - } - }, - "RootsListChangedNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/roots/list_changed" - }, - "SamplingCapability": { - "description": "Sampling capability with optional sub-capabilities (SEP-1577).\n\nDeprecated by SEP-2577; remains functional and will be removed in a future\nrelease.\nSee .", - "type": "object", - "properties": { - "context": { - "description": "Support for `includeContext` (soft-deprecated)", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "tools": { - "description": "Support for `tools` and `toolChoice` parameters", - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, - "SamplingContent": { - "description": "Single or array content wrapper (SEP-1577).", - "anyOf": [ - { - "$ref": "#/definitions/SamplingMessageContentBlock" - }, - { - "type": "array", - "items": { - "$ref": "#/definitions/SamplingMessageContentBlock" - } - } - ] - }, - "SamplingMessageContentBlock": { - "description": "Content types for sampling messages (SEP-1577).", - "deprecated": true, - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - } - }, - "allOf": [ - { - "$ref": "#/definitions/TextContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "image" - } - }, - "allOf": [ - { - "$ref": "#/definitions/ImageContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "audio" - } - }, - "allOf": [ - { - "$ref": "#/definitions/AudioContent" - } - ], - "required": [ - "type" - ] - }, - { - "description": "Assistant only", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tool_use" - } - }, - "allOf": [ - { - "$ref": "#/definitions/ToolUseContent" - } - ], - "required": [ - "type" - ] - }, - { - "description": "User only", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tool_result" - } - }, - "allOf": [ - { - "$ref": "#/definitions/ToolResultContent" - } - ], - "required": [ - "type" - ] - } - ] - }, - "SetLevelRequestMethod": { - "type": "string", - "format": "const", - "const": "logging/setLevel" - }, - "SetLevelRequestParams": { - "description": "Parameters for setting the logging level", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "level": { - "description": "The desired logging level", - "allOf": [ - { - "$ref": "#/definitions/LoggingLevel" - } - ] - } - }, - "deprecated": true, - "required": [ - "level" - ] - }, - "SubscribeRequestMethod": { - "type": "string", - "format": "const", - "const": "resources/subscribe" - }, - "SubscribeRequestParams": { - "description": "Parameters for subscribing to resource updates", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "uri": { - "description": "The URI of the resource to subscribe to", - "type": "string" - } - }, - "required": [ - "uri" - ] - }, - "SubscriptionFilter": { - "description": "Notification categories a client opts in to on a `subscriptions/listen` stream.", - "type": "object", - "properties": { - "promptsListChanged": { - "type": "boolean" - }, - "resourceSubscriptions": { - "type": "array", - "items": { - "type": "string" - } - }, - "resourcesListChanged": { - "type": "boolean" - }, - "toolsListChanged": { - "type": "boolean" - } - } - }, - "SubscriptionsListenRequestMethod": { - "type": "string", - "format": "const", - "const": "subscriptions/listen" - }, - "SubscriptionsListenRequestParams": { - "description": "Parameters for opening a long-lived notification subscription.", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata. Required by the 2026-07-28 wire schema.", - "type": "object", - "properties": { - "io.modelcontextprotocol/clientCapabilities": { - "$ref": "#/definitions/ClientCapabilities" - }, - "io.modelcontextprotocol/clientInfo": { - "$ref": "#/definitions/Implementation" - }, - "io.modelcontextprotocol/logLevel": { - "$ref": "#/definitions/LoggingLevel" - }, - "io.modelcontextprotocol/protocolVersion": { - "type": "string" - }, - "progressToken": { - "$ref": "#/definitions/ProgressToken" - } - }, - "additionalProperties": true, - "required": [ - "io.modelcontextprotocol/protocolVersion", - "io.modelcontextprotocol/clientCapabilities" - ] - }, - "notifications": { - "description": "Notification categories requested for this stream.", - "allOf": [ - { - "$ref": "#/definitions/SubscriptionFilter" - } - ] - } - }, - "required": [ - "_meta", - "notifications" - ] - }, - "TextContent": { - "description": "Text content block (spec `TextContent`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this content.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "text": { - "description": "The text content of the message.", - "type": "string" - } - }, - "required": [ - "text" - ] - }, - "ToolResultContent": { - "description": "Tool execution result in user message (SEP-1577).", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "content": { - "type": "array", - "items": { - "$ref": "#/definitions/ContentBlock" - } - }, - "isError": { - "type": [ - "boolean", - "null" - ] - }, - "structuredContent": true, - "toolUseId": { - "type": "string" - } - }, - "deprecated": true, - "required": [ - "toolUseId", - "content" - ] - }, - "ToolUseContent": { - "description": "Tool call request from assistant (SEP-1577).", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": "string" - }, - "input": { - "type": "object", - "additionalProperties": true - }, - "name": { - "type": "string" - } - }, - "deprecated": true, - "required": [ - "id", - "name", - "input" - ] - }, - "UnsubscribeRequestMethod": { - "type": "string", - "format": "const", - "const": "resources/unsubscribe" - }, - "UnsubscribeRequestParams": { - "description": "Parameters for unsubscribing from resource updates", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "uri": { - "description": "The URI of the resource to unsubscribe from", - "type": "string" - } - }, - "required": [ - "uri" - ] - }, - "UpdateTaskMethod": { - "type": "string", - "format": "const", - "const": "tasks/update" - }, - "UpdateTaskParams": { - "description": "Parameters for `tasks/update` (SEP-2663): deliver responses to outstanding\nin-task server-to-client requests surfaced via `tasks/get` `inputRequests`.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "inputResponses": { - "description": "Responses to outstanding `inputRequests` previously surfaced by the\nserver. Each key MUST correspond to a currently-outstanding\n`inputRequests` key.", - "type": "object", - "additionalProperties": true - }, - "taskId": { - "description": "Identifier of the task to update.", - "type": "string" - } - }, - "required": [ - "taskId", - "inputResponses" - ] - }, - "UrlElicitationCapability": { - "description": "Capability for URL mode elicitation.", - "type": "object" - } - } -} \ No newline at end of file diff --git a/vendor/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/vendor/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json deleted file mode 100644 index 760d9e18c..000000000 --- a/vendor/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ /dev/null @@ -1,2447 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "JsonRpcMessage", - "description": "Represents any JSON-RPC message that can be sent or received.\n\nThis enum covers all possible message types in the JSON-RPC protocol:\nindividual requests/responses, notifications, and errors.\nIt serves as the top-level message container for MCP communication.", - "anyOf": [ - { - "description": "A single request expecting a response", - "allOf": [ - { - "$ref": "#/definitions/JsonRpcRequest" - } - ] - }, - { - "description": "A response to a previous request", - "allOf": [ - { - "$ref": "#/definitions/JsonRpcResponse" - } - ] - }, - { - "description": "A one-way notification (no response expected)", - "allOf": [ - { - "$ref": "#/definitions/JsonRpcNotification" - } - ] - }, - { - "description": "An error response", - "allOf": [ - { - "$ref": "#/definitions/JsonRpcError" - } - ] - } - ], - "definitions": { - "Annotations": { - "description": "Optional annotations for the client. The client can use annotations to inform how objects are\nused or displayed.", - "type": "object", - "properties": { - "audience": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Role" - } - }, - "lastModified": { - "type": [ - "string", - "null" - ] - }, - "priority": { - "type": [ - "number", - "null" - ], - "format": "float" - } - } - }, - "ArgumentInfo": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": [ - "name", - "value" - ] - }, - "AudioContent": { - "description": "Audio content with base64-encoded data (spec `AudioContent`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this content.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "data": { - "description": "The base64-encoded audio data.", - "type": "string" - }, - "mimeType": { - "description": "The MIME type of the audio (e.g. `audio/wav`).", - "type": "string" - } - }, - "required": [ - "data", - "mimeType" - ] - }, - "CallToolRequestMethod": { - "type": "string", - "format": "const", - "const": "tools/call" - }, - "CallToolRequestParams": { - "description": "Parameters for calling a tool provided by an MCP server.\n\nContains the tool name and optional arguments needed to execute\nthe tool operation.", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "arguments": { - "description": "Arguments to pass to the tool (must match the tool's input schema)", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "inputResponses": { - "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`]. Present only when retrying after an incomplete result.", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "name": { - "description": "The name of the tool to call", - "type": "string" - }, - "requestState": { - "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].\nClients MUST return this value exactly as received.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "name" - ] - }, - "CancelTaskMethod": { - "type": "string", - "format": "const", - "const": "tasks/cancel" - }, - "CancelTaskParams": { - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "taskId": { - "type": "string" - } - }, - "required": [ - "taskId" - ] - }, - "CancelledNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/cancelled" - }, - "CancelledNotificationParam": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "reason": { - "type": [ - "string", - "null" - ] - }, - "requestId": { - "anyOf": [ - { - "$ref": "#/definitions/NumberOrString" - }, - { - "type": "null" - } - ] - } - } - }, - "ClientCapabilities": { - "title": "Builder", - "description": "```rust\n# use rmcp::model::ClientCapabilities;\nlet cap = ClientCapabilities::builder()\n .enable_experimental()\n .build();\n```", - "type": "object", - "properties": { - "elicitation": { - "description": "Capability to handle elicitation requests from servers for interactive user input", - "anyOf": [ - { - "$ref": "#/definitions/ElicitationCapability" - }, - { - "type": "null" - } - ] - }, - "experimental": { - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": "object", - "additionalProperties": true - } - }, - "extensions": { - "description": "Optional MCP extensions that the client supports (SEP-1724).\nKeys are extension identifiers (e.g., `\"io.modelcontextprotocol/ui\"`),\nvalues are per-extension settings objects. An empty object indicates\nsupport with no settings.", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": "object", - "additionalProperties": true - } - }, - "roots": { - "description": "Capability for filesystem roots (deprecated by SEP-2577).", - "anyOf": [ - { - "$ref": "#/definitions/RootsCapabilities" - }, - { - "type": "null" - } - ] - }, - "sampling": { - "description": "Capability for LLM sampling requests (SEP-1577, deprecated by SEP-2577).", - "anyOf": [ - { - "$ref": "#/definitions/SamplingCapability" - }, - { - "type": "null" - } - ] - } - } - }, - "ClientResult": { - "anyOf": [ - { - "$ref": "#/definitions/CreateMessageResult" - }, - { - "$ref": "#/definitions/ListRootsResult" - }, - { - "$ref": "#/definitions/ElicitResult" - }, - { - "$ref": "#/definitions/EmptyObject" - }, - { - "$ref": "#/definitions/CustomResult" - } - ] - }, - "CompleteRequestMethod": { - "type": "string", - "format": "const", - "const": "completion/complete" - }, - "CompleteRequestParams": { - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "argument": { - "$ref": "#/definitions/ArgumentInfo" - }, - "context": { - "description": "Optional context containing previously resolved argument values", - "anyOf": [ - { - "$ref": "#/definitions/CompletionContext" - }, - { - "type": "null" - } - ] - }, - "ref": { - "$ref": "#/definitions/Reference" - } - }, - "required": [ - "ref", - "argument" - ] - }, - "CompletionContext": { - "description": "Context for completion requests providing previously resolved arguments.\n\nThis enables context-aware completion where subsequent argument completions\ncan take into account the values of previously resolved arguments.", - "type": "object", - "properties": { - "arguments": { - "description": "Previously resolved argument values that can inform completion suggestions", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": "string" - } - } - } - }, - "ContentBlock": { - "description": "Unified content block union (spec `ContentBlock`).\n\n`text | image | audio | resource_link | resource`", - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - } - }, - "allOf": [ - { - "$ref": "#/definitions/TextContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "image" - } - }, - "allOf": [ - { - "$ref": "#/definitions/ImageContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "audio" - } - }, - "allOf": [ - { - "$ref": "#/definitions/AudioContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "resource" - } - }, - "allOf": [ - { - "$ref": "#/definitions/EmbeddedResource" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "resource_link" - } - }, - "allOf": [ - { - "$ref": "#/definitions/Resource" - } - ], - "required": [ - "type" - ] - } - ] - }, - "CreateMessageResult": { - "description": "Result of sampling/createMessage (SEP-1577).\nThe result of a sampling/createMessage request containing the generated response.\n\nThis structure contains the generated message along with metadata about\nhow the generation was performed and why it stopped.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "content": { - "description": "The actual content of the message (text, image, audio, tool use, or tool result)", - "allOf": [ - { - "$ref": "#/definitions/SamplingContent" - } - ] - }, - "model": { - "description": "The identifier of the model that generated the response", - "type": "string" - }, - "role": { - "description": "The role of the message sender (User or Assistant)", - "allOf": [ - { - "$ref": "#/definitions/Role" - } - ] - }, - "stopReason": { - "description": "The reason why generation stopped (e.g., \"endTurn\", \"maxTokens\")", - "type": [ - "string", - "null" - ] - } - }, - "deprecated": true, - "required": [ - "model", - "role", - "content" - ] - }, - "CustomNotification": { - "description": "A catch-all notification either side can use to send custom messages to its peer.\n\nThis preserves the raw `method` name and `params` payload so handlers can\ndeserialize them into domain-specific types.", - "type": "object", - "properties": { - "method": { - "type": "string" - }, - "params": true - }, - "required": [ - "method" - ] - }, - "CustomRequest": { - "description": "A catch-all request either side can use to send custom messages to its peer.\n\nThis preserves the raw `method` name and `params` payload so handlers can\ndeserialize them into domain-specific types.", - "type": "object", - "properties": { - "method": { - "type": "string" - }, - "params": true - }, - "required": [ - "method" - ] - }, - "CustomResult": { - "description": "A catch-all response either side can use for custom requests." - }, - "DiscoverRequestMethod": { - "type": "string", - "format": "const", - "const": "server/discover" - }, - "DiscoverRequestParams": { - "type": "object", - "properties": { - "_meta": { - "$ref": "#/definitions/RequestMetaObject" - } - }, - "required": [ - "_meta" - ] - }, - "ElicitResult": { - "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this result.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "action": { - "description": "The user's decision on how to handle the elicitation request", - "allOf": [ - { - "$ref": "#/definitions/ElicitationAction" - } - ] - }, - "content": { - "description": "The actual data provided by the user, if they accepted the request.\nMust conform to the JSON schema specified in the original request.\nOnly present when action is Accept." - } - }, - "required": [ - "action" - ] - }, - "ElicitationAction": { - "description": "Represents the possible actions a user can take in response to an elicitation request.\n\nWhen a server requests user input through elicitation, the user can:\n- Accept: Provide the requested information and continue\n- Decline: Refuse to provide the information but continue the operation\n- Cancel: Stop the entire operation", - "oneOf": [ - { - "description": "User accepts the request and provides the requested information", - "type": "string", - "const": "accept" - }, - { - "description": "User declines to provide the information but allows the operation to continue", - "type": "string", - "const": "decline" - }, - { - "description": "User cancels the entire operation", - "type": "string", - "const": "cancel" - } - ] - }, - "ElicitationCapability": { - "description": "Elicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.", - "type": "object", - "properties": { - "form": { - "description": "Whether client supports form-based elicitation.", - "anyOf": [ - { - "$ref": "#/definitions/FormElicitationCapability" - }, - { - "type": "null" - } - ] - }, - "url": { - "description": "Whether client supports URL-based elicitation.", - "anyOf": [ - { - "$ref": "#/definitions/UrlElicitationCapability" - }, - { - "type": "null" - } - ] - } - } - }, - "EmbeddedResource": { - "description": "Embedded resource content (spec `EmbeddedResource`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this content.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "resource": { - "description": "The embedded resource contents (text or blob).", - "allOf": [ - { - "$ref": "#/definitions/ResourceContents" - } - ] - } - }, - "required": [ - "resource" - ] - }, - "EmptyObject": { - "description": "This is commonly used for representing empty objects in MCP messages.\n\nwithout returning any specific data.", - "type": "object", - "additionalProperties": false - }, - "ErrorCode": { - "description": "Standard JSON-RPC error codes used throughout the MCP protocol.\n\nThese codes follow the JSON-RPC 2.0 specification and provide\nstandardized error reporting across all MCP implementations.", - "type": "integer", - "format": "int32" - }, - "ErrorData": { - "description": "Error information for JSON-RPC error responses.\n\nThis structure follows the JSON-RPC 2.0 specification for error reporting,\nproviding a standardized way to communicate errors between clients and servers.", - "type": "object", - "properties": { - "code": { - "description": "The error type that occurred (using standard JSON-RPC error codes)", - "allOf": [ - { - "$ref": "#/definitions/ErrorCode" - } - ] - }, - "data": { - "description": "Additional information about the error. The value of this member is defined by the\nsender (e.g. detailed error information, nested errors etc.)." - }, - "message": { - "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", - "type": "string" - } - }, - "required": [ - "code", - "message" - ] - }, - "FormElicitationCapability": { - "description": "Capability for handling elicitation requests from servers.\nElicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.\n\nCapability for form mode elicitation.", - "type": "object", - "properties": { - "schemaValidation": { - "description": "Whether the client supports JSON Schema validation for elicitation responses.\nWhen true, the client will validate user input against the requested_schema\nbefore sending the response back to the server.", - "type": [ - "boolean", - "null" - ] - } - } - }, - "GetPromptRequestMethod": { - "type": "string", - "format": "const", - "const": "prompts/get" - }, - "GetPromptRequestParams": { - "description": "Parameters for retrieving a specific prompt", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "arguments": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "inputResponses": { - "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`].", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "name": { - "type": "string" - }, - "requestState": { - "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "name" - ] - }, - "GetTaskMethod": { - "type": "string", - "format": "const", - "const": "tasks/get" - }, - "GetTaskParams": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "taskId": { - "description": "Identifier of the task to query.", - "type": "string" - } - }, - "required": [ - "taskId" - ] - }, - "Icon": { - "description": "A URL pointing to an icon resource or a base64-encoded data URI.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- image/png - PNG images (safe, universal compatibility)\n- image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- image/svg+xml - SVG images (scalable but requires security precautions)\n- image/webp - WebP images (modern, efficient format)", - "type": "object", - "properties": { - "mimeType": { - "description": "Optional override if the server's MIME type is missing or generic", - "type": [ - "string", - "null" - ] - }, - "sizes": { - "description": "Size specification, each string should be in WxH format (e.g., `\\\"48x48\\\"`, `\\\"96x96\\\"`) or `\\\"any\\\"` for scalable formats like SVG", - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "src": { - "description": "A standard URI pointing to an icon resource", - "type": "string" - }, - "theme": { - "description": "Optional specifier for the theme this icon is designed for\nIf not provided, the client should assume the icon can be used with any theme.", - "anyOf": [ - { - "$ref": "#/definitions/IconTheme" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "src" - ] - }, - "IconTheme": { - "description": "Icon themes supported by the MCP specification", - "oneOf": [ - { - "description": "Indicates the icon is designed to be used with a light background", - "type": "string", - "const": "light" - }, - { - "description": "Indicates the icon is designed to be used with a dark background", - "type": "string", - "const": "dark" - } - ] - }, - "ImageContent": { - "description": "Image content with base64-encoded data (spec `ImageContent`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this content.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "data": { - "description": "The base64-encoded image data.", - "type": "string" - }, - "mimeType": { - "description": "The MIME type of the image (e.g. `image/png`).", - "type": "string" - } - }, - "required": [ - "data", - "mimeType" - ] - }, - "Implementation": { - "type": "object", - "properties": { - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "name": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "version": { - "type": "string" - }, - "websiteUrl": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "name", - "version" - ] - }, - "InitializeRequestParams": { - "description": "Parameters sent by a client when initializing a connection to an MCP server.\n\nThis contains the client's protocol version, capabilities, and implementation\ninformation, allowing the server to understand what the client supports.", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "capabilities": { - "description": "The capabilities this client supports (sampling, roots, etc.)", - "allOf": [ - { - "$ref": "#/definitions/ClientCapabilities" - } - ] - }, - "clientInfo": { - "description": "Information about the client implementation", - "allOf": [ - { - "$ref": "#/definitions/Implementation" - } - ] - }, - "protocolVersion": { - "description": "The MCP protocol version this client supports", - "allOf": [ - { - "$ref": "#/definitions/ProtocolVersion" - } - ] - } - }, - "required": [ - "protocolVersion", - "capabilities", - "clientInfo" - ] - }, - "InitializeResultMethod": { - "type": "string", - "format": "const", - "const": "initialize" - }, - "InitializedNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/initialized" - }, - "JsonRpcError": { - "type": "object", - "properties": { - "error": { - "$ref": "#/definitions/ErrorData" - }, - "id": { - "anyOf": [ - { - "$ref": "#/definitions/NumberOrString" - }, - { - "type": "null" - } - ] - }, - "jsonrpc": { - "$ref": "#/definitions/JsonRpcVersion2_0" - } - }, - "required": [ - "jsonrpc", - "error" - ] - }, - "JsonRpcNotification": { - "type": "object", - "properties": { - "jsonrpc": { - "$ref": "#/definitions/JsonRpcVersion2_0" - } - }, - "anyOf": [ - { - "$ref": "#/definitions/Notification" - }, - { - "$ref": "#/definitions/Notification2" - }, - { - "$ref": "#/definitions/NotificationNoParam" - }, - { - "$ref": "#/definitions/NotificationNoParam2" - }, - { - "$ref": "#/definitions/CustomNotification" - } - ], - "required": [ - "jsonrpc" - ] - }, - "JsonRpcRequest": { - "type": "object", - "properties": { - "id": { - "$ref": "#/definitions/NumberOrString" - }, - "jsonrpc": { - "$ref": "#/definitions/JsonRpcVersion2_0" - } - }, - "anyOf": [ - { - "$ref": "#/definitions/RequestNoParam" - }, - { - "$ref": "#/definitions/Request" - }, - { - "$ref": "#/definitions/Request2" - }, - { - "$ref": "#/definitions/Request3" - }, - { - "$ref": "#/definitions/Request4" - }, - { - "$ref": "#/definitions/Request5" - }, - { - "$ref": "#/definitions/RequestOptionalParam" - }, - { - "$ref": "#/definitions/RequestOptionalParam2" - }, - { - "$ref": "#/definitions/RequestOptionalParam3" - }, - { - "$ref": "#/definitions/Request6" - }, - { - "$ref": "#/definitions/Request7" - }, - { - "$ref": "#/definitions/Request8" - }, - { - "$ref": "#/definitions/Request9" - }, - { - "$ref": "#/definitions/Request10" - }, - { - "$ref": "#/definitions/RequestOptionalParam4" - }, - { - "$ref": "#/definitions/Request11" - }, - { - "$ref": "#/definitions/Request12" - }, - { - "$ref": "#/definitions/Request13" - }, - { - "$ref": "#/definitions/CustomRequest" - } - ], - "required": [ - "jsonrpc", - "id" - ] - }, - "JsonRpcResponse": { - "type": "object", - "properties": { - "id": { - "$ref": "#/definitions/NumberOrString" - }, - "jsonrpc": { - "$ref": "#/definitions/JsonRpcVersion2_0" - }, - "result": { - "$ref": "#/definitions/ClientResult" - } - }, - "required": [ - "jsonrpc", - "id", - "result" - ] - }, - "JsonRpcVersion2_0": { - "type": "string", - "format": "const", - "const": "2.0" - }, - "ListPromptsRequestMethod": { - "type": "string", - "format": "const", - "const": "prompts/list" - }, - "ListResourceTemplatesRequestMethod": { - "type": "string", - "format": "const", - "const": "resources/templates/list" - }, - "ListResourcesRequestMethod": { - "type": "string", - "format": "const", - "const": "resources/list" - }, - "ListRootsResult": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "roots": { - "type": "array", - "items": { - "$ref": "#/definitions/Root" - } - } - }, - "deprecated": true, - "required": [ - "roots" - ] - }, - "ListToolsRequestMethod": { - "type": "string", - "format": "const", - "const": "tools/list" - }, - "LoggingLevel": { - "description": "Logging levels supported by the MCP protocol", - "type": "string", - "deprecated": true, - "enum": [ - "debug", - "info", - "notice", - "warning", - "error", - "critical", - "alert", - "emergency" - ] - }, - "MetaObject": { - "description": "See [MCP general fields](https://modelcontextprotocol.io/specification/2026-07-28/basic#general-fields) for notes on _meta usage.", - "type": "object", - "additionalProperties": true - }, - "Notification": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/CancelledNotificationMethod" - }, - "params": { - "$ref": "#/definitions/CancelledNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, - "Notification2": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ProgressNotificationMethod" - }, - "params": { - "$ref": "#/definitions/ProgressNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, - "NotificationMetaObject": { - "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", - "type": "object", - "properties": { - "io.modelcontextprotocol/subscriptionId": { - "$ref": "#/definitions/NumberOrString" - } - }, - "additionalProperties": true - }, - "NotificationNoParam": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/InitializedNotificationMethod" - } - }, - "required": [ - "method" - ] - }, - "NotificationNoParam2": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/RootsListChangedNotificationMethod" - } - }, - "required": [ - "method" - ] - }, - "NumberOrString": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string" - } - ] - }, - "PaginatedRequestParams": { - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "cursor": { - "type": [ - "string", - "null" - ] - } - } - }, - "PingRequestMethod": { - "type": "string", - "format": "const", - "const": "ping" - }, - "ProgressNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/progress" - }, - "ProgressNotificationParam": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "message": { - "description": "An optional message describing the current progress.", - "type": [ - "string", - "null" - ] - }, - "progress": { - "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", - "type": "number", - "format": "double" - }, - "progressToken": { - "$ref": "#/definitions/ProgressToken" - }, - "total": { - "description": "Total number of items to process (or total progress required), if known", - "type": [ - "number", - "null" - ], - "format": "double" - } - }, - "required": [ - "progressToken", - "progress" - ] - }, - "ProgressToken": { - "description": "A token used to track the progress of long-running operations.\n\nProgress tokens allow clients and servers to associate progress notifications\nwith specific requests, enabling real-time updates on operation status.", - "allOf": [ - { - "$ref": "#/definitions/NumberOrString" - } - ] - }, - "PromptReference": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "name" - ] - }, - "ProtocolVersion": { - "description": "Represents the MCP protocol version used for communication.\n\nThis ensures compatibility between clients and servers by specifying\nwhich version of the Model Context Protocol is being used.", - "type": "string" - }, - "ReadResourceRequestMethod": { - "type": "string", - "format": "const", - "const": "resources/read" - }, - "ReadResourceRequestParams": { - "description": "Parameters for reading a specific resource", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "inputResponses": { - "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`].", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "requestState": { - "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].", - "type": [ - "string", - "null" - ] - }, - "uri": { - "description": "The URI of the resource to read", - "type": "string" - } - }, - "required": [ - "uri" - ] - }, - "Reference": { - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "ref/resource" - } - }, - "allOf": [ - { - "$ref": "#/definitions/ResourceTemplateReference" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "ref/prompt" - } - }, - "allOf": [ - { - "$ref": "#/definitions/PromptReference" - } - ], - "required": [ - "type" - ] - } - ] - }, - "Request": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/InitializeResultMethod" - }, - "params": { - "$ref": "#/definitions/InitializeRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request10": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/CallToolRequestMethod" - }, - "params": { - "$ref": "#/definitions/CallToolRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request11": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/GetTaskMethod" - }, - "params": { - "$ref": "#/definitions/GetTaskParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request12": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/UpdateTaskMethod" - }, - "params": { - "$ref": "#/definitions/UpdateTaskParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request13": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/CancelTaskMethod" - }, - "params": { - "$ref": "#/definitions/CancelTaskParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request2": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/DiscoverRequestMethod" - }, - "params": { - "$ref": "#/definitions/DiscoverRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request3": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/CompleteRequestMethod" - }, - "params": { - "$ref": "#/definitions/CompleteRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request4": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/SetLevelRequestMethod" - }, - "params": { - "$ref": "#/definitions/SetLevelRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request5": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/GetPromptRequestMethod" - }, - "params": { - "$ref": "#/definitions/GetPromptRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request6": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ReadResourceRequestMethod" - }, - "params": { - "$ref": "#/definitions/ReadResourceRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request7": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/SubscriptionsListenRequestMethod" - }, - "params": { - "$ref": "#/definitions/SubscriptionsListenRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request8": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/SubscribeRequestMethod" - }, - "params": { - "$ref": "#/definitions/SubscribeRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request9": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/UnsubscribeRequestMethod" - }, - "params": { - "$ref": "#/definitions/UnsubscribeRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "RequestMetaObject": { - "description": "Metadata reserved by MCP on requests. Extension keys are also allowed.", - "type": "object", - "properties": { - "io.modelcontextprotocol/clientCapabilities": { - "$ref": "#/definitions/ClientCapabilities" - }, - "io.modelcontextprotocol/clientInfo": { - "$ref": "#/definitions/Implementation" - }, - "io.modelcontextprotocol/logLevel": { - "$ref": "#/definitions/LoggingLevel" - }, - "io.modelcontextprotocol/protocolVersion": { - "type": "string" - }, - "progressToken": { - "$ref": "#/definitions/ProgressToken" - } - }, - "additionalProperties": true - }, - "RequestNoParam": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/PingRequestMethod" - } - }, - "required": [ - "method" - ] - }, - "RequestOptionalParam": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ListPromptsRequestMethod" - }, - "params": { - "anyOf": [ - { - "$ref": "#/definitions/PaginatedRequestParams" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "method" - ] - }, - "RequestOptionalParam2": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ListResourcesRequestMethod" - }, - "params": { - "anyOf": [ - { - "$ref": "#/definitions/PaginatedRequestParams" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "method" - ] - }, - "RequestOptionalParam3": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ListResourceTemplatesRequestMethod" - }, - "params": { - "anyOf": [ - { - "$ref": "#/definitions/PaginatedRequestParams" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "method" - ] - }, - "RequestOptionalParam4": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ListToolsRequestMethod" - }, - "params": { - "anyOf": [ - { - "$ref": "#/definitions/PaginatedRequestParams" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "method" - ] - }, - "Resource": { - "description": "A known resource that the server is capable of reading (spec `Resource`).\n\nAlso used as the inner type of `ContentBlock::ResourceLink` (spec `ResourceLink extends Resource`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this resource.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this resource.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "description": { - "description": "Optional description of what this resource represents.", - "type": [ - "string", - "null" - ] - }, - "icons": { - "description": "Optional set of icons the client may display for this resource.", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "mimeType": { - "description": "The MIME type of this resource, if known.", - "type": [ - "string", - "null" - ] - }, - "name": { - "description": "The programmatic name of the resource.", - "type": "string" - }, - "size": { - "description": "The size of the raw resource content in bytes (before base64/tokenization), if known.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "title": { - "description": "Optional human-readable display title.", - "type": [ - "string", - "null" - ] - }, - "uri": { - "description": "The URI of this resource (e.g. `file:///path/to/file`).", - "type": "string" - } - }, - "required": [ - "uri", - "name" - ] - }, - "ResourceContents": { - "description": "The contents of a specific resource or sub-resource.", - "anyOf": [ - { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "text": { - "type": "string" - }, - "uri": { - "type": "string" - } - }, - "required": [ - "uri", - "text" - ] - }, - { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "blob": { - "type": "string" - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "uri": { - "type": "string" - } - }, - "required": [ - "uri", - "blob" - ] - } - ] - }, - "ResourceTemplateReference": { - "type": "object", - "properties": { - "uri": { - "type": "string" - } - }, - "required": [ - "uri" - ] - }, - "Role": { - "description": "Represents the role of a participant in a conversation or message exchange.\n\nUsed in sampling and chat contexts to distinguish between different\ntypes of message senders in the conversation flow.", - "oneOf": [ - { - "description": "A human user or client making a request", - "type": "string", - "const": "user" - }, - { - "description": "An AI assistant or server providing a response", - "type": "string", - "const": "assistant" - } - ] - }, - "Root": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "name": { - "type": [ - "string", - "null" - ] - }, - "uri": { - "type": "string" - } - }, - "deprecated": true, - "required": [ - "uri" - ] - }, - "RootsCapabilities": { - "description": "Roots capability. Deprecated by SEP-2577; remains functional and will be\nremoved in a future release.\nSee .", - "type": "object", - "properties": { - "listChanged": { - "type": [ - "boolean", - "null" - ] - } - } - }, - "RootsListChangedNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/roots/list_changed" - }, - "SamplingCapability": { - "description": "Sampling capability with optional sub-capabilities (SEP-1577).\n\nDeprecated by SEP-2577; remains functional and will be removed in a future\nrelease.\nSee .", - "type": "object", - "properties": { - "context": { - "description": "Support for `includeContext` (soft-deprecated)", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "tools": { - "description": "Support for `tools` and `toolChoice` parameters", - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, - "SamplingContent": { - "description": "Single or array content wrapper (SEP-1577).", - "anyOf": [ - { - "$ref": "#/definitions/SamplingMessageContentBlock" - }, - { - "type": "array", - "items": { - "$ref": "#/definitions/SamplingMessageContentBlock" - } - } - ] - }, - "SamplingMessageContentBlock": { - "description": "Content types for sampling messages (SEP-1577).", - "deprecated": true, - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - } - }, - "allOf": [ - { - "$ref": "#/definitions/TextContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "image" - } - }, - "allOf": [ - { - "$ref": "#/definitions/ImageContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "audio" - } - }, - "allOf": [ - { - "$ref": "#/definitions/AudioContent" - } - ], - "required": [ - "type" - ] - }, - { - "description": "Assistant only", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tool_use" - } - }, - "allOf": [ - { - "$ref": "#/definitions/ToolUseContent" - } - ], - "required": [ - "type" - ] - }, - { - "description": "User only", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tool_result" - } - }, - "allOf": [ - { - "$ref": "#/definitions/ToolResultContent" - } - ], - "required": [ - "type" - ] - } - ] - }, - "SetLevelRequestMethod": { - "type": "string", - "format": "const", - "const": "logging/setLevel" - }, - "SetLevelRequestParams": { - "description": "Parameters for setting the logging level", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "level": { - "description": "The desired logging level", - "allOf": [ - { - "$ref": "#/definitions/LoggingLevel" - } - ] - } - }, - "deprecated": true, - "required": [ - "level" - ] - }, - "SubscribeRequestMethod": { - "type": "string", - "format": "const", - "const": "resources/subscribe" - }, - "SubscribeRequestParams": { - "description": "Parameters for subscribing to resource updates", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "uri": { - "description": "The URI of the resource to subscribe to", - "type": "string" - } - }, - "required": [ - "uri" - ] - }, - "SubscriptionFilter": { - "description": "Notification categories a client opts in to on a `subscriptions/listen` stream.", - "type": "object", - "properties": { - "promptsListChanged": { - "type": "boolean" - }, - "resourceSubscriptions": { - "type": "array", - "items": { - "type": "string" - } - }, - "resourcesListChanged": { - "type": "boolean" - }, - "toolsListChanged": { - "type": "boolean" - } - } - }, - "SubscriptionsListenRequestMethod": { - "type": "string", - "format": "const", - "const": "subscriptions/listen" - }, - "SubscriptionsListenRequestParams": { - "description": "Parameters for opening a long-lived notification subscription.", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata. Required by the 2026-07-28 wire schema.", - "type": "object", - "properties": { - "io.modelcontextprotocol/clientCapabilities": { - "$ref": "#/definitions/ClientCapabilities" - }, - "io.modelcontextprotocol/clientInfo": { - "$ref": "#/definitions/Implementation" - }, - "io.modelcontextprotocol/logLevel": { - "$ref": "#/definitions/LoggingLevel" - }, - "io.modelcontextprotocol/protocolVersion": { - "type": "string" - }, - "progressToken": { - "$ref": "#/definitions/ProgressToken" - } - }, - "additionalProperties": true, - "required": [ - "io.modelcontextprotocol/protocolVersion", - "io.modelcontextprotocol/clientCapabilities" - ] - }, - "notifications": { - "description": "Notification categories requested for this stream.", - "allOf": [ - { - "$ref": "#/definitions/SubscriptionFilter" - } - ] - } - }, - "required": [ - "_meta", - "notifications" - ] - }, - "TextContent": { - "description": "Text content block (spec `TextContent`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this content.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "text": { - "description": "The text content of the message.", - "type": "string" - } - }, - "required": [ - "text" - ] - }, - "ToolResultContent": { - "description": "Tool execution result in user message (SEP-1577).", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "content": { - "type": "array", - "items": { - "$ref": "#/definitions/ContentBlock" - } - }, - "isError": { - "type": [ - "boolean", - "null" - ] - }, - "structuredContent": true, - "toolUseId": { - "type": "string" - } - }, - "deprecated": true, - "required": [ - "toolUseId", - "content" - ] - }, - "ToolUseContent": { - "description": "Tool call request from assistant (SEP-1577).", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": "string" - }, - "input": { - "type": "object", - "additionalProperties": true - }, - "name": { - "type": "string" - } - }, - "deprecated": true, - "required": [ - "id", - "name", - "input" - ] - }, - "UnsubscribeRequestMethod": { - "type": "string", - "format": "const", - "const": "resources/unsubscribe" - }, - "UnsubscribeRequestParams": { - "description": "Parameters for unsubscribing from resource updates", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "uri": { - "description": "The URI of the resource to unsubscribe from", - "type": "string" - } - }, - "required": [ - "uri" - ] - }, - "UpdateTaskMethod": { - "type": "string", - "format": "const", - "const": "tasks/update" - }, - "UpdateTaskParams": { - "description": "Parameters for `tasks/update` (SEP-2663): deliver responses to outstanding\nin-task server-to-client requests surfaced via `tasks/get` `inputRequests`.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "inputResponses": { - "description": "Responses to outstanding `inputRequests` previously surfaced by the\nserver. Each key MUST correspond to a currently-outstanding\n`inputRequests` key.", - "type": "object", - "additionalProperties": true - }, - "taskId": { - "description": "Identifier of the task to update.", - "type": "string" - } - }, - "required": [ - "taskId", - "inputResponses" - ] - }, - "UrlElicitationCapability": { - "description": "Capability for URL mode elicitation.", - "type": "object" - } - } -} \ No newline at end of file diff --git a/vendor/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/vendor/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json deleted file mode 100644 index ca0ef5611..000000000 --- a/vendor/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ /dev/null @@ -1,4113 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "JsonRpcMessage", - "description": "Represents any JSON-RPC message that can be sent or received.\n\nThis enum covers all possible message types in the JSON-RPC protocol:\nindividual requests/responses, notifications, and errors.\nIt serves as the top-level message container for MCP communication.", - "anyOf": [ - { - "description": "A single request expecting a response", - "allOf": [ - { - "$ref": "#/definitions/JsonRpcRequest" - } - ] - }, - { - "description": "A response to a previous request", - "allOf": [ - { - "$ref": "#/definitions/JsonRpcResponse" - } - ] - }, - { - "description": "A one-way notification (no response expected)", - "allOf": [ - { - "$ref": "#/definitions/JsonRpcNotification" - } - ] - }, - { - "description": "An error response", - "allOf": [ - { - "$ref": "#/definitions/JsonRpcError" - } - ] - } - ], - "definitions": { - "Annotations": { - "description": "Optional annotations for the client. The client can use annotations to inform how objects are\nused or displayed.", - "type": "object", - "properties": { - "audience": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Role" - } - }, - "lastModified": { - "type": [ - "string", - "null" - ] - }, - "priority": { - "type": [ - "number", - "null" - ], - "format": "float" - } - } - }, - "ArrayTypeConst": { - "type": "string", - "format": "const", - "const": "array" - }, - "AudioContent": { - "description": "Audio content with base64-encoded data (spec `AudioContent`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this content.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "data": { - "description": "The base64-encoded audio data.", - "type": "string" - }, - "mimeType": { - "description": "The MIME type of the audio (e.g. `audio/wav`).", - "type": "string" - } - }, - "required": [ - "data", - "mimeType" - ] - }, - "BooleanSchema": { - "description": "Schema definition for boolean properties.", - "type": "object", - "properties": { - "default": { - "description": "Default value", - "type": [ - "boolean", - "null" - ] - }, - "description": { - "description": "Human-readable description", - "type": [ - "string", - "null" - ] - }, - "title": { - "description": "Optional title for the schema", - "type": [ - "string", - "null" - ] - }, - "type": { - "description": "Type discriminator", - "allOf": [ - { - "$ref": "#/definitions/BooleanTypeConst" - } - ] - } - }, - "required": [ - "type" - ] - }, - "BooleanTypeConst": { - "type": "string", - "format": "const", - "const": "boolean" - }, - "CacheScope": { - "description": "Scope describing who may cache cacheable list/read results (SEP-2549).\n\nDefaults to [`CacheScope::Public`] when absent from the wire.", - "oneOf": [ - { - "description": "Any client or intermediary may cache and serve the response to any user.", - "type": "string", - "const": "public" - }, - { - "description": "Only the requesting user's client may cache the response.", - "type": "string", - "const": "private" - } - ] - }, - "CallToolResult": { - "description": "The result of a tool call operation.\n\nContains the content returned by the tool execution and an optional\nflag indicating whether the operation resulted in an error.", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this result", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "content": { - "description": "The content returned by the tool (text, images, etc.)", - "type": "array", - "default": [], - "items": { - "$ref": "#/definitions/ContentBlock" - } - }, - "isError": { - "description": "Whether this result represents an error condition", - "type": [ - "boolean", - "null" - ] - }, - "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", - "anyOf": [ - { - "$ref": "#/definitions/ResultType" - }, - { - "type": "null" - } - ] - }, - "structuredContent": { - "description": "An optional JSON object that represents the structured result of the tool call" - } - } - }, - "CancelledNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/cancelled" - }, - "CancelledNotificationParam": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "reason": { - "type": [ - "string", - "null" - ] - }, - "requestId": { - "anyOf": [ - { - "$ref": "#/definitions/NumberOrString" - }, - { - "type": "null" - } - ] - } - } - }, - "ClientCapabilities": { - "title": "Builder", - "description": "```rust\n# use rmcp::model::ClientCapabilities;\nlet cap = ClientCapabilities::builder()\n .enable_experimental()\n .build();\n```", - "type": "object", - "properties": { - "elicitation": { - "description": "Capability to handle elicitation requests from servers for interactive user input", - "anyOf": [ - { - "$ref": "#/definitions/ElicitationCapability" - }, - { - "type": "null" - } - ] - }, - "experimental": { - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": "object", - "additionalProperties": true - } - }, - "extensions": { - "description": "Optional MCP extensions that the client supports (SEP-1724).\nKeys are extension identifiers (e.g., `\"io.modelcontextprotocol/ui\"`),\nvalues are per-extension settings objects. An empty object indicates\nsupport with no settings.", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": "object", - "additionalProperties": true - } - }, - "roots": { - "description": "Capability for filesystem roots (deprecated by SEP-2577).", - "anyOf": [ - { - "$ref": "#/definitions/RootsCapabilities" - }, - { - "type": "null" - } - ] - }, - "sampling": { - "description": "Capability for LLM sampling requests (SEP-1577, deprecated by SEP-2577).", - "anyOf": [ - { - "$ref": "#/definitions/SamplingCapability" - }, - { - "type": "null" - } - ] - } - } - }, - "CompleteResult": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "completion": { - "$ref": "#/definitions/CompletionInfo" - }, - "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", - "anyOf": [ - { - "$ref": "#/definitions/ResultType" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "completion" - ] - }, - "CompletionInfo": { - "type": "object", - "properties": { - "hasMore": { - "type": [ - "boolean", - "null" - ] - }, - "total": { - "type": [ - "integer", - "null" - ], - "format": "uint32", - "minimum": 0 - }, - "values": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "values" - ] - }, - "ConstTitle": { - "description": "Schema definition for enum properties.\n\nRepresent single entry for titled item", - "type": "object", - "properties": { - "const": { - "type": "string" - }, - "title": { - "type": "string" - } - }, - "required": [ - "const", - "title" - ] - }, - "ContentBlock": { - "description": "Unified content block union (spec `ContentBlock`).\n\n`text | image | audio | resource_link | resource`", - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - } - }, - "allOf": [ - { - "$ref": "#/definitions/TextContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "image" - } - }, - "allOf": [ - { - "$ref": "#/definitions/ImageContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "audio" - } - }, - "allOf": [ - { - "$ref": "#/definitions/AudioContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "resource" - } - }, - "allOf": [ - { - "$ref": "#/definitions/EmbeddedResource" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "resource_link" - } - }, - "allOf": [ - { - "$ref": "#/definitions/Resource" - } - ], - "required": [ - "type" - ] - } - ] - }, - "ContextInclusion": { - "description": "Specifies how much context should be included in sampling requests.\n\nThis allows clients to control what additional context information\nshould be provided to the LLM when processing sampling requests.", - "oneOf": [ - { - "description": "Include context from all connected MCP servers", - "type": "string", - "const": "allServers" - }, - { - "description": "Include no additional context", - "type": "string", - "const": "none" - }, - { - "description": "Include context only from the requesting server", - "type": "string", - "const": "thisServer" - } - ] - }, - "CreateMessageRequestMethod": { - "type": "string", - "format": "const", - "const": "sampling/createMessage" - }, - "CreateMessageRequestParams": { - "description": "Parameters for creating a message through LLM sampling.\n\nThis structure contains all the necessary information for a client to\ngenerate an LLM response, including conversation history, model preferences,\nand generation parameters.\n\nThis implements `TaskAugmentedRequestParamsMeta` as sampling requests can be\nlong-running and may benefit from task-based execution.", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "includeContext": { - "description": "How much context to include from MCP servers", - "anyOf": [ - { - "$ref": "#/definitions/ContextInclusion" - }, - { - "type": "null" - } - ] - }, - "maxTokens": { - "description": "Maximum number of tokens to generate", - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "messages": { - "description": "The conversation history and current messages", - "type": "array", - "items": { - "$ref": "#/definitions/SamplingMessage" - } - }, - "metadata": { - "description": "Additional metadata for the request" - }, - "modelPreferences": { - "description": "Preferences for model selection and behavior", - "anyOf": [ - { - "$ref": "#/definitions/ModelPreferences" - }, - { - "type": "null" - } - ] - }, - "stopSequences": { - "description": "Sequences that should stop generation", - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "systemPrompt": { - "description": "System prompt to guide the model's behavior", - "type": [ - "string", - "null" - ] - }, - "temperature": { - "description": "Temperature for controlling randomness (0.0 to 1.0)", - "type": [ - "number", - "null" - ], - "format": "float" - }, - "toolChoice": { - "description": "Tool selection behavior (SEP-1577)", - "anyOf": [ - { - "$ref": "#/definitions/ToolChoice" - }, - { - "type": "null" - } - ] - }, - "tools": { - "description": "Tools available for the model to call (SEP-1577)", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Tool" - } - } - }, - "deprecated": true, - "required": [ - "messages", - "maxTokens" - ] - }, - "CreateTaskResult": { - "description": "Result returned in lieu of a standard result to indicate the request will\nbe processed asynchronously (spec `CreateTaskResult`, `resultType: \"task\"`).\n\nThe embedded task is the seed state for the task; the client uses\n`task.task_id` for all subsequent `tasks/get`, `tasks/update`, and\n`tasks/cancel` calls.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "createdAt": { - "description": "ISO 8601 timestamp when the task was created.", - "type": "string" - }, - "lastUpdatedAt": { - "description": "ISO 8601 timestamp when the task was last updated.", - "type": "string" - }, - "pollIntervalMs": { - "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "resultType": { - "description": "Always `\"task\"`.", - "allOf": [ - { - "$ref": "#/definitions/ResultType" - } - ] - }, - "status": { - "description": "Current task status.", - "allOf": [ - { - "$ref": "#/definitions/TaskStatus" - } - ] - }, - "statusMessage": { - "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", - "type": [ - "string", - "null" - ] - }, - "taskId": { - "description": "Stable identifier for this task, generated by the server.", - "type": "string" - }, - "ttlMs": { - "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "resultType", - "taskId", - "status", - "createdAt", - "lastUpdatedAt" - ] - }, - "CustomNotification": { - "description": "A catch-all notification either side can use to send custom messages to its peer.\n\nThis preserves the raw `method` name and `params` payload so handlers can\ndeserialize them into domain-specific types.", - "type": "object", - "properties": { - "method": { - "type": "string" - }, - "params": true - }, - "required": [ - "method" - ] - }, - "CustomRequest": { - "description": "A catch-all request either side can use to send custom messages to its peer.\n\nThis preserves the raw `method` name and `params` payload so handlers can\ndeserialize them into domain-specific types.", - "type": "object", - "properties": { - "method": { - "type": "string" - }, - "params": true - }, - "required": [ - "method" - ] - }, - "CustomResult": { - "description": "A catch-all response either side can use for custom requests." - }, - "DiscoverResult": { - "description": "The server's response to a [`DiscoverRequest`].", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level response metadata.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "cacheScope": { - "description": "Whether the cached result may be shared across authorization contexts.", - "allOf": [ - { - "$ref": "#/definitions/CacheScope" - } - ] - }, - "capabilities": { - "description": "Capabilities provided by this server.", - "allOf": [ - { - "$ref": "#/definitions/ServerCapabilities" - } - ] - }, - "instructions": { - "description": "Optional guidance for using the server.", - "type": [ - "string", - "null" - ] - }, - "resultType": { - "description": "Identifies how the result should be parsed.", - "allOf": [ - { - "$ref": "#/definitions/ResultType" - } - ] - }, - "supportedVersions": { - "description": "Protocol versions implemented by this server.", - "type": "array", - "items": { - "$ref": "#/definitions/ProtocolVersion" - } - }, - "ttlMs": { - "description": "How long clients may consider this response fresh, in milliseconds.", - "type": "integer", - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "resultType", - "supportedVersions", - "capabilities", - "ttlMs", - "cacheScope" - ] - }, - "ElicitRequestParams": { - "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n1. Form-based elicitation request\n```rust\nuse rmcp::model::*;\n\nlet params = ElicitRequestParams::FormElicitationParams {\n meta: None,\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```\n2. URL-based elicitation request\n```rust\nuse rmcp::model::*;\nlet params = ElicitRequestParams::UrlElicitationParams {\n meta: None,\n message: \"Please provide your feedback at the following URL\".to_string(),\n url: \"https://example.com/feedback\".to_string(),\n elicitation_id: \"unique-id-123\".to_string(),\n};\n```", - "anyOf": [ - { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "message": { - "type": "string" - }, - "mode": { - "type": "string", - "const": "form" - }, - "requestedSchema": { - "$ref": "#/definitions/ElicitationSchema" - } - }, - "required": [ - "mode", - "message", - "requestedSchema" - ] - }, - { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "elicitationId": { - "type": "string" - }, - "message": { - "type": "string" - }, - "mode": { - "type": "string", - "const": "url" - }, - "url": { - "type": "string" - } - }, - "required": [ - "mode", - "message", - "url", - "elicitationId" - ] - }, - { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "message": { - "type": "string" - }, - "requestedSchema": { - "$ref": "#/definitions/ElicitationSchema" - } - }, - "required": [ - "message", - "requestedSchema" - ] - } - ] - }, - "ElicitResult": { - "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this result.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "action": { - "description": "The user's decision on how to handle the elicitation request", - "allOf": [ - { - "$ref": "#/definitions/ElicitationAction" - } - ] - }, - "content": { - "description": "The actual data provided by the user, if they accepted the request.\nMust conform to the JSON schema specified in the original request.\nOnly present when action is Accept." - } - }, - "required": [ - "action" - ] - }, - "ElicitationAction": { - "description": "Represents the possible actions a user can take in response to an elicitation request.\n\nWhen a server requests user input through elicitation, the user can:\n- Accept: Provide the requested information and continue\n- Decline: Refuse to provide the information but continue the operation\n- Cancel: Stop the entire operation", - "oneOf": [ - { - "description": "User accepts the request and provides the requested information", - "type": "string", - "const": "accept" - }, - { - "description": "User declines to provide the information but allows the operation to continue", - "type": "string", - "const": "decline" - }, - { - "description": "User cancels the entire operation", - "type": "string", - "const": "cancel" - } - ] - }, - "ElicitationCapability": { - "description": "Elicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.", - "type": "object", - "properties": { - "form": { - "description": "Whether client supports form-based elicitation.", - "anyOf": [ - { - "$ref": "#/definitions/FormElicitationCapability" - }, - { - "type": "null" - } - ] - }, - "url": { - "description": "Whether client supports URL-based elicitation.", - "anyOf": [ - { - "$ref": "#/definitions/UrlElicitationCapability" - }, - { - "type": "null" - } - ] - } - } - }, - "ElicitationCreateRequestMethod": { - "type": "string", - "format": "const", - "const": "elicitation/create" - }, - "ElicitationSchema": { - "description": "Type-safe elicitation schema for requesting structured user input.\n\nThis enforces the MCP 2025-06-18 specification that elicitation schemas\nmust be objects with primitive-typed properties.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet schema = ElicitationSchema::builder()\n .required_email(\"email\")\n .required_integer(\"age\", 0, 150)\n .optional_bool(\"newsletter\", false)\n .build();\n```", - "type": "object", - "properties": { - "description": { - "description": "Optional description of what this schema represents", - "type": [ - "string", - "null" - ] - }, - "properties": { - "description": "Property definitions (must be primitive types)", - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/PrimitiveSchemaDefinition" - } - }, - "required": { - "description": "List of required property names", - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "title": { - "description": "Optional title for the schema", - "type": [ - "string", - "null" - ] - }, - "type": { - "description": "Always \"object\" for elicitation schemas", - "allOf": [ - { - "$ref": "#/definitions/ObjectTypeConst" - } - ] - } - }, - "required": [ - "type", - "properties" - ] - }, - "EmbeddedResource": { - "description": "Embedded resource content (spec `EmbeddedResource`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this content.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "resource": { - "description": "The embedded resource contents (text or blob).", - "allOf": [ - { - "$ref": "#/definitions/ResourceContents" - } - ] - } - }, - "required": [ - "resource" - ] - }, - "EmptyObject": { - "description": "This is commonly used for representing empty objects in MCP messages.\n\nwithout returning any specific data.", - "type": "object", - "additionalProperties": false - }, - "EnumSchema": { - "description": "Compliant with MCP 2025-06-18 specification for elicitation schemas.\nEnums must have string type for values and can optionally include human-readable names.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet enum_schema = EnumSchema::builder(vec![\"US\".to_string(), \"UK\".to_string()])\n .multiselect()\n .min_items(1u64).expect(\"Min items should be correct value\")\n .max_items(4u64).expect(\"Max items should be correct value\")\n .description(\"Country code\")\n .build();\n```", - "anyOf": [ - { - "$ref": "#/definitions/SingleSelectEnumSchema" - }, - { - "$ref": "#/definitions/MultiSelectEnumSchema" - }, - { - "$ref": "#/definitions/LegacyEnumSchema" - } - ] - }, - "ErrorCode": { - "description": "Standard JSON-RPC error codes used throughout the MCP protocol.\n\nThese codes follow the JSON-RPC 2.0 specification and provide\nstandardized error reporting across all MCP implementations.", - "type": "integer", - "format": "int32" - }, - "ErrorData": { - "description": "Error information for JSON-RPC error responses.\n\nThis structure follows the JSON-RPC 2.0 specification for error reporting,\nproviding a standardized way to communicate errors between clients and servers.", - "type": "object", - "properties": { - "code": { - "description": "The error type that occurred (using standard JSON-RPC error codes)", - "allOf": [ - { - "$ref": "#/definitions/ErrorCode" - } - ] - }, - "data": { - "description": "Additional information about the error. The value of this member is defined by the\nsender (e.g. detailed error information, nested errors etc.)." - }, - "message": { - "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", - "type": "string" - } - }, - "required": [ - "code", - "message" - ] - }, - "FormElicitationCapability": { - "description": "Capability for handling elicitation requests from servers.\nElicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.\n\nCapability for form mode elicitation.", - "type": "object", - "properties": { - "schemaValidation": { - "description": "Whether the client supports JSON Schema validation for elicitation responses.\nWhen true, the client will validate user input against the requested_schema\nbefore sending the response back to the server.", - "type": [ - "boolean", - "null" - ] - } - } - }, - "GetPromptResult": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "messages": { - "type": "array", - "items": { - "$ref": "#/definitions/PromptMessage" - } - }, - "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", - "anyOf": [ - { - "$ref": "#/definitions/ResultType" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "messages" - ] - }, - "GetTaskResult": { - "description": "Response to a `tasks/get` request (spec `GetTaskResult = Result & DetailedTask`).\n\n`resultType` is `\"complete\"` — this is the standard result shape for\n`tasks/get`, not a task handle.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "createdAt": { - "description": "ISO 8601 timestamp when the task was created.", - "type": "string" - }, - "error": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "inputRequests": { - "type": [ - "object", - "null" - ], - "additionalProperties": { - "$ref": "#/definitions/InputRequest" - } - }, - "lastUpdatedAt": { - "description": "ISO 8601 timestamp when the task was last updated.", - "type": "string" - }, - "pollIntervalMs": { - "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "result": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "resultType": { - "description": "Result type discriminator. `tasks/get` responses are standard results:\n`\"complete\"` (SEP-2322). Absent values deserialize as `\"complete\"`.", - "allOf": [ - { - "$ref": "#/definitions/ResultType" - } - ], - "default": "complete" - }, - "status": { - "description": "Current task status.", - "allOf": [ - { - "$ref": "#/definitions/TaskStatus" - } - ] - }, - "statusMessage": { - "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", - "type": [ - "string", - "null" - ] - }, - "taskId": { - "description": "Stable identifier for this task, generated by the server.", - "type": "string" - }, - "ttlMs": { - "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt" - ] - }, - "Icon": { - "description": "A URL pointing to an icon resource or a base64-encoded data URI.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- image/png - PNG images (safe, universal compatibility)\n- image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- image/svg+xml - SVG images (scalable but requires security precautions)\n- image/webp - WebP images (modern, efficient format)", - "type": "object", - "properties": { - "mimeType": { - "description": "Optional override if the server's MIME type is missing or generic", - "type": [ - "string", - "null" - ] - }, - "sizes": { - "description": "Size specification, each string should be in WxH format (e.g., `\\\"48x48\\\"`, `\\\"96x96\\\"`) or `\\\"any\\\"` for scalable formats like SVG", - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "src": { - "description": "A standard URI pointing to an icon resource", - "type": "string" - }, - "theme": { - "description": "Optional specifier for the theme this icon is designed for\nIf not provided, the client should assume the icon can be used with any theme.", - "anyOf": [ - { - "$ref": "#/definitions/IconTheme" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "src" - ] - }, - "IconTheme": { - "description": "Icon themes supported by the MCP specification", - "oneOf": [ - { - "description": "Indicates the icon is designed to be used with a light background", - "type": "string", - "const": "light" - }, - { - "description": "Indicates the icon is designed to be used with a dark background", - "type": "string", - "const": "dark" - } - ] - }, - "ImageContent": { - "description": "Image content with base64-encoded data (spec `ImageContent`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this content.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "data": { - "description": "The base64-encoded image data.", - "type": "string" - }, - "mimeType": { - "description": "The MIME type of the image (e.g. `image/png`).", - "type": "string" - } - }, - "required": [ - "data", - "mimeType" - ] - }, - "Implementation": { - "type": "object", - "properties": { - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "name": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "version": { - "type": "string" - }, - "websiteUrl": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "name", - "version" - ] - }, - "InitializeResult": { - "description": "The server's response to an initialization request.\n\nContains the server's protocol version, capabilities, and implementation\ninformation, along with optional instructions for the client.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "capabilities": { - "description": "The capabilities this server provides (tools, resources, prompts, etc.)", - "allOf": [ - { - "$ref": "#/definitions/ServerCapabilities" - } - ] - }, - "instructions": { - "description": "Optional human-readable instructions about using this server", - "type": [ - "string", - "null" - ] - }, - "protocolVersion": { - "description": "The MCP protocol version this server supports", - "allOf": [ - { - "$ref": "#/definitions/ProtocolVersion" - } - ] - }, - "serverInfo": { - "description": "Information about the server implementation", - "allOf": [ - { - "$ref": "#/definitions/Implementation" - } - ] - } - }, - "required": [ - "protocolVersion", - "capabilities", - "serverInfo" - ] - }, - "InputRequest": { - "description": "A server-initiated request that can appear inside [`InputRequests`].\n\nPer the MCP spec, only `CreateMessageRequest` (sampling),\n`ElicitRequest` (elicitation), and `ListRootsRequest` (roots)\nare allowed. This is modeled as an untagged enum rather than a\n`ServerRequest` alias to prevent `PingRequest` or `CustomRequest` from\nbeing included.", - "anyOf": [ - { - "description": "A `sampling/createMessage` request.", - "allOf": [ - { - "$ref": "#/definitions/Request" - } - ] - }, - { - "description": "An `elicitation/create` request.", - "allOf": [ - { - "$ref": "#/definitions/Request2" - } - ] - }, - { - "description": "A `roots/list` request.", - "allOf": [ - { - "$ref": "#/definitions/RequestNoParam2" - } - ] - } - ] - }, - "InputRequiredResult": { - "description": "A result indicating that additional input is needed before the request\ncan be completed.\n\nAt least one of [`input_requests`](Self::input_requests) or\n[`request_state`](Self::request_state) MUST be present.\n\nServers MAY send this in response to `tools/call`, `prompts/get`, or\n`resources/read`. Servers MUST NOT send this for any other request.\n\n# Examples\n\n```\nuse rmcp::model::InputRequiredResult;\n\nlet result = InputRequiredResult::from_request_state(\"opaque-server-state\");\nassert!(result.input_requests.is_none());\nassert_eq!(result.request_state.as_deref(), Some(\"opaque-server-state\"));\n```", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "inputRequests": { - "description": "Server-initiated requests that the client must fulfill before retrying.", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "$ref": "#/definitions/InputRequest" - } - }, - "requestState": { - "description": "Opaque request state to be echoed back by the client on retry.\nClients MUST NOT inspect, parse, modify, or make any assumptions\nabout the contents.", - "type": [ - "string", - "null" - ] - }, - "resultType": { - "description": "Always `\"input_required\"` for this result type.", - "allOf": [ - { - "$ref": "#/definitions/ResultType" - } - ] - } - }, - "required": [ - "resultType" - ] - }, - "IntegerSchema": { - "description": "Schema definition for integer properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", - "type": "object", - "properties": { - "default": { - "description": "Default value", - "type": [ - "integer", - "null" - ], - "format": "int64" - }, - "description": { - "description": "Human-readable description", - "type": [ - "string", - "null" - ] - }, - "maximum": { - "description": "Maximum value (inclusive)", - "type": [ - "integer", - "null" - ], - "format": "int64" - }, - "minimum": { - "description": "Minimum value (inclusive)", - "type": [ - "integer", - "null" - ], - "format": "int64" - }, - "title": { - "description": "Optional title for the schema", - "type": [ - "string", - "null" - ] - }, - "type": { - "description": "Type discriminator", - "allOf": [ - { - "$ref": "#/definitions/IntegerTypeConst" - } - ] - } - }, - "required": [ - "type" - ] - }, - "IntegerTypeConst": { - "type": "string", - "format": "const", - "const": "integer" - }, - "JsonRpcError": { - "type": "object", - "properties": { - "error": { - "$ref": "#/definitions/ErrorData" - }, - "id": { - "anyOf": [ - { - "$ref": "#/definitions/NumberOrString" - }, - { - "type": "null" - } - ] - }, - "jsonrpc": { - "$ref": "#/definitions/JsonRpcVersion2_0" - } - }, - "required": [ - "jsonrpc", - "error" - ] - }, - "JsonRpcNotification": { - "type": "object", - "properties": { - "jsonrpc": { - "$ref": "#/definitions/JsonRpcVersion2_0" - } - }, - "anyOf": [ - { - "$ref": "#/definitions/Notification" - }, - { - "$ref": "#/definitions/Notification2" - }, - { - "$ref": "#/definitions/Notification3" - }, - { - "$ref": "#/definitions/Notification4" - }, - { - "$ref": "#/definitions/NotificationNoParam" - }, - { - "$ref": "#/definitions/NotificationNoParam2" - }, - { - "$ref": "#/definitions/NotificationNoParam3" - }, - { - "$ref": "#/definitions/Notification5" - }, - { - "$ref": "#/definitions/Notification6" - }, - { - "$ref": "#/definitions/CustomNotification" - } - ], - "required": [ - "jsonrpc" - ] - }, - "JsonRpcRequest": { - "type": "object", - "properties": { - "id": { - "$ref": "#/definitions/NumberOrString" - }, - "jsonrpc": { - "$ref": "#/definitions/JsonRpcVersion2_0" - } - }, - "anyOf": [ - { - "$ref": "#/definitions/RequestNoParam" - }, - { - "$ref": "#/definitions/Request" - }, - { - "$ref": "#/definitions/RequestNoParam2" - }, - { - "$ref": "#/definitions/Request2" - }, - { - "$ref": "#/definitions/CustomRequest" - } - ], - "required": [ - "jsonrpc", - "id" - ] - }, - "JsonRpcResponse": { - "type": "object", - "properties": { - "id": { - "$ref": "#/definitions/NumberOrString" - }, - "jsonrpc": { - "$ref": "#/definitions/JsonRpcVersion2_0" - }, - "result": { - "$ref": "#/definitions/ServerResult" - } - }, - "required": [ - "jsonrpc", - "id", - "result" - ] - }, - "JsonRpcVersion2_0": { - "type": "string", - "format": "const", - "const": "2.0" - }, - "LegacyEnumSchema": { - "description": "Legacy enum schema, keep for backward compatibility", - "type": "object", - "properties": { - "default": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "enum": { - "type": "array", - "items": { - "type": "string" - } - }, - "enumNames": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "type": { - "$ref": "#/definitions/StringTypeConst" - } - }, - "required": [ - "type", - "enum" - ] - }, - "ListPromptsResult": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "cacheScope": { - "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", - "anyOf": [ - { - "$ref": "#/definitions/CacheScope" - }, - { - "type": "null" - } - ] - }, - "nextCursor": { - "type": [ - "string", - "null" - ] - }, - "prompts": { - "type": "array", - "items": { - "$ref": "#/definitions/Prompt" - } - }, - "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", - "anyOf": [ - { - "$ref": "#/definitions/ResultType" - }, - { - "type": "null" - } - ] - }, - "ttlMs": { - "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "prompts" - ] - }, - "ListResourceTemplatesResult": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "cacheScope": { - "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", - "anyOf": [ - { - "$ref": "#/definitions/CacheScope" - }, - { - "type": "null" - } - ] - }, - "nextCursor": { - "type": [ - "string", - "null" - ] - }, - "resourceTemplates": { - "type": "array", - "items": { - "$ref": "#/definitions/ResourceTemplate" - } - }, - "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", - "anyOf": [ - { - "$ref": "#/definitions/ResultType" - }, - { - "type": "null" - } - ] - }, - "ttlMs": { - "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "resourceTemplates" - ] - }, - "ListResourcesResult": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "cacheScope": { - "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", - "anyOf": [ - { - "$ref": "#/definitions/CacheScope" - }, - { - "type": "null" - } - ] - }, - "nextCursor": { - "type": [ - "string", - "null" - ] - }, - "resources": { - "type": "array", - "items": { - "$ref": "#/definitions/Resource" - } - }, - "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", - "anyOf": [ - { - "$ref": "#/definitions/ResultType" - }, - { - "type": "null" - } - ] - }, - "ttlMs": { - "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "resources" - ] - }, - "ListRootsRequestMethod": { - "type": "string", - "format": "const", - "const": "roots/list" - }, - "ListToolsResult": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "cacheScope": { - "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", - "anyOf": [ - { - "$ref": "#/definitions/CacheScope" - }, - { - "type": "null" - } - ] - }, - "nextCursor": { - "type": [ - "string", - "null" - ] - }, - "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", - "anyOf": [ - { - "$ref": "#/definitions/ResultType" - }, - { - "type": "null" - } - ] - }, - "tools": { - "type": "array", - "items": { - "$ref": "#/definitions/Tool" - } - }, - "ttlMs": { - "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "tools" - ] - }, - "LoggingLevel": { - "description": "Logging levels supported by the MCP protocol", - "type": "string", - "deprecated": true, - "enum": [ - "debug", - "info", - "notice", - "warning", - "error", - "critical", - "alert", - "emergency" - ] - }, - "LoggingMessageNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/message" - }, - "LoggingMessageNotificationParam": { - "description": "Parameters for a logging message notification", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "data": { - "description": "The actual log data" - }, - "level": { - "description": "The severity level of this log message", - "allOf": [ - { - "$ref": "#/definitions/LoggingLevel" - } - ] - }, - "logger": { - "description": "Optional logger name that generated this message", - "type": [ - "string", - "null" - ] - } - }, - "deprecated": true, - "required": [ - "level", - "data" - ] - }, - "MetaObject": { - "description": "See [MCP general fields](https://modelcontextprotocol.io/specification/2026-07-28/basic#general-fields) for notes on _meta usage.", - "type": "object", - "additionalProperties": true - }, - "ModelHint": { - "description": "A hint suggesting a preferred model name or family.\n\nModel hints are advisory suggestions that help clients choose appropriate\nmodels. They can be specific model names or general families like \"claude\" or \"gpt\".", - "type": "object", - "properties": { - "name": { - "description": "The suggested model name or family identifier", - "type": [ - "string", - "null" - ] - } - }, - "deprecated": true - }, - "ModelPreferences": { - "description": "Preferences for model selection and behavior in sampling requests.\n\nThis allows servers to express their preferences for which model to use\nand how to balance different priorities when the client has multiple\nmodel options available.", - "type": "object", - "properties": { - "costPriority": { - "description": "Priority for cost optimization (0.0 to 1.0, higher = prefer cheaper models)", - "type": [ - "number", - "null" - ], - "format": "float" - }, - "hints": { - "description": "Specific model names or families to prefer (e.g., \"claude\", \"gpt\")", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/ModelHint" - } - }, - "intelligencePriority": { - "description": "Priority for intelligence/capability (0.0 to 1.0, higher = prefer more capable models)", - "type": [ - "number", - "null" - ], - "format": "float" - }, - "speedPriority": { - "description": "Priority for speed/latency (0.0 to 1.0, higher = prefer faster models)", - "type": [ - "number", - "null" - ], - "format": "float" - } - }, - "deprecated": true - }, - "MultiSelectEnumSchema": { - "description": "Multi-select enum options", - "anyOf": [ - { - "$ref": "#/definitions/UntitledMultiSelectEnumSchema" - }, - { - "$ref": "#/definitions/TitledMultiSelectEnumSchema" - } - ] - }, - "Notification": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/CancelledNotificationMethod" - }, - "params": { - "$ref": "#/definitions/CancelledNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, - "Notification2": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ProgressNotificationMethod" - }, - "params": { - "$ref": "#/definitions/ProgressNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, - "Notification3": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/LoggingMessageNotificationMethod" - }, - "params": { - "$ref": "#/definitions/LoggingMessageNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, - "Notification4": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ResourceUpdatedNotificationMethod" - }, - "params": { - "$ref": "#/definitions/ResourceUpdatedNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, - "Notification5": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/SubscriptionsAcknowledgedNotificationMethod" - }, - "params": { - "$ref": "#/definitions/SubscriptionsAcknowledgedNotificationParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Notification6": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/TaskStatusNotificationMethod" - }, - "params": { - "$ref": "#/definitions/TaskStatusNotificationParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "NotificationMetaObject": { - "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", - "type": "object", - "properties": { - "io.modelcontextprotocol/subscriptionId": { - "$ref": "#/definitions/NumberOrString" - } - }, - "additionalProperties": true - }, - "NotificationNoParam": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ResourceListChangedNotificationMethod" - } - }, - "required": [ - "method" - ] - }, - "NotificationNoParam2": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ToolListChangedNotificationMethod" - } - }, - "required": [ - "method" - ] - }, - "NotificationNoParam3": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/PromptListChangedNotificationMethod" - } - }, - "required": [ - "method" - ] - }, - "NumberOrString": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string" - } - ] - }, - "NumberSchema": { - "description": "Schema definition for number properties (floating-point).\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", - "type": "object", - "properties": { - "default": { - "description": "Default value", - "type": [ - "number", - "null" - ], - "format": "double" - }, - "description": { - "description": "Human-readable description", - "type": [ - "string", - "null" - ] - }, - "maximum": { - "description": "Maximum value (inclusive)", - "type": [ - "number", - "null" - ], - "format": "double" - }, - "minimum": { - "description": "Minimum value (inclusive)", - "type": [ - "number", - "null" - ], - "format": "double" - }, - "title": { - "description": "Optional title for the schema", - "type": [ - "string", - "null" - ] - }, - "type": { - "description": "Type discriminator", - "allOf": [ - { - "$ref": "#/definitions/NumberTypeConst" - } - ] - } - }, - "required": [ - "type" - ] - }, - "NumberTypeConst": { - "type": "string", - "format": "const", - "const": "number" - }, - "ObjectTypeConst": { - "type": "string", - "format": "const", - "const": "object" - }, - "PingRequestMethod": { - "type": "string", - "format": "const", - "const": "ping" - }, - "PrimitiveSchemaDefinition": { - "description": "Primitive schema definition for elicitation properties.\n\nAccording to MCP 2025-06-18 specification, elicitation schemas must have\nproperties of primitive types only (string, number, integer, boolean, enum).\n\nNote: Put Enum as the first variant to avoid ambiguity during deserialization.\nThis is due to the fact that EnumSchema can contain StringSchema internally and serde\nuses first match wins strategy when deserializing untagged enums.", - "anyOf": [ - { - "description": "Enum property (explicit enum schema)", - "allOf": [ - { - "$ref": "#/definitions/EnumSchema" - } - ] - }, - { - "description": "String property (with optional enum constraint)", - "allOf": [ - { - "$ref": "#/definitions/StringSchema" - } - ] - }, - { - "description": "Number property (with optional enum constraint)", - "allOf": [ - { - "$ref": "#/definitions/NumberSchema" - } - ] - }, - { - "description": "Integer property (with optional enum constraint)", - "allOf": [ - { - "$ref": "#/definitions/IntegerSchema" - } - ] - }, - { - "description": "Boolean property", - "allOf": [ - { - "$ref": "#/definitions/BooleanSchema" - } - ] - } - ] - }, - "ProgressNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/progress" - }, - "ProgressNotificationParam": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "message": { - "description": "An optional message describing the current progress.", - "type": [ - "string", - "null" - ] - }, - "progress": { - "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", - "type": "number", - "format": "double" - }, - "progressToken": { - "$ref": "#/definitions/ProgressToken" - }, - "total": { - "description": "Total number of items to process (or total progress required), if known", - "type": [ - "number", - "null" - ], - "format": "double" - } - }, - "required": [ - "progressToken", - "progress" - ] - }, - "ProgressToken": { - "description": "A token used to track the progress of long-running operations.\n\nProgress tokens allow clients and servers to associate progress notifications\nwith specific requests, enabling real-time updates on operation status.", - "allOf": [ - { - "$ref": "#/definitions/NumberOrString" - } - ] - }, - "Prompt": { - "description": "A prompt or prompt template that the server offers (spec `Prompt`).", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "arguments": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/PromptArgument" - } - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "name": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "name" - ] - }, - "PromptArgument": { - "description": "Describes an argument that a prompt can accept (spec `PromptArgument`).", - "type": "object", - "properties": { - "description": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "required": { - "type": [ - "boolean", - "null" - ] - }, - "title": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "name" - ] - }, - "PromptListChangedNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/prompts/list_changed" - }, - "PromptMessage": { - "description": "A message returned as part of a prompt (spec `PromptMessage`).\n\nUses the unified `ContentBlock` for its content (text | image | audio | resource_link | resource).", - "type": "object", - "properties": { - "content": { - "$ref": "#/definitions/ContentBlock" - }, - "role": { - "$ref": "#/definitions/Role" - } - }, - "required": [ - "role", - "content" - ] - }, - "PromptsCapability": { - "type": "object", - "properties": { - "listChanged": { - "type": [ - "boolean", - "null" - ] - } - } - }, - "ProtocolVersion": { - "description": "Represents the MCP protocol version used for communication.\n\nThis ensures compatibility between clients and servers by specifying\nwhich version of the Model Context Protocol is being used.", - "type": "string" - }, - "ReadResourceResult": { - "description": "Result containing the contents of a read resource", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "cacheScope": { - "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", - "anyOf": [ - { - "$ref": "#/definitions/CacheScope" - }, - { - "type": "null" - } - ] - }, - "contents": { - "description": "The actual content of the resource", - "type": "array", - "items": { - "$ref": "#/definitions/ResourceContents" - } - }, - "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", - "anyOf": [ - { - "$ref": "#/definitions/ResultType" - }, - { - "type": "null" - } - ] - }, - "ttlMs": { - "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "contents" - ] - }, - "Request": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/CreateMessageRequestMethod" - }, - "params": { - "$ref": "#/definitions/CreateMessageRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request2": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ElicitationCreateRequestMethod" - }, - "params": { - "$ref": "#/definitions/ElicitRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "RequestMetaObject": { - "description": "Metadata reserved by MCP on requests. Extension keys are also allowed.", - "type": "object", - "properties": { - "io.modelcontextprotocol/clientCapabilities": { - "$ref": "#/definitions/ClientCapabilities" - }, - "io.modelcontextprotocol/clientInfo": { - "$ref": "#/definitions/Implementation" - }, - "io.modelcontextprotocol/logLevel": { - "$ref": "#/definitions/LoggingLevel" - }, - "io.modelcontextprotocol/protocolVersion": { - "type": "string" - }, - "progressToken": { - "$ref": "#/definitions/ProgressToken" - } - }, - "additionalProperties": true - }, - "RequestNoParam": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/PingRequestMethod" - } - }, - "required": [ - "method" - ] - }, - "RequestNoParam2": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ListRootsRequestMethod" - } - }, - "required": [ - "method" - ] - }, - "Resource": { - "description": "A known resource that the server is capable of reading (spec `Resource`).\n\nAlso used as the inner type of `ContentBlock::ResourceLink` (spec `ResourceLink extends Resource`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this resource.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this resource.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "description": { - "description": "Optional description of what this resource represents.", - "type": [ - "string", - "null" - ] - }, - "icons": { - "description": "Optional set of icons the client may display for this resource.", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "mimeType": { - "description": "The MIME type of this resource, if known.", - "type": [ - "string", - "null" - ] - }, - "name": { - "description": "The programmatic name of the resource.", - "type": "string" - }, - "size": { - "description": "The size of the raw resource content in bytes (before base64/tokenization), if known.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "title": { - "description": "Optional human-readable display title.", - "type": [ - "string", - "null" - ] - }, - "uri": { - "description": "The URI of this resource (e.g. `file:///path/to/file`).", - "type": "string" - } - }, - "required": [ - "uri", - "name" - ] - }, - "ResourceContents": { - "description": "The contents of a specific resource or sub-resource.", - "anyOf": [ - { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "text": { - "type": "string" - }, - "uri": { - "type": "string" - } - }, - "required": [ - "uri", - "text" - ] - }, - { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "blob": { - "type": "string" - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "uri": { - "type": "string" - } - }, - "required": [ - "uri", - "blob" - ] - } - ] - }, - "ResourceListChangedNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/resources/list_changed" - }, - "ResourceTemplate": { - "description": "A template description for resources available on the server (spec `ResourceTemplate`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this resource template.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this template.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "description": { - "description": "Optional description of what this template is for.", - "type": [ - "string", - "null" - ] - }, - "icons": { - "description": "Optional set of icons the client may display for this template.", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "mimeType": { - "description": "The MIME type for resources matching this template, if uniform.", - "type": [ - "string", - "null" - ] - }, - "name": { - "description": "The programmatic name of the resource template.", - "type": "string" - }, - "title": { - "description": "Optional human-readable display title.", - "type": [ - "string", - "null" - ] - }, - "uriTemplate": { - "description": "An RFC 6570 URI template for constructing resource URIs.", - "type": "string" - } - }, - "required": [ - "uriTemplate", - "name" - ] - }, - "ResourceUpdatedNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/resources/updated" - }, - "ResourceUpdatedNotificationParam": { - "description": "Parameters for a resource update notification", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "uri": { - "description": "The URI of the resource that was updated", - "type": "string" - } - }, - "required": [ - "uri" - ] - }, - "ResourcesCapability": { - "type": "object", - "properties": { - "listChanged": { - "type": [ - "boolean", - "null" - ] - }, - "subscribe": { - "type": [ - "boolean", - "null" - ] - } - } - }, - "ResultType": { - "description": "Indicates the type of a result object, allowing the client to\ndetermine how to parse the response.\n\nThe spec defines this as an open string (`\"complete\" | \"input_required\" | string`),\nso unknown values are preserved rather than rejected. Servers implementing this\nprotocol version MUST include `resultType` in every result. For backward\ncompatibility, clients MUST treat an absent field as `\"complete\"`.\n\nOrdinary results model the field as `Option`: `None` means the\nfield is absent on the wire. Constructors default to `Some(COMPLETE)`, and\nthe server handler strips the `\"complete\"` discriminator before responding\nto peers that negotiated a protocol version older than `2026-07-28`, so\nlegacy sessions keep their historical wire shape (see\n[`ServerResult::strip_result_type_for_legacy_peer`]).", - "type": "string" - }, - "Role": { - "description": "Represents the role of a participant in a conversation or message exchange.\n\nUsed in sampling and chat contexts to distinguish between different\ntypes of message senders in the conversation flow.", - "oneOf": [ - { - "description": "A human user or client making a request", - "type": "string", - "const": "user" - }, - { - "description": "An AI assistant or server providing a response", - "type": "string", - "const": "assistant" - } - ] - }, - "RootsCapabilities": { - "description": "Roots capability. Deprecated by SEP-2577; remains functional and will be\nremoved in a future release.\nSee .", - "type": "object", - "properties": { - "listChanged": { - "type": [ - "boolean", - "null" - ] - } - } - }, - "SamplingCapability": { - "description": "Sampling capability with optional sub-capabilities (SEP-1577).\n\nDeprecated by SEP-2577; remains functional and will be removed in a future\nrelease.\nSee .", - "type": "object", - "properties": { - "context": { - "description": "Support for `includeContext` (soft-deprecated)", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "tools": { - "description": "Support for `tools` and `toolChoice` parameters", - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, - "SamplingContent": { - "description": "Single or array content wrapper (SEP-1577).", - "anyOf": [ - { - "$ref": "#/definitions/SamplingMessageContentBlock" - }, - { - "type": "array", - "items": { - "$ref": "#/definitions/SamplingMessageContentBlock" - } - } - ] - }, - "SamplingMessage": { - "description": "A message in a sampling conversation, containing a role and content.\n\nThis represents a single message in a conversation flow, used primarily\nin LLM sampling requests where the conversation history is important\nfor generating appropriate responses.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "content": { - "description": "The actual content of the message (text, image, audio, tool use, or tool result)", - "allOf": [ - { - "$ref": "#/definitions/SamplingContent" - } - ] - }, - "role": { - "description": "The role of the message sender (User or Assistant)", - "allOf": [ - { - "$ref": "#/definitions/Role" - } - ] - } - }, - "deprecated": true, - "required": [ - "role", - "content" - ] - }, - "SamplingMessageContentBlock": { - "description": "Content types for sampling messages (SEP-1577).", - "deprecated": true, - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - } - }, - "allOf": [ - { - "$ref": "#/definitions/TextContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "image" - } - }, - "allOf": [ - { - "$ref": "#/definitions/ImageContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "audio" - } - }, - "allOf": [ - { - "$ref": "#/definitions/AudioContent" - } - ], - "required": [ - "type" - ] - }, - { - "description": "Assistant only", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tool_use" - } - }, - "allOf": [ - { - "$ref": "#/definitions/ToolUseContent" - } - ], - "required": [ - "type" - ] - }, - { - "description": "User only", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tool_result" - } - }, - "allOf": [ - { - "$ref": "#/definitions/ToolResultContent" - } - ], - "required": [ - "type" - ] - } - ] - }, - "ServerCapabilities": { - "title": "Builder", - "description": "```rust\n# use rmcp::model::ServerCapabilities;\nlet cap = ServerCapabilities::builder()\n .enable_experimental()\n .enable_prompts()\n .enable_resources()\n .enable_tools()\n .enable_tool_list_changed()\n .build();\n```", - "type": "object", - "properties": { - "completions": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "experimental": { - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": "object", - "additionalProperties": true - } - }, - "extensions": { - "description": "Optional MCP extensions that the server supports (SEP-1724).\nKeys are extension identifiers (e.g., `\"io.modelcontextprotocol/apps\"`),\nvalues are per-extension settings objects. An empty object indicates\nsupport with no settings.", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": "object", - "additionalProperties": true - } - }, - "logging": { - "description": "Capability for server log message notifications (deprecated by SEP-2577).", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "prompts": { - "anyOf": [ - { - "$ref": "#/definitions/PromptsCapability" - }, - { - "type": "null" - } - ] - }, - "resources": { - "anyOf": [ - { - "$ref": "#/definitions/ResourcesCapability" - }, - { - "type": "null" - } - ] - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/ToolsCapability" - }, - { - "type": "null" - } - ] - } - } - }, - "ServerResult": { - "anyOf": [ - { - "$ref": "#/definitions/DiscoverResult" - }, - { - "$ref": "#/definitions/InitializeResult" - }, - { - "$ref": "#/definitions/CompleteResult" - }, - { - "$ref": "#/definitions/GetPromptResult" - }, - { - "$ref": "#/definitions/ListPromptsResult" - }, - { - "$ref": "#/definitions/ListResourcesResult" - }, - { - "$ref": "#/definitions/ListResourceTemplatesResult" - }, - { - "$ref": "#/definitions/ReadResourceResult" - }, - { - "$ref": "#/definitions/SubscriptionsListenResult" - }, - { - "$ref": "#/definitions/ListToolsResult" - }, - { - "$ref": "#/definitions/ElicitResult" - }, - { - "$ref": "#/definitions/CreateTaskResult" - }, - { - "$ref": "#/definitions/GetTaskResult" - }, - { - "$ref": "#/definitions/CallToolResult" - }, - { - "$ref": "#/definitions/InputRequiredResult" - }, - { - "$ref": "#/definitions/TaskAckResult" - }, - { - "$ref": "#/definitions/EmptyObject" - }, - { - "$ref": "#/definitions/CustomResult" - } - ] - }, - "SingleSelectEnumSchema": { - "description": "Combined single-select", - "anyOf": [ - { - "$ref": "#/definitions/UntitledSingleSelectEnumSchema" - }, - { - "$ref": "#/definitions/TitledSingleSelectEnumSchema" - } - ] - }, - "StringFormat": { - "description": "String format types allowed by the MCP specification.", - "oneOf": [ - { - "description": "Email address format", - "type": "string", - "const": "email" - }, - { - "description": "URI format", - "type": "string", - "const": "uri" - }, - { - "description": "Date format (YYYY-MM-DD)", - "type": "string", - "const": "date" - }, - { - "description": "Date-time format (ISO 8601)", - "type": "string", - "const": "date-time" - } - ] - }, - "StringSchema": { - "description": "Schema definition for string properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec:\n- format limited to: \"email\", \"uri\", \"date\", \"date-time\"", - "type": "object", - "properties": { - "default": { - "description": "Default value", - "type": [ - "string", - "null" - ] - }, - "description": { - "description": "Human-readable description", - "type": [ - "string", - "null" - ] - }, - "format": { - "description": "String format - limited to: \"email\", \"uri\", \"date\", \"date-time\"", - "anyOf": [ - { - "$ref": "#/definitions/StringFormat" - }, - { - "type": "null" - } - ] - }, - "maxLength": { - "description": "Maximum string length", - "type": [ - "integer", - "null" - ], - "format": "uint32", - "minimum": 0 - }, - "minLength": { - "description": "Minimum string length", - "type": [ - "integer", - "null" - ], - "format": "uint32", - "minimum": 0 - }, - "title": { - "description": "Optional title for the schema", - "type": [ - "string", - "null" - ] - }, - "type": { - "description": "Type discriminator", - "allOf": [ - { - "$ref": "#/definitions/StringTypeConst" - } - ] - } - }, - "required": [ - "type" - ] - }, - "StringTypeConst": { - "type": "string", - "format": "const", - "const": "string" - }, - "SubscriptionFilter": { - "description": "Notification categories a client opts in to on a `subscriptions/listen` stream.", - "type": "object", - "properties": { - "promptsListChanged": { - "type": "boolean" - }, - "resourceSubscriptions": { - "type": "array", - "items": { - "type": "string" - } - }, - "resourcesListChanged": { - "type": "boolean" - }, - "toolsListChanged": { - "type": "boolean" - } - } - }, - "SubscriptionsAcknowledgedNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/subscriptions/acknowledged" - }, - "SubscriptionsAcknowledgedNotificationParams": { - "description": "Parameters reporting the accepted subset of a subscription filter.", - "type": "object", - "properties": { - "_meta": { - "$ref": "#/definitions/NotificationMetaObject" - }, - "notifications": { - "$ref": "#/definitions/SubscriptionFilter" - } - }, - "required": [ - "notifications" - ] - }, - "SubscriptionsListenResult": { - "description": "Final response indicating that a subscription ended gracefully.", - "type": "object", - "properties": { - "_meta": { - "$ref": "#/definitions/SubscriptionsListenResultMeta" - }, - "resultType": { - "$ref": "#/definitions/ResultType" - } - }, - "required": [ - "resultType", - "_meta" - ] - }, - "SubscriptionsListenResultMeta": { - "type": "object", - "properties": { - "io.modelcontextprotocol/serverInfo": { - "description": "Identifies the server software producing the response. Servers SHOULD include this field on every response unless specifically configured not to do so.", - "allOf": [ - { - "$ref": "#/definitions/Implementation" - } - ] - }, - "io.modelcontextprotocol/subscriptionId": { - "$ref": "#/definitions/NumberOrString" - } - }, - "additionalProperties": true, - "required": [ - "io.modelcontextprotocol/subscriptionId" - ] - }, - "TaskAckResult": { - "description": "Empty acknowledgement for `tasks/update` and `tasks/cancel` (SEP-2663).\n\nThe spec requires these acks to be empty results carrying the SEP-2322\n`resultType: \"complete\"` discriminator; task state changes are observed\nvia the next `tasks/get`.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "resultType": { - "description": "Always `\"complete\"`.", - "allOf": [ - { - "$ref": "#/definitions/ResultType" - } - ] - } - }, - "required": [ - "resultType" - ] - }, - "TaskStatus": { - "description": "Canonical task lifecycle status (SEP-2663).", - "oneOf": [ - { - "description": "The request is currently being processed.", - "type": "string", - "const": "working" - }, - { - "description": "The server needs input from the client before the task can proceed.", - "type": "string", - "const": "input_required" - }, - { - "description": "The request completed successfully and the result is available.\nThis includes tool calls that returned results with `isError: true`.", - "type": "string", - "const": "completed" - }, - { - "description": "The request failed due to a JSON-RPC error during execution.", - "type": "string", - "const": "failed" - }, - { - "description": "The request was cancelled before completion.", - "type": "string", - "const": "cancelled" - } - ] - }, - "TaskStatusNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/tasks" - }, - "TaskStatusNotificationParams": { - "description": "Parameters for a task status notification (spec `TaskStatusNotificationParams`).\n\nCarries a complete [`DetailedTask`] for the current status, identical to\nwhat `tasks/get` would have returned at that moment. The task fields are\nflattened at the top level: `NotificationParams & Task`.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "createdAt": { - "description": "ISO 8601 timestamp when the task was created.", - "type": "string" - }, - "error": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "inputRequests": { - "type": [ - "object", - "null" - ], - "additionalProperties": { - "$ref": "#/definitions/InputRequest" - } - }, - "lastUpdatedAt": { - "description": "ISO 8601 timestamp when the task was last updated.", - "type": "string" - }, - "pollIntervalMs": { - "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "result": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "status": { - "description": "Current task status.", - "allOf": [ - { - "$ref": "#/definitions/TaskStatus" - } - ] - }, - "statusMessage": { - "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", - "type": [ - "string", - "null" - ] - }, - "taskId": { - "description": "Stable identifier for this task, generated by the server.", - "type": "string" - }, - "ttlMs": { - "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt" - ] - }, - "TextContent": { - "description": "Text content block (spec `TextContent`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this content.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "text": { - "description": "The text content of the message.", - "type": "string" - } - }, - "required": [ - "text" - ] - }, - "TitledItems": { - "description": "Items for titled multi-select options", - "type": "object", - "properties": { - "anyOf": { - "type": "array", - "items": { - "$ref": "#/definitions/ConstTitle" - } - } - }, - "required": [ - "anyOf" - ] - }, - "TitledMultiSelectEnumSchema": { - "description": "Multi-select titled options", - "type": "object", - "properties": { - "default": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "items": { - "$ref": "#/definitions/TitledItems" - }, - "maxItems": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "minItems": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "type": { - "$ref": "#/definitions/ArrayTypeConst" - } - }, - "required": [ - "type", - "items" - ] - }, - "TitledSingleSelectEnumSchema": { - "description": "Titled single-select", - "type": "object", - "properties": { - "default": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "oneOf": { - "type": "array", - "items": { - "$ref": "#/definitions/ConstTitle" - } - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "type": { - "$ref": "#/definitions/StringTypeConst" - } - }, - "required": [ - "type", - "oneOf" - ] - }, - "Tool": { - "description": "A tool that can be used by a model.", - "type": "object", - "properties": { - "_meta": { - "description": "Optional additional metadata for this tool", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional additional tool information.", - "anyOf": [ - { - "$ref": "#/definitions/ToolAnnotations" - }, - { - "type": "null" - } - ] - }, - "description": { - "description": "A description of what the tool does", - "type": [ - "string", - "null" - ] - }, - "icons": { - "description": "Optional list of icons for the tool", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "inputSchema": { - "description": "A JSON Schema object defining the expected parameters for the tool", - "type": "object", - "additionalProperties": true - }, - "name": { - "description": "The name of the tool", - "type": "string" - }, - "outputSchema": { - "description": "An optional JSON Schema object defining the structure of the tool's output", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "title": { - "description": "A human-readable title for the tool", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "name", - "inputSchema" - ] - }, - "ToolAnnotations": { - "description": "Additional properties describing a Tool to clients.\n\nNOTE: all properties in ToolAnnotations are **hints**.\nThey are not guaranteed to provide a faithful description of\ntool behavior (including descriptive properties like `title`).\n\nClients should never make tool use decisions based on ToolAnnotations\nreceived from untrusted servers.", - "type": "object", - "properties": { - "destructiveHint": { - "description": "If true, the tool may perform destructive updates to its environment.\nIf false, the tool performs only additive updates.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: true\nA human-readable description of the tool's purpose.", - "type": [ - "boolean", - "null" - ] - }, - "idempotentHint": { - "description": "If true, calling the tool repeatedly with the same arguments\nwill have no additional effect on the its environment.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: false.", - "type": [ - "boolean", - "null" - ] - }, - "openWorldHint": { - "description": "If true, this tool may interact with an \"open world\" of external\nentities. If false, the tool's domain of interaction is closed.\nFor example, the world of a web search tool is open, whereas that\nof a memory tool is not.\n\nDefault: true", - "type": [ - "boolean", - "null" - ] - }, - "readOnlyHint": { - "description": "If true, the tool does not modify its environment.\n\nDefault: false", - "type": [ - "boolean", - "null" - ] - }, - "title": { - "description": "A human-readable title for the tool.", - "type": [ - "string", - "null" - ] - } - } - }, - "ToolChoice": { - "description": "Tool choice configuration (SEP-1577).", - "type": "object", - "properties": { - "mode": { - "anyOf": [ - { - "$ref": "#/definitions/ToolChoiceMode" - }, - { - "type": "null" - } - ] - } - }, - "deprecated": true - }, - "ToolChoiceMode": { - "description": "Tool selection mode (SEP-1577).", - "oneOf": [ - { - "description": "Model decides whether to use tools", - "type": "string", - "const": "auto" - }, - { - "description": "Model must use at least one tool", - "type": "string", - "const": "required" - }, - { - "description": "Model must not use tools", - "type": "string", - "const": "none" - } - ] - }, - "ToolListChangedNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/tools/list_changed" - }, - "ToolResultContent": { - "description": "Tool execution result in user message (SEP-1577).", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "content": { - "type": "array", - "items": { - "$ref": "#/definitions/ContentBlock" - } - }, - "isError": { - "type": [ - "boolean", - "null" - ] - }, - "structuredContent": true, - "toolUseId": { - "type": "string" - } - }, - "deprecated": true, - "required": [ - "toolUseId", - "content" - ] - }, - "ToolUseContent": { - "description": "Tool call request from assistant (SEP-1577).", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": "string" - }, - "input": { - "type": "object", - "additionalProperties": true - }, - "name": { - "type": "string" - } - }, - "deprecated": true, - "required": [ - "id", - "name", - "input" - ] - }, - "ToolsCapability": { - "type": "object", - "properties": { - "listChanged": { - "type": [ - "boolean", - "null" - ] - } - } - }, - "UntitledItems": { - "description": "Items for untitled multi-select options", - "type": "object", - "properties": { - "enum": { - "type": "array", - "items": { - "type": "string" - } - }, - "type": { - "$ref": "#/definitions/StringTypeConst" - } - }, - "required": [ - "type", - "enum" - ] - }, - "UntitledMultiSelectEnumSchema": { - "description": "Multi-select untitled options", - "type": "object", - "properties": { - "default": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "items": { - "$ref": "#/definitions/UntitledItems" - }, - "maxItems": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "minItems": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "type": { - "$ref": "#/definitions/ArrayTypeConst" - } - }, - "required": [ - "type", - "items" - ] - }, - "UntitledSingleSelectEnumSchema": { - "description": "Untitled single-select", - "type": "object", - "properties": { - "default": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "enum": { - "type": "array", - "items": { - "type": "string" - } - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "type": { - "$ref": "#/definitions/StringTypeConst" - } - }, - "additionalProperties": false, - "required": [ - "type", - "enum" - ] - }, - "UrlElicitationCapability": { - "description": "Capability for URL mode elicitation.", - "type": "object" - } - } -} \ No newline at end of file diff --git a/vendor/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/vendor/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json deleted file mode 100644 index ca0ef5611..000000000 --- a/vendor/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ /dev/null @@ -1,4113 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "JsonRpcMessage", - "description": "Represents any JSON-RPC message that can be sent or received.\n\nThis enum covers all possible message types in the JSON-RPC protocol:\nindividual requests/responses, notifications, and errors.\nIt serves as the top-level message container for MCP communication.", - "anyOf": [ - { - "description": "A single request expecting a response", - "allOf": [ - { - "$ref": "#/definitions/JsonRpcRequest" - } - ] - }, - { - "description": "A response to a previous request", - "allOf": [ - { - "$ref": "#/definitions/JsonRpcResponse" - } - ] - }, - { - "description": "A one-way notification (no response expected)", - "allOf": [ - { - "$ref": "#/definitions/JsonRpcNotification" - } - ] - }, - { - "description": "An error response", - "allOf": [ - { - "$ref": "#/definitions/JsonRpcError" - } - ] - } - ], - "definitions": { - "Annotations": { - "description": "Optional annotations for the client. The client can use annotations to inform how objects are\nused or displayed.", - "type": "object", - "properties": { - "audience": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Role" - } - }, - "lastModified": { - "type": [ - "string", - "null" - ] - }, - "priority": { - "type": [ - "number", - "null" - ], - "format": "float" - } - } - }, - "ArrayTypeConst": { - "type": "string", - "format": "const", - "const": "array" - }, - "AudioContent": { - "description": "Audio content with base64-encoded data (spec `AudioContent`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this content.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "data": { - "description": "The base64-encoded audio data.", - "type": "string" - }, - "mimeType": { - "description": "The MIME type of the audio (e.g. `audio/wav`).", - "type": "string" - } - }, - "required": [ - "data", - "mimeType" - ] - }, - "BooleanSchema": { - "description": "Schema definition for boolean properties.", - "type": "object", - "properties": { - "default": { - "description": "Default value", - "type": [ - "boolean", - "null" - ] - }, - "description": { - "description": "Human-readable description", - "type": [ - "string", - "null" - ] - }, - "title": { - "description": "Optional title for the schema", - "type": [ - "string", - "null" - ] - }, - "type": { - "description": "Type discriminator", - "allOf": [ - { - "$ref": "#/definitions/BooleanTypeConst" - } - ] - } - }, - "required": [ - "type" - ] - }, - "BooleanTypeConst": { - "type": "string", - "format": "const", - "const": "boolean" - }, - "CacheScope": { - "description": "Scope describing who may cache cacheable list/read results (SEP-2549).\n\nDefaults to [`CacheScope::Public`] when absent from the wire.", - "oneOf": [ - { - "description": "Any client or intermediary may cache and serve the response to any user.", - "type": "string", - "const": "public" - }, - { - "description": "Only the requesting user's client may cache the response.", - "type": "string", - "const": "private" - } - ] - }, - "CallToolResult": { - "description": "The result of a tool call operation.\n\nContains the content returned by the tool execution and an optional\nflag indicating whether the operation resulted in an error.", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this result", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "content": { - "description": "The content returned by the tool (text, images, etc.)", - "type": "array", - "default": [], - "items": { - "$ref": "#/definitions/ContentBlock" - } - }, - "isError": { - "description": "Whether this result represents an error condition", - "type": [ - "boolean", - "null" - ] - }, - "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", - "anyOf": [ - { - "$ref": "#/definitions/ResultType" - }, - { - "type": "null" - } - ] - }, - "structuredContent": { - "description": "An optional JSON object that represents the structured result of the tool call" - } - } - }, - "CancelledNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/cancelled" - }, - "CancelledNotificationParam": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "reason": { - "type": [ - "string", - "null" - ] - }, - "requestId": { - "anyOf": [ - { - "$ref": "#/definitions/NumberOrString" - }, - { - "type": "null" - } - ] - } - } - }, - "ClientCapabilities": { - "title": "Builder", - "description": "```rust\n# use rmcp::model::ClientCapabilities;\nlet cap = ClientCapabilities::builder()\n .enable_experimental()\n .build();\n```", - "type": "object", - "properties": { - "elicitation": { - "description": "Capability to handle elicitation requests from servers for interactive user input", - "anyOf": [ - { - "$ref": "#/definitions/ElicitationCapability" - }, - { - "type": "null" - } - ] - }, - "experimental": { - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": "object", - "additionalProperties": true - } - }, - "extensions": { - "description": "Optional MCP extensions that the client supports (SEP-1724).\nKeys are extension identifiers (e.g., `\"io.modelcontextprotocol/ui\"`),\nvalues are per-extension settings objects. An empty object indicates\nsupport with no settings.", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": "object", - "additionalProperties": true - } - }, - "roots": { - "description": "Capability for filesystem roots (deprecated by SEP-2577).", - "anyOf": [ - { - "$ref": "#/definitions/RootsCapabilities" - }, - { - "type": "null" - } - ] - }, - "sampling": { - "description": "Capability for LLM sampling requests (SEP-1577, deprecated by SEP-2577).", - "anyOf": [ - { - "$ref": "#/definitions/SamplingCapability" - }, - { - "type": "null" - } - ] - } - } - }, - "CompleteResult": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "completion": { - "$ref": "#/definitions/CompletionInfo" - }, - "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", - "anyOf": [ - { - "$ref": "#/definitions/ResultType" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "completion" - ] - }, - "CompletionInfo": { - "type": "object", - "properties": { - "hasMore": { - "type": [ - "boolean", - "null" - ] - }, - "total": { - "type": [ - "integer", - "null" - ], - "format": "uint32", - "minimum": 0 - }, - "values": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "values" - ] - }, - "ConstTitle": { - "description": "Schema definition for enum properties.\n\nRepresent single entry for titled item", - "type": "object", - "properties": { - "const": { - "type": "string" - }, - "title": { - "type": "string" - } - }, - "required": [ - "const", - "title" - ] - }, - "ContentBlock": { - "description": "Unified content block union (spec `ContentBlock`).\n\n`text | image | audio | resource_link | resource`", - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - } - }, - "allOf": [ - { - "$ref": "#/definitions/TextContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "image" - } - }, - "allOf": [ - { - "$ref": "#/definitions/ImageContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "audio" - } - }, - "allOf": [ - { - "$ref": "#/definitions/AudioContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "resource" - } - }, - "allOf": [ - { - "$ref": "#/definitions/EmbeddedResource" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "resource_link" - } - }, - "allOf": [ - { - "$ref": "#/definitions/Resource" - } - ], - "required": [ - "type" - ] - } - ] - }, - "ContextInclusion": { - "description": "Specifies how much context should be included in sampling requests.\n\nThis allows clients to control what additional context information\nshould be provided to the LLM when processing sampling requests.", - "oneOf": [ - { - "description": "Include context from all connected MCP servers", - "type": "string", - "const": "allServers" - }, - { - "description": "Include no additional context", - "type": "string", - "const": "none" - }, - { - "description": "Include context only from the requesting server", - "type": "string", - "const": "thisServer" - } - ] - }, - "CreateMessageRequestMethod": { - "type": "string", - "format": "const", - "const": "sampling/createMessage" - }, - "CreateMessageRequestParams": { - "description": "Parameters for creating a message through LLM sampling.\n\nThis structure contains all the necessary information for a client to\ngenerate an LLM response, including conversation history, model preferences,\nand generation parameters.\n\nThis implements `TaskAugmentedRequestParamsMeta` as sampling requests can be\nlong-running and may benefit from task-based execution.", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "includeContext": { - "description": "How much context to include from MCP servers", - "anyOf": [ - { - "$ref": "#/definitions/ContextInclusion" - }, - { - "type": "null" - } - ] - }, - "maxTokens": { - "description": "Maximum number of tokens to generate", - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "messages": { - "description": "The conversation history and current messages", - "type": "array", - "items": { - "$ref": "#/definitions/SamplingMessage" - } - }, - "metadata": { - "description": "Additional metadata for the request" - }, - "modelPreferences": { - "description": "Preferences for model selection and behavior", - "anyOf": [ - { - "$ref": "#/definitions/ModelPreferences" - }, - { - "type": "null" - } - ] - }, - "stopSequences": { - "description": "Sequences that should stop generation", - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "systemPrompt": { - "description": "System prompt to guide the model's behavior", - "type": [ - "string", - "null" - ] - }, - "temperature": { - "description": "Temperature for controlling randomness (0.0 to 1.0)", - "type": [ - "number", - "null" - ], - "format": "float" - }, - "toolChoice": { - "description": "Tool selection behavior (SEP-1577)", - "anyOf": [ - { - "$ref": "#/definitions/ToolChoice" - }, - { - "type": "null" - } - ] - }, - "tools": { - "description": "Tools available for the model to call (SEP-1577)", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Tool" - } - } - }, - "deprecated": true, - "required": [ - "messages", - "maxTokens" - ] - }, - "CreateTaskResult": { - "description": "Result returned in lieu of a standard result to indicate the request will\nbe processed asynchronously (spec `CreateTaskResult`, `resultType: \"task\"`).\n\nThe embedded task is the seed state for the task; the client uses\n`task.task_id` for all subsequent `tasks/get`, `tasks/update`, and\n`tasks/cancel` calls.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "createdAt": { - "description": "ISO 8601 timestamp when the task was created.", - "type": "string" - }, - "lastUpdatedAt": { - "description": "ISO 8601 timestamp when the task was last updated.", - "type": "string" - }, - "pollIntervalMs": { - "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "resultType": { - "description": "Always `\"task\"`.", - "allOf": [ - { - "$ref": "#/definitions/ResultType" - } - ] - }, - "status": { - "description": "Current task status.", - "allOf": [ - { - "$ref": "#/definitions/TaskStatus" - } - ] - }, - "statusMessage": { - "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", - "type": [ - "string", - "null" - ] - }, - "taskId": { - "description": "Stable identifier for this task, generated by the server.", - "type": "string" - }, - "ttlMs": { - "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "resultType", - "taskId", - "status", - "createdAt", - "lastUpdatedAt" - ] - }, - "CustomNotification": { - "description": "A catch-all notification either side can use to send custom messages to its peer.\n\nThis preserves the raw `method` name and `params` payload so handlers can\ndeserialize them into domain-specific types.", - "type": "object", - "properties": { - "method": { - "type": "string" - }, - "params": true - }, - "required": [ - "method" - ] - }, - "CustomRequest": { - "description": "A catch-all request either side can use to send custom messages to its peer.\n\nThis preserves the raw `method` name and `params` payload so handlers can\ndeserialize them into domain-specific types.", - "type": "object", - "properties": { - "method": { - "type": "string" - }, - "params": true - }, - "required": [ - "method" - ] - }, - "CustomResult": { - "description": "A catch-all response either side can use for custom requests." - }, - "DiscoverResult": { - "description": "The server's response to a [`DiscoverRequest`].", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level response metadata.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "cacheScope": { - "description": "Whether the cached result may be shared across authorization contexts.", - "allOf": [ - { - "$ref": "#/definitions/CacheScope" - } - ] - }, - "capabilities": { - "description": "Capabilities provided by this server.", - "allOf": [ - { - "$ref": "#/definitions/ServerCapabilities" - } - ] - }, - "instructions": { - "description": "Optional guidance for using the server.", - "type": [ - "string", - "null" - ] - }, - "resultType": { - "description": "Identifies how the result should be parsed.", - "allOf": [ - { - "$ref": "#/definitions/ResultType" - } - ] - }, - "supportedVersions": { - "description": "Protocol versions implemented by this server.", - "type": "array", - "items": { - "$ref": "#/definitions/ProtocolVersion" - } - }, - "ttlMs": { - "description": "How long clients may consider this response fresh, in milliseconds.", - "type": "integer", - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "resultType", - "supportedVersions", - "capabilities", - "ttlMs", - "cacheScope" - ] - }, - "ElicitRequestParams": { - "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n1. Form-based elicitation request\n```rust\nuse rmcp::model::*;\n\nlet params = ElicitRequestParams::FormElicitationParams {\n meta: None,\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```\n2. URL-based elicitation request\n```rust\nuse rmcp::model::*;\nlet params = ElicitRequestParams::UrlElicitationParams {\n meta: None,\n message: \"Please provide your feedback at the following URL\".to_string(),\n url: \"https://example.com/feedback\".to_string(),\n elicitation_id: \"unique-id-123\".to_string(),\n};\n```", - "anyOf": [ - { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "message": { - "type": "string" - }, - "mode": { - "type": "string", - "const": "form" - }, - "requestedSchema": { - "$ref": "#/definitions/ElicitationSchema" - } - }, - "required": [ - "mode", - "message", - "requestedSchema" - ] - }, - { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "elicitationId": { - "type": "string" - }, - "message": { - "type": "string" - }, - "mode": { - "type": "string", - "const": "url" - }, - "url": { - "type": "string" - } - }, - "required": [ - "mode", - "message", - "url", - "elicitationId" - ] - }, - { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "message": { - "type": "string" - }, - "requestedSchema": { - "$ref": "#/definitions/ElicitationSchema" - } - }, - "required": [ - "message", - "requestedSchema" - ] - } - ] - }, - "ElicitResult": { - "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this result.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "action": { - "description": "The user's decision on how to handle the elicitation request", - "allOf": [ - { - "$ref": "#/definitions/ElicitationAction" - } - ] - }, - "content": { - "description": "The actual data provided by the user, if they accepted the request.\nMust conform to the JSON schema specified in the original request.\nOnly present when action is Accept." - } - }, - "required": [ - "action" - ] - }, - "ElicitationAction": { - "description": "Represents the possible actions a user can take in response to an elicitation request.\n\nWhen a server requests user input through elicitation, the user can:\n- Accept: Provide the requested information and continue\n- Decline: Refuse to provide the information but continue the operation\n- Cancel: Stop the entire operation", - "oneOf": [ - { - "description": "User accepts the request and provides the requested information", - "type": "string", - "const": "accept" - }, - { - "description": "User declines to provide the information but allows the operation to continue", - "type": "string", - "const": "decline" - }, - { - "description": "User cancels the entire operation", - "type": "string", - "const": "cancel" - } - ] - }, - "ElicitationCapability": { - "description": "Elicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.", - "type": "object", - "properties": { - "form": { - "description": "Whether client supports form-based elicitation.", - "anyOf": [ - { - "$ref": "#/definitions/FormElicitationCapability" - }, - { - "type": "null" - } - ] - }, - "url": { - "description": "Whether client supports URL-based elicitation.", - "anyOf": [ - { - "$ref": "#/definitions/UrlElicitationCapability" - }, - { - "type": "null" - } - ] - } - } - }, - "ElicitationCreateRequestMethod": { - "type": "string", - "format": "const", - "const": "elicitation/create" - }, - "ElicitationSchema": { - "description": "Type-safe elicitation schema for requesting structured user input.\n\nThis enforces the MCP 2025-06-18 specification that elicitation schemas\nmust be objects with primitive-typed properties.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet schema = ElicitationSchema::builder()\n .required_email(\"email\")\n .required_integer(\"age\", 0, 150)\n .optional_bool(\"newsletter\", false)\n .build();\n```", - "type": "object", - "properties": { - "description": { - "description": "Optional description of what this schema represents", - "type": [ - "string", - "null" - ] - }, - "properties": { - "description": "Property definitions (must be primitive types)", - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/PrimitiveSchemaDefinition" - } - }, - "required": { - "description": "List of required property names", - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "title": { - "description": "Optional title for the schema", - "type": [ - "string", - "null" - ] - }, - "type": { - "description": "Always \"object\" for elicitation schemas", - "allOf": [ - { - "$ref": "#/definitions/ObjectTypeConst" - } - ] - } - }, - "required": [ - "type", - "properties" - ] - }, - "EmbeddedResource": { - "description": "Embedded resource content (spec `EmbeddedResource`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this content.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "resource": { - "description": "The embedded resource contents (text or blob).", - "allOf": [ - { - "$ref": "#/definitions/ResourceContents" - } - ] - } - }, - "required": [ - "resource" - ] - }, - "EmptyObject": { - "description": "This is commonly used for representing empty objects in MCP messages.\n\nwithout returning any specific data.", - "type": "object", - "additionalProperties": false - }, - "EnumSchema": { - "description": "Compliant with MCP 2025-06-18 specification for elicitation schemas.\nEnums must have string type for values and can optionally include human-readable names.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet enum_schema = EnumSchema::builder(vec![\"US\".to_string(), \"UK\".to_string()])\n .multiselect()\n .min_items(1u64).expect(\"Min items should be correct value\")\n .max_items(4u64).expect(\"Max items should be correct value\")\n .description(\"Country code\")\n .build();\n```", - "anyOf": [ - { - "$ref": "#/definitions/SingleSelectEnumSchema" - }, - { - "$ref": "#/definitions/MultiSelectEnumSchema" - }, - { - "$ref": "#/definitions/LegacyEnumSchema" - } - ] - }, - "ErrorCode": { - "description": "Standard JSON-RPC error codes used throughout the MCP protocol.\n\nThese codes follow the JSON-RPC 2.0 specification and provide\nstandardized error reporting across all MCP implementations.", - "type": "integer", - "format": "int32" - }, - "ErrorData": { - "description": "Error information for JSON-RPC error responses.\n\nThis structure follows the JSON-RPC 2.0 specification for error reporting,\nproviding a standardized way to communicate errors between clients and servers.", - "type": "object", - "properties": { - "code": { - "description": "The error type that occurred (using standard JSON-RPC error codes)", - "allOf": [ - { - "$ref": "#/definitions/ErrorCode" - } - ] - }, - "data": { - "description": "Additional information about the error. The value of this member is defined by the\nsender (e.g. detailed error information, nested errors etc.)." - }, - "message": { - "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", - "type": "string" - } - }, - "required": [ - "code", - "message" - ] - }, - "FormElicitationCapability": { - "description": "Capability for handling elicitation requests from servers.\nElicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.\n\nCapability for form mode elicitation.", - "type": "object", - "properties": { - "schemaValidation": { - "description": "Whether the client supports JSON Schema validation for elicitation responses.\nWhen true, the client will validate user input against the requested_schema\nbefore sending the response back to the server.", - "type": [ - "boolean", - "null" - ] - } - } - }, - "GetPromptResult": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "messages": { - "type": "array", - "items": { - "$ref": "#/definitions/PromptMessage" - } - }, - "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", - "anyOf": [ - { - "$ref": "#/definitions/ResultType" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "messages" - ] - }, - "GetTaskResult": { - "description": "Response to a `tasks/get` request (spec `GetTaskResult = Result & DetailedTask`).\n\n`resultType` is `\"complete\"` — this is the standard result shape for\n`tasks/get`, not a task handle.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "createdAt": { - "description": "ISO 8601 timestamp when the task was created.", - "type": "string" - }, - "error": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "inputRequests": { - "type": [ - "object", - "null" - ], - "additionalProperties": { - "$ref": "#/definitions/InputRequest" - } - }, - "lastUpdatedAt": { - "description": "ISO 8601 timestamp when the task was last updated.", - "type": "string" - }, - "pollIntervalMs": { - "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "result": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "resultType": { - "description": "Result type discriminator. `tasks/get` responses are standard results:\n`\"complete\"` (SEP-2322). Absent values deserialize as `\"complete\"`.", - "allOf": [ - { - "$ref": "#/definitions/ResultType" - } - ], - "default": "complete" - }, - "status": { - "description": "Current task status.", - "allOf": [ - { - "$ref": "#/definitions/TaskStatus" - } - ] - }, - "statusMessage": { - "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", - "type": [ - "string", - "null" - ] - }, - "taskId": { - "description": "Stable identifier for this task, generated by the server.", - "type": "string" - }, - "ttlMs": { - "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt" - ] - }, - "Icon": { - "description": "A URL pointing to an icon resource or a base64-encoded data URI.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- image/png - PNG images (safe, universal compatibility)\n- image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- image/svg+xml - SVG images (scalable but requires security precautions)\n- image/webp - WebP images (modern, efficient format)", - "type": "object", - "properties": { - "mimeType": { - "description": "Optional override if the server's MIME type is missing or generic", - "type": [ - "string", - "null" - ] - }, - "sizes": { - "description": "Size specification, each string should be in WxH format (e.g., `\\\"48x48\\\"`, `\\\"96x96\\\"`) or `\\\"any\\\"` for scalable formats like SVG", - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "src": { - "description": "A standard URI pointing to an icon resource", - "type": "string" - }, - "theme": { - "description": "Optional specifier for the theme this icon is designed for\nIf not provided, the client should assume the icon can be used with any theme.", - "anyOf": [ - { - "$ref": "#/definitions/IconTheme" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "src" - ] - }, - "IconTheme": { - "description": "Icon themes supported by the MCP specification", - "oneOf": [ - { - "description": "Indicates the icon is designed to be used with a light background", - "type": "string", - "const": "light" - }, - { - "description": "Indicates the icon is designed to be used with a dark background", - "type": "string", - "const": "dark" - } - ] - }, - "ImageContent": { - "description": "Image content with base64-encoded data (spec `ImageContent`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this content.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "data": { - "description": "The base64-encoded image data.", - "type": "string" - }, - "mimeType": { - "description": "The MIME type of the image (e.g. `image/png`).", - "type": "string" - } - }, - "required": [ - "data", - "mimeType" - ] - }, - "Implementation": { - "type": "object", - "properties": { - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "name": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "version": { - "type": "string" - }, - "websiteUrl": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "name", - "version" - ] - }, - "InitializeResult": { - "description": "The server's response to an initialization request.\n\nContains the server's protocol version, capabilities, and implementation\ninformation, along with optional instructions for the client.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "capabilities": { - "description": "The capabilities this server provides (tools, resources, prompts, etc.)", - "allOf": [ - { - "$ref": "#/definitions/ServerCapabilities" - } - ] - }, - "instructions": { - "description": "Optional human-readable instructions about using this server", - "type": [ - "string", - "null" - ] - }, - "protocolVersion": { - "description": "The MCP protocol version this server supports", - "allOf": [ - { - "$ref": "#/definitions/ProtocolVersion" - } - ] - }, - "serverInfo": { - "description": "Information about the server implementation", - "allOf": [ - { - "$ref": "#/definitions/Implementation" - } - ] - } - }, - "required": [ - "protocolVersion", - "capabilities", - "serverInfo" - ] - }, - "InputRequest": { - "description": "A server-initiated request that can appear inside [`InputRequests`].\n\nPer the MCP spec, only `CreateMessageRequest` (sampling),\n`ElicitRequest` (elicitation), and `ListRootsRequest` (roots)\nare allowed. This is modeled as an untagged enum rather than a\n`ServerRequest` alias to prevent `PingRequest` or `CustomRequest` from\nbeing included.", - "anyOf": [ - { - "description": "A `sampling/createMessage` request.", - "allOf": [ - { - "$ref": "#/definitions/Request" - } - ] - }, - { - "description": "An `elicitation/create` request.", - "allOf": [ - { - "$ref": "#/definitions/Request2" - } - ] - }, - { - "description": "A `roots/list` request.", - "allOf": [ - { - "$ref": "#/definitions/RequestNoParam2" - } - ] - } - ] - }, - "InputRequiredResult": { - "description": "A result indicating that additional input is needed before the request\ncan be completed.\n\nAt least one of [`input_requests`](Self::input_requests) or\n[`request_state`](Self::request_state) MUST be present.\n\nServers MAY send this in response to `tools/call`, `prompts/get`, or\n`resources/read`. Servers MUST NOT send this for any other request.\n\n# Examples\n\n```\nuse rmcp::model::InputRequiredResult;\n\nlet result = InputRequiredResult::from_request_state(\"opaque-server-state\");\nassert!(result.input_requests.is_none());\nassert_eq!(result.request_state.as_deref(), Some(\"opaque-server-state\"));\n```", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "inputRequests": { - "description": "Server-initiated requests that the client must fulfill before retrying.", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "$ref": "#/definitions/InputRequest" - } - }, - "requestState": { - "description": "Opaque request state to be echoed back by the client on retry.\nClients MUST NOT inspect, parse, modify, or make any assumptions\nabout the contents.", - "type": [ - "string", - "null" - ] - }, - "resultType": { - "description": "Always `\"input_required\"` for this result type.", - "allOf": [ - { - "$ref": "#/definitions/ResultType" - } - ] - } - }, - "required": [ - "resultType" - ] - }, - "IntegerSchema": { - "description": "Schema definition for integer properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", - "type": "object", - "properties": { - "default": { - "description": "Default value", - "type": [ - "integer", - "null" - ], - "format": "int64" - }, - "description": { - "description": "Human-readable description", - "type": [ - "string", - "null" - ] - }, - "maximum": { - "description": "Maximum value (inclusive)", - "type": [ - "integer", - "null" - ], - "format": "int64" - }, - "minimum": { - "description": "Minimum value (inclusive)", - "type": [ - "integer", - "null" - ], - "format": "int64" - }, - "title": { - "description": "Optional title for the schema", - "type": [ - "string", - "null" - ] - }, - "type": { - "description": "Type discriminator", - "allOf": [ - { - "$ref": "#/definitions/IntegerTypeConst" - } - ] - } - }, - "required": [ - "type" - ] - }, - "IntegerTypeConst": { - "type": "string", - "format": "const", - "const": "integer" - }, - "JsonRpcError": { - "type": "object", - "properties": { - "error": { - "$ref": "#/definitions/ErrorData" - }, - "id": { - "anyOf": [ - { - "$ref": "#/definitions/NumberOrString" - }, - { - "type": "null" - } - ] - }, - "jsonrpc": { - "$ref": "#/definitions/JsonRpcVersion2_0" - } - }, - "required": [ - "jsonrpc", - "error" - ] - }, - "JsonRpcNotification": { - "type": "object", - "properties": { - "jsonrpc": { - "$ref": "#/definitions/JsonRpcVersion2_0" - } - }, - "anyOf": [ - { - "$ref": "#/definitions/Notification" - }, - { - "$ref": "#/definitions/Notification2" - }, - { - "$ref": "#/definitions/Notification3" - }, - { - "$ref": "#/definitions/Notification4" - }, - { - "$ref": "#/definitions/NotificationNoParam" - }, - { - "$ref": "#/definitions/NotificationNoParam2" - }, - { - "$ref": "#/definitions/NotificationNoParam3" - }, - { - "$ref": "#/definitions/Notification5" - }, - { - "$ref": "#/definitions/Notification6" - }, - { - "$ref": "#/definitions/CustomNotification" - } - ], - "required": [ - "jsonrpc" - ] - }, - "JsonRpcRequest": { - "type": "object", - "properties": { - "id": { - "$ref": "#/definitions/NumberOrString" - }, - "jsonrpc": { - "$ref": "#/definitions/JsonRpcVersion2_0" - } - }, - "anyOf": [ - { - "$ref": "#/definitions/RequestNoParam" - }, - { - "$ref": "#/definitions/Request" - }, - { - "$ref": "#/definitions/RequestNoParam2" - }, - { - "$ref": "#/definitions/Request2" - }, - { - "$ref": "#/definitions/CustomRequest" - } - ], - "required": [ - "jsonrpc", - "id" - ] - }, - "JsonRpcResponse": { - "type": "object", - "properties": { - "id": { - "$ref": "#/definitions/NumberOrString" - }, - "jsonrpc": { - "$ref": "#/definitions/JsonRpcVersion2_0" - }, - "result": { - "$ref": "#/definitions/ServerResult" - } - }, - "required": [ - "jsonrpc", - "id", - "result" - ] - }, - "JsonRpcVersion2_0": { - "type": "string", - "format": "const", - "const": "2.0" - }, - "LegacyEnumSchema": { - "description": "Legacy enum schema, keep for backward compatibility", - "type": "object", - "properties": { - "default": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "enum": { - "type": "array", - "items": { - "type": "string" - } - }, - "enumNames": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "type": { - "$ref": "#/definitions/StringTypeConst" - } - }, - "required": [ - "type", - "enum" - ] - }, - "ListPromptsResult": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "cacheScope": { - "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", - "anyOf": [ - { - "$ref": "#/definitions/CacheScope" - }, - { - "type": "null" - } - ] - }, - "nextCursor": { - "type": [ - "string", - "null" - ] - }, - "prompts": { - "type": "array", - "items": { - "$ref": "#/definitions/Prompt" - } - }, - "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", - "anyOf": [ - { - "$ref": "#/definitions/ResultType" - }, - { - "type": "null" - } - ] - }, - "ttlMs": { - "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "prompts" - ] - }, - "ListResourceTemplatesResult": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "cacheScope": { - "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", - "anyOf": [ - { - "$ref": "#/definitions/CacheScope" - }, - { - "type": "null" - } - ] - }, - "nextCursor": { - "type": [ - "string", - "null" - ] - }, - "resourceTemplates": { - "type": "array", - "items": { - "$ref": "#/definitions/ResourceTemplate" - } - }, - "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", - "anyOf": [ - { - "$ref": "#/definitions/ResultType" - }, - { - "type": "null" - } - ] - }, - "ttlMs": { - "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "resourceTemplates" - ] - }, - "ListResourcesResult": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "cacheScope": { - "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", - "anyOf": [ - { - "$ref": "#/definitions/CacheScope" - }, - { - "type": "null" - } - ] - }, - "nextCursor": { - "type": [ - "string", - "null" - ] - }, - "resources": { - "type": "array", - "items": { - "$ref": "#/definitions/Resource" - } - }, - "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", - "anyOf": [ - { - "$ref": "#/definitions/ResultType" - }, - { - "type": "null" - } - ] - }, - "ttlMs": { - "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "resources" - ] - }, - "ListRootsRequestMethod": { - "type": "string", - "format": "const", - "const": "roots/list" - }, - "ListToolsResult": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "cacheScope": { - "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", - "anyOf": [ - { - "$ref": "#/definitions/CacheScope" - }, - { - "type": "null" - } - ] - }, - "nextCursor": { - "type": [ - "string", - "null" - ] - }, - "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", - "anyOf": [ - { - "$ref": "#/definitions/ResultType" - }, - { - "type": "null" - } - ] - }, - "tools": { - "type": "array", - "items": { - "$ref": "#/definitions/Tool" - } - }, - "ttlMs": { - "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "tools" - ] - }, - "LoggingLevel": { - "description": "Logging levels supported by the MCP protocol", - "type": "string", - "deprecated": true, - "enum": [ - "debug", - "info", - "notice", - "warning", - "error", - "critical", - "alert", - "emergency" - ] - }, - "LoggingMessageNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/message" - }, - "LoggingMessageNotificationParam": { - "description": "Parameters for a logging message notification", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "data": { - "description": "The actual log data" - }, - "level": { - "description": "The severity level of this log message", - "allOf": [ - { - "$ref": "#/definitions/LoggingLevel" - } - ] - }, - "logger": { - "description": "Optional logger name that generated this message", - "type": [ - "string", - "null" - ] - } - }, - "deprecated": true, - "required": [ - "level", - "data" - ] - }, - "MetaObject": { - "description": "See [MCP general fields](https://modelcontextprotocol.io/specification/2026-07-28/basic#general-fields) for notes on _meta usage.", - "type": "object", - "additionalProperties": true - }, - "ModelHint": { - "description": "A hint suggesting a preferred model name or family.\n\nModel hints are advisory suggestions that help clients choose appropriate\nmodels. They can be specific model names or general families like \"claude\" or \"gpt\".", - "type": "object", - "properties": { - "name": { - "description": "The suggested model name or family identifier", - "type": [ - "string", - "null" - ] - } - }, - "deprecated": true - }, - "ModelPreferences": { - "description": "Preferences for model selection and behavior in sampling requests.\n\nThis allows servers to express their preferences for which model to use\nand how to balance different priorities when the client has multiple\nmodel options available.", - "type": "object", - "properties": { - "costPriority": { - "description": "Priority for cost optimization (0.0 to 1.0, higher = prefer cheaper models)", - "type": [ - "number", - "null" - ], - "format": "float" - }, - "hints": { - "description": "Specific model names or families to prefer (e.g., \"claude\", \"gpt\")", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/ModelHint" - } - }, - "intelligencePriority": { - "description": "Priority for intelligence/capability (0.0 to 1.0, higher = prefer more capable models)", - "type": [ - "number", - "null" - ], - "format": "float" - }, - "speedPriority": { - "description": "Priority for speed/latency (0.0 to 1.0, higher = prefer faster models)", - "type": [ - "number", - "null" - ], - "format": "float" - } - }, - "deprecated": true - }, - "MultiSelectEnumSchema": { - "description": "Multi-select enum options", - "anyOf": [ - { - "$ref": "#/definitions/UntitledMultiSelectEnumSchema" - }, - { - "$ref": "#/definitions/TitledMultiSelectEnumSchema" - } - ] - }, - "Notification": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/CancelledNotificationMethod" - }, - "params": { - "$ref": "#/definitions/CancelledNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, - "Notification2": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ProgressNotificationMethod" - }, - "params": { - "$ref": "#/definitions/ProgressNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, - "Notification3": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/LoggingMessageNotificationMethod" - }, - "params": { - "$ref": "#/definitions/LoggingMessageNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, - "Notification4": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ResourceUpdatedNotificationMethod" - }, - "params": { - "$ref": "#/definitions/ResourceUpdatedNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, - "Notification5": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/SubscriptionsAcknowledgedNotificationMethod" - }, - "params": { - "$ref": "#/definitions/SubscriptionsAcknowledgedNotificationParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Notification6": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/TaskStatusNotificationMethod" - }, - "params": { - "$ref": "#/definitions/TaskStatusNotificationParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "NotificationMetaObject": { - "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", - "type": "object", - "properties": { - "io.modelcontextprotocol/subscriptionId": { - "$ref": "#/definitions/NumberOrString" - } - }, - "additionalProperties": true - }, - "NotificationNoParam": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ResourceListChangedNotificationMethod" - } - }, - "required": [ - "method" - ] - }, - "NotificationNoParam2": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ToolListChangedNotificationMethod" - } - }, - "required": [ - "method" - ] - }, - "NotificationNoParam3": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/PromptListChangedNotificationMethod" - } - }, - "required": [ - "method" - ] - }, - "NumberOrString": { - "oneOf": [ - { - "type": "number" - }, - { - "type": "string" - } - ] - }, - "NumberSchema": { - "description": "Schema definition for number properties (floating-point).\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", - "type": "object", - "properties": { - "default": { - "description": "Default value", - "type": [ - "number", - "null" - ], - "format": "double" - }, - "description": { - "description": "Human-readable description", - "type": [ - "string", - "null" - ] - }, - "maximum": { - "description": "Maximum value (inclusive)", - "type": [ - "number", - "null" - ], - "format": "double" - }, - "minimum": { - "description": "Minimum value (inclusive)", - "type": [ - "number", - "null" - ], - "format": "double" - }, - "title": { - "description": "Optional title for the schema", - "type": [ - "string", - "null" - ] - }, - "type": { - "description": "Type discriminator", - "allOf": [ - { - "$ref": "#/definitions/NumberTypeConst" - } - ] - } - }, - "required": [ - "type" - ] - }, - "NumberTypeConst": { - "type": "string", - "format": "const", - "const": "number" - }, - "ObjectTypeConst": { - "type": "string", - "format": "const", - "const": "object" - }, - "PingRequestMethod": { - "type": "string", - "format": "const", - "const": "ping" - }, - "PrimitiveSchemaDefinition": { - "description": "Primitive schema definition for elicitation properties.\n\nAccording to MCP 2025-06-18 specification, elicitation schemas must have\nproperties of primitive types only (string, number, integer, boolean, enum).\n\nNote: Put Enum as the first variant to avoid ambiguity during deserialization.\nThis is due to the fact that EnumSchema can contain StringSchema internally and serde\nuses first match wins strategy when deserializing untagged enums.", - "anyOf": [ - { - "description": "Enum property (explicit enum schema)", - "allOf": [ - { - "$ref": "#/definitions/EnumSchema" - } - ] - }, - { - "description": "String property (with optional enum constraint)", - "allOf": [ - { - "$ref": "#/definitions/StringSchema" - } - ] - }, - { - "description": "Number property (with optional enum constraint)", - "allOf": [ - { - "$ref": "#/definitions/NumberSchema" - } - ] - }, - { - "description": "Integer property (with optional enum constraint)", - "allOf": [ - { - "$ref": "#/definitions/IntegerSchema" - } - ] - }, - { - "description": "Boolean property", - "allOf": [ - { - "$ref": "#/definitions/BooleanSchema" - } - ] - } - ] - }, - "ProgressNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/progress" - }, - "ProgressNotificationParam": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "message": { - "description": "An optional message describing the current progress.", - "type": [ - "string", - "null" - ] - }, - "progress": { - "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", - "type": "number", - "format": "double" - }, - "progressToken": { - "$ref": "#/definitions/ProgressToken" - }, - "total": { - "description": "Total number of items to process (or total progress required), if known", - "type": [ - "number", - "null" - ], - "format": "double" - } - }, - "required": [ - "progressToken", - "progress" - ] - }, - "ProgressToken": { - "description": "A token used to track the progress of long-running operations.\n\nProgress tokens allow clients and servers to associate progress notifications\nwith specific requests, enabling real-time updates on operation status.", - "allOf": [ - { - "$ref": "#/definitions/NumberOrString" - } - ] - }, - "Prompt": { - "description": "A prompt or prompt template that the server offers (spec `Prompt`).", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "arguments": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/PromptArgument" - } - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "name": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "name" - ] - }, - "PromptArgument": { - "description": "Describes an argument that a prompt can accept (spec `PromptArgument`).", - "type": "object", - "properties": { - "description": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "required": { - "type": [ - "boolean", - "null" - ] - }, - "title": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "name" - ] - }, - "PromptListChangedNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/prompts/list_changed" - }, - "PromptMessage": { - "description": "A message returned as part of a prompt (spec `PromptMessage`).\n\nUses the unified `ContentBlock` for its content (text | image | audio | resource_link | resource).", - "type": "object", - "properties": { - "content": { - "$ref": "#/definitions/ContentBlock" - }, - "role": { - "$ref": "#/definitions/Role" - } - }, - "required": [ - "role", - "content" - ] - }, - "PromptsCapability": { - "type": "object", - "properties": { - "listChanged": { - "type": [ - "boolean", - "null" - ] - } - } - }, - "ProtocolVersion": { - "description": "Represents the MCP protocol version used for communication.\n\nThis ensures compatibility between clients and servers by specifying\nwhich version of the Model Context Protocol is being used.", - "type": "string" - }, - "ReadResourceResult": { - "description": "Result containing the contents of a read resource", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "cacheScope": { - "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", - "anyOf": [ - { - "$ref": "#/definitions/CacheScope" - }, - { - "type": "null" - } - ] - }, - "contents": { - "description": "The actual content of the resource", - "type": "array", - "items": { - "$ref": "#/definitions/ResourceContents" - } - }, - "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", - "anyOf": [ - { - "$ref": "#/definitions/ResultType" - }, - { - "type": "null" - } - ] - }, - "ttlMs": { - "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "contents" - ] - }, - "Request": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/CreateMessageRequestMethod" - }, - "params": { - "$ref": "#/definitions/CreateMessageRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "Request2": { - "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ElicitationCreateRequestMethod" - }, - "params": { - "$ref": "#/definitions/ElicitRequestParams" - } - }, - "required": [ - "method", - "params" - ] - }, - "RequestMetaObject": { - "description": "Metadata reserved by MCP on requests. Extension keys are also allowed.", - "type": "object", - "properties": { - "io.modelcontextprotocol/clientCapabilities": { - "$ref": "#/definitions/ClientCapabilities" - }, - "io.modelcontextprotocol/clientInfo": { - "$ref": "#/definitions/Implementation" - }, - "io.modelcontextprotocol/logLevel": { - "$ref": "#/definitions/LoggingLevel" - }, - "io.modelcontextprotocol/protocolVersion": { - "type": "string" - }, - "progressToken": { - "$ref": "#/definitions/ProgressToken" - } - }, - "additionalProperties": true - }, - "RequestNoParam": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/PingRequestMethod" - } - }, - "required": [ - "method" - ] - }, - "RequestNoParam2": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ListRootsRequestMethod" - } - }, - "required": [ - "method" - ] - }, - "Resource": { - "description": "A known resource that the server is capable of reading (spec `Resource`).\n\nAlso used as the inner type of `ContentBlock::ResourceLink` (spec `ResourceLink extends Resource`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this resource.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this resource.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "description": { - "description": "Optional description of what this resource represents.", - "type": [ - "string", - "null" - ] - }, - "icons": { - "description": "Optional set of icons the client may display for this resource.", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "mimeType": { - "description": "The MIME type of this resource, if known.", - "type": [ - "string", - "null" - ] - }, - "name": { - "description": "The programmatic name of the resource.", - "type": "string" - }, - "size": { - "description": "The size of the raw resource content in bytes (before base64/tokenization), if known.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "title": { - "description": "Optional human-readable display title.", - "type": [ - "string", - "null" - ] - }, - "uri": { - "description": "The URI of this resource (e.g. `file:///path/to/file`).", - "type": "string" - } - }, - "required": [ - "uri", - "name" - ] - }, - "ResourceContents": { - "description": "The contents of a specific resource or sub-resource.", - "anyOf": [ - { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "text": { - "type": "string" - }, - "uri": { - "type": "string" - } - }, - "required": [ - "uri", - "text" - ] - }, - { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "blob": { - "type": "string" - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "uri": { - "type": "string" - } - }, - "required": [ - "uri", - "blob" - ] - } - ] - }, - "ResourceListChangedNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/resources/list_changed" - }, - "ResourceTemplate": { - "description": "A template description for resources available on the server (spec `ResourceTemplate`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this resource template.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this template.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "description": { - "description": "Optional description of what this template is for.", - "type": [ - "string", - "null" - ] - }, - "icons": { - "description": "Optional set of icons the client may display for this template.", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "mimeType": { - "description": "The MIME type for resources matching this template, if uniform.", - "type": [ - "string", - "null" - ] - }, - "name": { - "description": "The programmatic name of the resource template.", - "type": "string" - }, - "title": { - "description": "Optional human-readable display title.", - "type": [ - "string", - "null" - ] - }, - "uriTemplate": { - "description": "An RFC 6570 URI template for constructing resource URIs.", - "type": "string" - } - }, - "required": [ - "uriTemplate", - "name" - ] - }, - "ResourceUpdatedNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/resources/updated" - }, - "ResourceUpdatedNotificationParam": { - "description": "Parameters for a resource update notification", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "uri": { - "description": "The URI of the resource that was updated", - "type": "string" - } - }, - "required": [ - "uri" - ] - }, - "ResourcesCapability": { - "type": "object", - "properties": { - "listChanged": { - "type": [ - "boolean", - "null" - ] - }, - "subscribe": { - "type": [ - "boolean", - "null" - ] - } - } - }, - "ResultType": { - "description": "Indicates the type of a result object, allowing the client to\ndetermine how to parse the response.\n\nThe spec defines this as an open string (`\"complete\" | \"input_required\" | string`),\nso unknown values are preserved rather than rejected. Servers implementing this\nprotocol version MUST include `resultType` in every result. For backward\ncompatibility, clients MUST treat an absent field as `\"complete\"`.\n\nOrdinary results model the field as `Option`: `None` means the\nfield is absent on the wire. Constructors default to `Some(COMPLETE)`, and\nthe server handler strips the `\"complete\"` discriminator before responding\nto peers that negotiated a protocol version older than `2026-07-28`, so\nlegacy sessions keep their historical wire shape (see\n[`ServerResult::strip_result_type_for_legacy_peer`]).", - "type": "string" - }, - "Role": { - "description": "Represents the role of a participant in a conversation or message exchange.\n\nUsed in sampling and chat contexts to distinguish between different\ntypes of message senders in the conversation flow.", - "oneOf": [ - { - "description": "A human user or client making a request", - "type": "string", - "const": "user" - }, - { - "description": "An AI assistant or server providing a response", - "type": "string", - "const": "assistant" - } - ] - }, - "RootsCapabilities": { - "description": "Roots capability. Deprecated by SEP-2577; remains functional and will be\nremoved in a future release.\nSee .", - "type": "object", - "properties": { - "listChanged": { - "type": [ - "boolean", - "null" - ] - } - } - }, - "SamplingCapability": { - "description": "Sampling capability with optional sub-capabilities (SEP-1577).\n\nDeprecated by SEP-2577; remains functional and will be removed in a future\nrelease.\nSee .", - "type": "object", - "properties": { - "context": { - "description": "Support for `includeContext` (soft-deprecated)", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "tools": { - "description": "Support for `tools` and `toolChoice` parameters", - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, - "SamplingContent": { - "description": "Single or array content wrapper (SEP-1577).", - "anyOf": [ - { - "$ref": "#/definitions/SamplingMessageContentBlock" - }, - { - "type": "array", - "items": { - "$ref": "#/definitions/SamplingMessageContentBlock" - } - } - ] - }, - "SamplingMessage": { - "description": "A message in a sampling conversation, containing a role and content.\n\nThis represents a single message in a conversation flow, used primarily\nin LLM sampling requests where the conversation history is important\nfor generating appropriate responses.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "content": { - "description": "The actual content of the message (text, image, audio, tool use, or tool result)", - "allOf": [ - { - "$ref": "#/definitions/SamplingContent" - } - ] - }, - "role": { - "description": "The role of the message sender (User or Assistant)", - "allOf": [ - { - "$ref": "#/definitions/Role" - } - ] - } - }, - "deprecated": true, - "required": [ - "role", - "content" - ] - }, - "SamplingMessageContentBlock": { - "description": "Content types for sampling messages (SEP-1577).", - "deprecated": true, - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - } - }, - "allOf": [ - { - "$ref": "#/definitions/TextContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "image" - } - }, - "allOf": [ - { - "$ref": "#/definitions/ImageContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "audio" - } - }, - "allOf": [ - { - "$ref": "#/definitions/AudioContent" - } - ], - "required": [ - "type" - ] - }, - { - "description": "Assistant only", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tool_use" - } - }, - "allOf": [ - { - "$ref": "#/definitions/ToolUseContent" - } - ], - "required": [ - "type" - ] - }, - { - "description": "User only", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tool_result" - } - }, - "allOf": [ - { - "$ref": "#/definitions/ToolResultContent" - } - ], - "required": [ - "type" - ] - } - ] - }, - "ServerCapabilities": { - "title": "Builder", - "description": "```rust\n# use rmcp::model::ServerCapabilities;\nlet cap = ServerCapabilities::builder()\n .enable_experimental()\n .enable_prompts()\n .enable_resources()\n .enable_tools()\n .enable_tool_list_changed()\n .build();\n```", - "type": "object", - "properties": { - "completions": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "experimental": { - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": "object", - "additionalProperties": true - } - }, - "extensions": { - "description": "Optional MCP extensions that the server supports (SEP-1724).\nKeys are extension identifiers (e.g., `\"io.modelcontextprotocol/apps\"`),\nvalues are per-extension settings objects. An empty object indicates\nsupport with no settings.", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": "object", - "additionalProperties": true - } - }, - "logging": { - "description": "Capability for server log message notifications (deprecated by SEP-2577).", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "prompts": { - "anyOf": [ - { - "$ref": "#/definitions/PromptsCapability" - }, - { - "type": "null" - } - ] - }, - "resources": { - "anyOf": [ - { - "$ref": "#/definitions/ResourcesCapability" - }, - { - "type": "null" - } - ] - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/ToolsCapability" - }, - { - "type": "null" - } - ] - } - } - }, - "ServerResult": { - "anyOf": [ - { - "$ref": "#/definitions/DiscoverResult" - }, - { - "$ref": "#/definitions/InitializeResult" - }, - { - "$ref": "#/definitions/CompleteResult" - }, - { - "$ref": "#/definitions/GetPromptResult" - }, - { - "$ref": "#/definitions/ListPromptsResult" - }, - { - "$ref": "#/definitions/ListResourcesResult" - }, - { - "$ref": "#/definitions/ListResourceTemplatesResult" - }, - { - "$ref": "#/definitions/ReadResourceResult" - }, - { - "$ref": "#/definitions/SubscriptionsListenResult" - }, - { - "$ref": "#/definitions/ListToolsResult" - }, - { - "$ref": "#/definitions/ElicitResult" - }, - { - "$ref": "#/definitions/CreateTaskResult" - }, - { - "$ref": "#/definitions/GetTaskResult" - }, - { - "$ref": "#/definitions/CallToolResult" - }, - { - "$ref": "#/definitions/InputRequiredResult" - }, - { - "$ref": "#/definitions/TaskAckResult" - }, - { - "$ref": "#/definitions/EmptyObject" - }, - { - "$ref": "#/definitions/CustomResult" - } - ] - }, - "SingleSelectEnumSchema": { - "description": "Combined single-select", - "anyOf": [ - { - "$ref": "#/definitions/UntitledSingleSelectEnumSchema" - }, - { - "$ref": "#/definitions/TitledSingleSelectEnumSchema" - } - ] - }, - "StringFormat": { - "description": "String format types allowed by the MCP specification.", - "oneOf": [ - { - "description": "Email address format", - "type": "string", - "const": "email" - }, - { - "description": "URI format", - "type": "string", - "const": "uri" - }, - { - "description": "Date format (YYYY-MM-DD)", - "type": "string", - "const": "date" - }, - { - "description": "Date-time format (ISO 8601)", - "type": "string", - "const": "date-time" - } - ] - }, - "StringSchema": { - "description": "Schema definition for string properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec:\n- format limited to: \"email\", \"uri\", \"date\", \"date-time\"", - "type": "object", - "properties": { - "default": { - "description": "Default value", - "type": [ - "string", - "null" - ] - }, - "description": { - "description": "Human-readable description", - "type": [ - "string", - "null" - ] - }, - "format": { - "description": "String format - limited to: \"email\", \"uri\", \"date\", \"date-time\"", - "anyOf": [ - { - "$ref": "#/definitions/StringFormat" - }, - { - "type": "null" - } - ] - }, - "maxLength": { - "description": "Maximum string length", - "type": [ - "integer", - "null" - ], - "format": "uint32", - "minimum": 0 - }, - "minLength": { - "description": "Minimum string length", - "type": [ - "integer", - "null" - ], - "format": "uint32", - "minimum": 0 - }, - "title": { - "description": "Optional title for the schema", - "type": [ - "string", - "null" - ] - }, - "type": { - "description": "Type discriminator", - "allOf": [ - { - "$ref": "#/definitions/StringTypeConst" - } - ] - } - }, - "required": [ - "type" - ] - }, - "StringTypeConst": { - "type": "string", - "format": "const", - "const": "string" - }, - "SubscriptionFilter": { - "description": "Notification categories a client opts in to on a `subscriptions/listen` stream.", - "type": "object", - "properties": { - "promptsListChanged": { - "type": "boolean" - }, - "resourceSubscriptions": { - "type": "array", - "items": { - "type": "string" - } - }, - "resourcesListChanged": { - "type": "boolean" - }, - "toolsListChanged": { - "type": "boolean" - } - } - }, - "SubscriptionsAcknowledgedNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/subscriptions/acknowledged" - }, - "SubscriptionsAcknowledgedNotificationParams": { - "description": "Parameters reporting the accepted subset of a subscription filter.", - "type": "object", - "properties": { - "_meta": { - "$ref": "#/definitions/NotificationMetaObject" - }, - "notifications": { - "$ref": "#/definitions/SubscriptionFilter" - } - }, - "required": [ - "notifications" - ] - }, - "SubscriptionsListenResult": { - "description": "Final response indicating that a subscription ended gracefully.", - "type": "object", - "properties": { - "_meta": { - "$ref": "#/definitions/SubscriptionsListenResultMeta" - }, - "resultType": { - "$ref": "#/definitions/ResultType" - } - }, - "required": [ - "resultType", - "_meta" - ] - }, - "SubscriptionsListenResultMeta": { - "type": "object", - "properties": { - "io.modelcontextprotocol/serverInfo": { - "description": "Identifies the server software producing the response. Servers SHOULD include this field on every response unless specifically configured not to do so.", - "allOf": [ - { - "$ref": "#/definitions/Implementation" - } - ] - }, - "io.modelcontextprotocol/subscriptionId": { - "$ref": "#/definitions/NumberOrString" - } - }, - "additionalProperties": true, - "required": [ - "io.modelcontextprotocol/subscriptionId" - ] - }, - "TaskAckResult": { - "description": "Empty acknowledgement for `tasks/update` and `tasks/cancel` (SEP-2663).\n\nThe spec requires these acks to be empty results carrying the SEP-2322\n`resultType: \"complete\"` discriminator; task state changes are observed\nvia the next `tasks/get`.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "resultType": { - "description": "Always `\"complete\"`.", - "allOf": [ - { - "$ref": "#/definitions/ResultType" - } - ] - } - }, - "required": [ - "resultType" - ] - }, - "TaskStatus": { - "description": "Canonical task lifecycle status (SEP-2663).", - "oneOf": [ - { - "description": "The request is currently being processed.", - "type": "string", - "const": "working" - }, - { - "description": "The server needs input from the client before the task can proceed.", - "type": "string", - "const": "input_required" - }, - { - "description": "The request completed successfully and the result is available.\nThis includes tool calls that returned results with `isError: true`.", - "type": "string", - "const": "completed" - }, - { - "description": "The request failed due to a JSON-RPC error during execution.", - "type": "string", - "const": "failed" - }, - { - "description": "The request was cancelled before completion.", - "type": "string", - "const": "cancelled" - } - ] - }, - "TaskStatusNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/tasks" - }, - "TaskStatusNotificationParams": { - "description": "Parameters for a task status notification (spec `TaskStatusNotificationParams`).\n\nCarries a complete [`DetailedTask`] for the current status, identical to\nwhat `tasks/get` would have returned at that moment. The task fields are\nflattened at the top level: `NotificationParams & Task`.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "createdAt": { - "description": "ISO 8601 timestamp when the task was created.", - "type": "string" - }, - "error": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "inputRequests": { - "type": [ - "object", - "null" - ], - "additionalProperties": { - "$ref": "#/definitions/InputRequest" - } - }, - "lastUpdatedAt": { - "description": "ISO 8601 timestamp when the task was last updated.", - "type": "string" - }, - "pollIntervalMs": { - "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "result": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "status": { - "description": "Current task status.", - "allOf": [ - { - "$ref": "#/definitions/TaskStatus" - } - ] - }, - "statusMessage": { - "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", - "type": [ - "string", - "null" - ] - }, - "taskId": { - "description": "Stable identifier for this task, generated by the server.", - "type": "string" - }, - "ttlMs": { - "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt" - ] - }, - "TextContent": { - "description": "Text content block (spec `TextContent`).", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block.", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional annotations describing how the client should use this content.", - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "text": { - "description": "The text content of the message.", - "type": "string" - } - }, - "required": [ - "text" - ] - }, - "TitledItems": { - "description": "Items for titled multi-select options", - "type": "object", - "properties": { - "anyOf": { - "type": "array", - "items": { - "$ref": "#/definitions/ConstTitle" - } - } - }, - "required": [ - "anyOf" - ] - }, - "TitledMultiSelectEnumSchema": { - "description": "Multi-select titled options", - "type": "object", - "properties": { - "default": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "items": { - "$ref": "#/definitions/TitledItems" - }, - "maxItems": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "minItems": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "type": { - "$ref": "#/definitions/ArrayTypeConst" - } - }, - "required": [ - "type", - "items" - ] - }, - "TitledSingleSelectEnumSchema": { - "description": "Titled single-select", - "type": "object", - "properties": { - "default": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "oneOf": { - "type": "array", - "items": { - "$ref": "#/definitions/ConstTitle" - } - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "type": { - "$ref": "#/definitions/StringTypeConst" - } - }, - "required": [ - "type", - "oneOf" - ] - }, - "Tool": { - "description": "A tool that can be used by a model.", - "type": "object", - "properties": { - "_meta": { - "description": "Optional additional metadata for this tool", - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "annotations": { - "description": "Optional additional tool information.", - "anyOf": [ - { - "$ref": "#/definitions/ToolAnnotations" - }, - { - "type": "null" - } - ] - }, - "description": { - "description": "A description of what the tool does", - "type": [ - "string", - "null" - ] - }, - "icons": { - "description": "Optional list of icons for the tool", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "inputSchema": { - "description": "A JSON Schema object defining the expected parameters for the tool", - "type": "object", - "additionalProperties": true - }, - "name": { - "description": "The name of the tool", - "type": "string" - }, - "outputSchema": { - "description": "An optional JSON Schema object defining the structure of the tool's output", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "title": { - "description": "A human-readable title for the tool", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "name", - "inputSchema" - ] - }, - "ToolAnnotations": { - "description": "Additional properties describing a Tool to clients.\n\nNOTE: all properties in ToolAnnotations are **hints**.\nThey are not guaranteed to provide a faithful description of\ntool behavior (including descriptive properties like `title`).\n\nClients should never make tool use decisions based on ToolAnnotations\nreceived from untrusted servers.", - "type": "object", - "properties": { - "destructiveHint": { - "description": "If true, the tool may perform destructive updates to its environment.\nIf false, the tool performs only additive updates.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: true\nA human-readable description of the tool's purpose.", - "type": [ - "boolean", - "null" - ] - }, - "idempotentHint": { - "description": "If true, calling the tool repeatedly with the same arguments\nwill have no additional effect on the its environment.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: false.", - "type": [ - "boolean", - "null" - ] - }, - "openWorldHint": { - "description": "If true, this tool may interact with an \"open world\" of external\nentities. If false, the tool's domain of interaction is closed.\nFor example, the world of a web search tool is open, whereas that\nof a memory tool is not.\n\nDefault: true", - "type": [ - "boolean", - "null" - ] - }, - "readOnlyHint": { - "description": "If true, the tool does not modify its environment.\n\nDefault: false", - "type": [ - "boolean", - "null" - ] - }, - "title": { - "description": "A human-readable title for the tool.", - "type": [ - "string", - "null" - ] - } - } - }, - "ToolChoice": { - "description": "Tool choice configuration (SEP-1577).", - "type": "object", - "properties": { - "mode": { - "anyOf": [ - { - "$ref": "#/definitions/ToolChoiceMode" - }, - { - "type": "null" - } - ] - } - }, - "deprecated": true - }, - "ToolChoiceMode": { - "description": "Tool selection mode (SEP-1577).", - "oneOf": [ - { - "description": "Model decides whether to use tools", - "type": "string", - "const": "auto" - }, - { - "description": "Model must use at least one tool", - "type": "string", - "const": "required" - }, - { - "description": "Model must not use tools", - "type": "string", - "const": "none" - } - ] - }, - "ToolListChangedNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/tools/list_changed" - }, - "ToolResultContent": { - "description": "Tool execution result in user message (SEP-1577).", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "content": { - "type": "array", - "items": { - "$ref": "#/definitions/ContentBlock" - } - }, - "isError": { - "type": [ - "boolean", - "null" - ] - }, - "structuredContent": true, - "toolUseId": { - "type": "string" - } - }, - "deprecated": true, - "required": [ - "toolUseId", - "content" - ] - }, - "ToolUseContent": { - "description": "Tool call request from assistant (SEP-1577).", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": "string" - }, - "input": { - "type": "object", - "additionalProperties": true - }, - "name": { - "type": "string" - } - }, - "deprecated": true, - "required": [ - "id", - "name", - "input" - ] - }, - "ToolsCapability": { - "type": "object", - "properties": { - "listChanged": { - "type": [ - "boolean", - "null" - ] - } - } - }, - "UntitledItems": { - "description": "Items for untitled multi-select options", - "type": "object", - "properties": { - "enum": { - "type": "array", - "items": { - "type": "string" - } - }, - "type": { - "$ref": "#/definitions/StringTypeConst" - } - }, - "required": [ - "type", - "enum" - ] - }, - "UntitledMultiSelectEnumSchema": { - "description": "Multi-select untitled options", - "type": "object", - "properties": { - "default": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "items": { - "$ref": "#/definitions/UntitledItems" - }, - "maxItems": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "minItems": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "type": { - "$ref": "#/definitions/ArrayTypeConst" - } - }, - "required": [ - "type", - "items" - ] - }, - "UntitledSingleSelectEnumSchema": { - "description": "Untitled single-select", - "type": "object", - "properties": { - "default": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "enum": { - "type": "array", - "items": { - "type": "string" - } - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "type": { - "$ref": "#/definitions/StringTypeConst" - } - }, - "additionalProperties": false, - "required": [ - "type", - "enum" - ] - }, - "UrlElicitationCapability": { - "description": "Capability for URL mode elicitation.", - "type": "object" - } - } -} \ No newline at end of file diff --git a/vendor/rmcp/tests/test_meta_helpers.rs b/vendor/rmcp/tests/test_meta_helpers.rs deleted file mode 100644 index 5f9128a42..000000000 --- a/vendor/rmcp/tests/test_meta_helpers.rs +++ /dev/null @@ -1,98 +0,0 @@ -#![allow(deprecated)] - -use rmcp::model::{ - ClientCapabilities, Implementation, LoggingLevel, ProtocolVersion, RequestMetaObject, -}; -use serde_json::json; - -const META_KEY_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion"; -const META_KEY_CLIENT_INFO: &str = "io.modelcontextprotocol/clientInfo"; -const META_KEY_CLIENT_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities"; -const META_KEY_LOG_LEVEL: &str = "io.modelcontextprotocol/logLevel"; - -#[test] -fn meta_setters_store_sep_2575_values() { - let mut meta = RequestMetaObject::new(); - meta.set_protocol_version(ProtocolVersion::V_2026_07_28); - meta.set_client_info(Implementation::new("test-client", "1.0.0")); - meta.set_client_capabilities(ClientCapabilities::default()); - meta.set_log_level(LoggingLevel::Warning); - - assert_eq!( - meta.get(META_KEY_PROTOCOL_VERSION), - Some(&json!("2026-07-28")) - ); - assert_eq!( - meta.get(META_KEY_CLIENT_INFO), - Some(&json!({ "name": "test-client", "version": "1.0.0" })) - ); - assert_eq!(meta.get(META_KEY_CLIENT_CAPABILITIES), Some(&json!({}))); - assert_eq!(meta.get(META_KEY_LOG_LEVEL), Some(&json!("warning"))); -} - -#[test] -fn with_client_context_sets_all_required_sep_2575_fields() { - let meta = RequestMetaObject::with_client_context( - ProtocolVersion::V_2026_07_28, - Implementation::new("test-client", "1.0.0"), - ClientCapabilities::default(), - ); - - assert!( - meta.missing_required_keys(&ProtocolVersion::V_2026_07_28) - .is_empty() - ); - assert_eq!(meta.protocol_version(), Some(ProtocolVersion::V_2026_07_28)); - assert_eq!( - meta.client_info(), - Some(Implementation::new("test-client", "1.0.0")) - ); - assert_eq!( - meta.client_capabilities(), - Some(ClientCapabilities::default()) - ); -} - -#[test] -fn meta_accessors_decode_wire_values() { - let meta: RequestMetaObject = serde_json::from_value(json!({ - "progressToken": "progress-1", - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientInfo": { - "name": "wire-client", - "version": "9.0.0" - }, - "io.modelcontextprotocol/clientCapabilities": { - "sampling": {} - }, - "io.modelcontextprotocol/logLevel": "error" - })) - .unwrap(); - - assert_eq!(meta.protocol_version(), Some(ProtocolVersion::V_2026_07_28)); - assert_eq!( - meta.client_info(), - Some(Implementation::new("wire-client", "9.0.0")) - ); - assert!( - meta.client_capabilities() - .is_some_and(|capabilities| capabilities.sampling.is_some()) - ); - assert_eq!(meta.log_level(), Some(LoggingLevel::Error)); -} - -#[test] -fn meta_accessors_ignore_missing_or_malformed_values() { - let meta: RequestMetaObject = serde_json::from_value(json!({ - "io.modelcontextprotocol/protocolVersion": 20260728, - "io.modelcontextprotocol/clientInfo": "not an implementation", - "io.modelcontextprotocol/clientCapabilities": "not capabilities", - "io.modelcontextprotocol/logLevel": "loud" - })) - .unwrap(); - - assert_eq!(meta.protocol_version(), None); - assert_eq!(meta.client_info(), None); - assert_eq!(meta.client_capabilities(), None); - assert_eq!(meta.log_level(), None); -} diff --git a/vendor/rmcp/tests/test_mrtr_behavior.rs b/vendor/rmcp/tests/test_mrtr_behavior.rs deleted file mode 100644 index 33332c2b7..000000000 --- a/vendor/rmcp/tests/test_mrtr_behavior.rs +++ /dev/null @@ -1,595 +0,0 @@ -//! Behavior and edge-case coverage for SEP-2322 multi round-trip requests (MRTR). -//! -//! These tests drive a real client/server pair over an in-memory duplex stream -//! and exercise the auto fulfill/retry loop, the manual `*_once` escape hatch, -//! and the server-side version gating. - -// Sampling/Roots are SEP-2577-deprecated but still used to model MRTR input requests. -#![allow(deprecated)] -use std::sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, -}; - -use rmcp::{ - ClientHandler, ServerHandler, - model::*, - service::{RequestContext, RoleClient, RoleServer, ServiceError, serve_directly}, -}; -use serde_json::json; - -/// A `requestState` value with characters that must survive a byte-exact echo: -/// dots (the codec delimiter), base64 punctuation, whitespace, and quotes. -const TRICKY_STATE: &str = "st.ate/with+special=chars and spaces \"quotes\"\n\ttab"; - -// ============================================================================= -// Test handlers -// ============================================================================= - -/// A stateless MRTR server whose behavior is selected by the tool/prompt/resource -/// name. Round progression is derived entirely from `request_state` and -/// `input_responses`, as required by the stateless MRTR pattern. -#[derive(Clone, Default)] -struct MrtrServer { - calls: Arc, -} - -fn elicitation_request(message: &str) -> InputRequest { - InputRequest::Elicitation(ElicitRequest::new( - ElicitRequestParams::FormElicitationParams { - meta: None, - message: message.into(), - requested_schema: serde_json::from_value(json!({ - "type": "object", - "properties": { "name": { "type": "string" } }, - "required": ["name"] - })) - .unwrap(), - }, - )) -} - -fn sampling_request() -> InputRequest { - InputRequest::CreateMessage(CreateMessageRequest::new(CreateMessageRequestParams::new( - vec![SamplingMessage::user_text("What is the capital of France?")], - 100, - ))) -} - -fn roots_request() -> InputRequest { - InputRequest::ListRoots(ListRootsRequest::default()) -} - -fn single_elicitation(state: &str) -> InputRequiredResult { - let mut requests = InputRequests::new(); - requests.insert("answer".to_string(), elicitation_request("Name?")); - InputRequiredResult::new(Some(requests), Some(state.into())) -} - -impl MrtrServer { - fn call_tool_impl( - &self, - request: CallToolRequestParams, - ) -> Result { - let responses = request.input_responses.as_ref(); - let state = request.request_state.as_deref(); - match request.name.as_ref() { - // Single round: one elicitation, then complete. - "single" => match responses { - None => Ok(single_elicitation("state-single").into()), - Some(map) => { - if state != Some("state-single") { - return Err(ErrorData::internal_error("request_state not echoed", None)); - } - let answer = map - .get("answer") - .ok_or_else(|| ErrorData::internal_error("missing answer", None))?; - if answer["action"] != "accept" || answer["content"]["name"] != "Ferris" { - return Err(ErrorData::internal_error("unexpected elicit result", None)); - } - Ok(CallToolResult::success(vec![ContentBlock::text("done")]).into()) - } - }, - // Two elicitation rounds before completing. - "multi_round" => match state { - None => Ok(single_elicitation("round-1").into()), - Some("round-1") => Ok(single_elicitation("round-2").into()), - Some("round-2") => { - Ok(CallToolResult::success(vec![ContentBlock::text("multi-done")]).into()) - } - Some(other) => Err(ErrorData::internal_error( - format!("unexpected round state {other:?}"), - None, - )), - }, - // Several input requests fulfilled concurrently in a single round. - "multi_request" => match responses { - None => { - let mut requests = InputRequests::new(); - requests.insert("form".to_string(), elicitation_request("Name?")); - requests.insert("sample".to_string(), sampling_request()); - requests.insert("roots".to_string(), roots_request()); - Ok(InputRequiredResult::new(Some(requests), Some("multi-req".into())).into()) - } - Some(map) => { - for key in ["form", "sample", "roots"] { - if !map.contains_key(key) { - return Err(ErrorData::internal_error( - format!("missing response for {key}"), - None, - )); - } - } - Ok(CallToolResult::success(vec![ContentBlock::text("multi-req-done")]).into()) - } - }, - // State-only load shedding: two state-only rounds, then complete. - "state_only" => match state { - None => Ok(InputRequiredResult::from_request_state("so-1").into()), - Some("so-1") => Ok(InputRequiredResult::from_request_state("so-2").into()), - Some("so-2") => { - Ok(CallToolResult::success(vec![ContentBlock::text("state-done")]).into()) - } - Some(other) => Err(ErrorData::internal_error( - format!("unexpected state {other:?}"), - None, - )), - }, - // Never completes: used to exercise the max-rounds cap. - "loops" => Ok(single_elicitation("loop").into()), - // Triggers a failure inside the client's elicitation handler. - "handler_error" => { - let mut requests = InputRequests::new(); - requests.insert("answer".to_string(), elicitation_request("FAIL")); - Ok(InputRequiredResult::new(Some(requests), Some("state".into())).into()) - } - // Verifies the client echoes `request_state` byte-for-byte. - "echo_state" => match responses { - None => { - let mut requests = InputRequests::new(); - requests.insert("answer".to_string(), elicitation_request("Name?")); - Ok(InputRequiredResult::new(Some(requests), Some(TRICKY_STATE.into())).into()) - } - Some(_) => { - if state != Some(TRICKY_STATE) { - return Err(ErrorData::internal_error( - "request_state was not echoed byte-exact", - None, - )); - } - Ok(CallToolResult::success(vec![ContentBlock::text("echo-ok")]).into()) - } - }, - _ => Ok(CallToolResult::success(vec![ContentBlock::text("noop")]).into()), - } - } -} - -impl ServerHandler for MrtrServer { - fn get_info(&self) -> ServerInfo { - let mut info = ServerInfo::new( - ServerCapabilities::builder() - .enable_tools() - .enable_prompts() - .build(), - ); - info.protocol_version = ProtocolVersion::V_2026_07_28; - info - } - - async fn call_tool( - &self, - request: CallToolRequestParams, - _context: RequestContext, - ) -> Result { - self.calls.fetch_add(1, Ordering::SeqCst); - self.call_tool_impl(request) - } - - async fn get_prompt( - &self, - request: GetPromptRequestParams, - _context: RequestContext, - ) -> Result { - self.calls.fetch_add(1, Ordering::SeqCst); - match request.request_state.as_deref() { - None => Ok(single_elicitation("prompt-1").into()), - Some("prompt-1") => Ok(GetPromptResult::new(vec![PromptMessage::new_text( - Role::Assistant, - "prompt-done", - )]) - .into()), - Some(other) => Err(ErrorData::internal_error( - format!("unexpected prompt state {other:?}"), - None, - )), - } - } - - async fn read_resource( - &self, - request: ReadResourceRequestParams, - _context: RequestContext, - ) -> Result { - self.calls.fetch_add(1, Ordering::SeqCst); - match request.request_state.as_deref() { - None => Ok(single_elicitation("res-1").into()), - Some("res-1") => Ok(ReadResourceResult::new(vec![ResourceContents::text( - "resource-done", - request.uri, - )]) - .into()), - Some(other) => Err(ErrorData::internal_error( - format!("unexpected resource state {other:?}"), - None, - )), - } - } -} - -/// A client that fulfills every kind of MRTR input request. Elicitation fails -/// deliberately when the prompt message is `"FAIL"`. -#[derive(Clone, Default)] -struct MrtrClient; - -impl ClientHandler for MrtrClient { - async fn create_elicitation( - &self, - request: ElicitRequestParams, - _context: RequestContext, - ) -> Result { - if let ElicitRequestParams::FormElicitationParams { message, .. } = &request - && message == "FAIL" - { - return Err(ErrorData::internal_error( - "elicitation handler failed", - None, - )); - } - Ok(ElicitResult::new(ElicitationAction::Accept).with_content(json!({ "name": "Ferris" }))) - } - - async fn create_message( - &self, - _request: CreateMessageRequestParams, - _context: RequestContext, - ) -> Result { - Ok(CreateMessageResult::new( - SamplingMessage::assistant_text("Paris."), - "test-model".into(), - ) - .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN)) - } - - async fn list_roots( - &self, - _context: RequestContext, - ) -> Result { - Ok(ListRootsResult::new(vec![Root::new("file:///workspace")])) - } -} - -// ============================================================================= -// Harness -// ============================================================================= - -fn client_info(protocol_version: ProtocolVersion) -> ClientInfo { - ClientInfo::new( - ClientCapabilities::builder().enable_elicitation().build(), - Implementation::new("mrtr-test-client", "0.0.0"), - ) - .with_protocol_version(protocol_version) -} - -fn server_info(protocol_version: ProtocolVersion) -> ServerInfo { - let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build()); - info.protocol_version = protocol_version; - info -} - -/// Runs `body` inside a `LocalSet` so `spawn_local` (used when the `local` -/// feature is active) is available, wiring up a connected client/server pair. -async fn with_pair( - server: MrtrServer, - client_protocol: ProtocolVersion, - body: F, -) -> anyhow::Result<()> -where - F: FnOnce(rmcp::service::RunningService) -> Fut, - Fut: std::future::Future>, -{ - tokio::task::LocalSet::new() - .run_until(async move { - let (server_transport, client_transport) = tokio::io::duplex(8192); - let server_peer_info = client_info(client_protocol); - let client_peer_info = server_info(server_peer_info.protocol_version.clone()); - let server_task = tokio::task::spawn_local(async move { - let running = serve_directly::( - server, - server_transport, - Some(server_peer_info), - ); - running.waiting().await?; - anyhow::Ok(()) - }); - - let client = serve_directly::( - MrtrClient, - client_transport, - Some(client_peer_info.into()), - ); - - let result = body(client).await; - - server_task.abort(); - result - }) - .await -} - -// ============================================================================= -// Tests -// ============================================================================= - -#[tokio::test(flavor = "current_thread")] -async fn client_auto_fulfills_input_required_tool_call() -> anyhow::Result<()> { - let server = MrtrServer::default(); - let calls = server.calls.clone(); - with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { - let result = client - .call_tool(CallToolRequestParams::new("single")) - .await?; - assert_eq!(result.content[0].as_text().unwrap().text, "done"); - assert_eq!(calls.load(Ordering::SeqCst), 2); - Ok(()) - }) - .await -} - -#[tokio::test(flavor = "current_thread")] -async fn manual_once_returns_input_required_without_retry() -> anyhow::Result<()> { - let server = MrtrServer::default(); - let calls = server.calls.clone(); - with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { - let result = client - .call_tool_once(CallToolRequestParams::new("single")) - .await?; - assert!(matches!(result, CallToolResponse::InputRequired(_))); - // A manual round makes exactly one server call and never retries. - assert_eq!(calls.load(Ordering::SeqCst), 1); - Ok(()) - }) - .await -} - -#[tokio::test(flavor = "current_thread")] -async fn multi_round_input_required_completes() -> anyhow::Result<()> { - let server = MrtrServer::default(); - let calls = server.calls.clone(); - with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { - let result = client - .call_tool(CallToolRequestParams::new("multi_round")) - .await?; - assert_eq!(result.content[0].as_text().unwrap().text, "multi-done"); - // round 0 + two retries = 3 server calls. - assert_eq!(calls.load(Ordering::SeqCst), 3); - Ok(()) - }) - .await -} - -#[tokio::test(flavor = "current_thread")] -async fn multiple_input_requests_fulfilled_in_one_round() -> anyhow::Result<()> { - let server = MrtrServer::default(); - let calls = server.calls.clone(); - with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { - let result = client - .call_tool(CallToolRequestParams::new("multi_request")) - .await?; - assert_eq!(result.content[0].as_text().unwrap().text, "multi-req-done"); - assert_eq!(calls.load(Ordering::SeqCst), 2); - Ok(()) - }) - .await -} - -#[tokio::test(flavor = "current_thread")] -async fn state_only_input_required_completes() -> anyhow::Result<()> { - let server = MrtrServer::default(); - let calls = server.calls.clone(); - with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { - let result = client - .call_tool(CallToolRequestParams::new("state_only")) - .await?; - assert_eq!(result.content[0].as_text().unwrap().text, "state-done"); - // round 0 + two state-only retries = 3 server calls. - assert_eq!(calls.load(Ordering::SeqCst), 3); - Ok(()) - }) - .await -} - -#[tokio::test(flavor = "current_thread")] -async fn max_rounds_exceeded_returns_error() -> anyhow::Result<()> { - let server = MrtrServer::default(); - let calls = server.calls.clone(); - with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { - let err = client - .call_tool_with_mrtr_max_rounds(CallToolRequestParams::new("loops"), 3) - .await - .expect_err("a tool that never completes must exhaust the round cap"); - assert!(matches!( - err, - ServiceError::InputRequiredRoundsExceeded { max_rounds: 3 } - )); - assert_eq!(calls.load(Ordering::SeqCst), 3); - Ok(()) - }) - .await -} - -#[tokio::test(flavor = "current_thread")] -async fn client_handler_error_propagates() -> anyhow::Result<()> { - let server = MrtrServer::default(); - with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { - let err = client - .call_tool(CallToolRequestParams::new("handler_error")) - .await - .expect_err("a failing input handler must fail the whole call"); - assert!(matches!(err, ServiceError::McpError(_))); - Ok(()) - }) - .await -} - -#[tokio::test(flavor = "current_thread")] -async fn request_state_is_echoed_byte_exact() -> anyhow::Result<()> { - let server = MrtrServer::default(); - with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { - // The server returns an error result unless it sees TRICKY_STATE echoed - // back unchanged, so a successful completion proves the byte-exact echo. - let result = client - .call_tool(CallToolRequestParams::new("echo_state")) - .await?; - assert_eq!(result.content[0].as_text().unwrap().text, "echo-ok"); - Ok(()) - }) - .await -} - -#[tokio::test(flavor = "current_thread")] -async fn get_prompt_auto_fulfills_input_required() -> anyhow::Result<()> { - let server = MrtrServer::default(); - with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { - let result = client.get_prompt(GetPromptRequestParams::new("p")).await?; - assert_eq!( - result.messages[0].content.as_text().unwrap().text, - "prompt-done" - ); - Ok(()) - }) - .await -} - -#[tokio::test(flavor = "current_thread")] -async fn read_resource_auto_fulfills_input_required() -> anyhow::Result<()> { - let server = MrtrServer::default(); - with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { - let result = client - .read_resource(ReadResourceRequestParams::new("res://x")) - .await?; - let text = match &result.contents[0] { - ResourceContents::TextResourceContents { text, .. } => text.clone(), - _ => panic!("expected text resource"), - }; - assert_eq!(text, "resource-done"); - Ok(()) - }) - .await -} - -#[tokio::test(flavor = "current_thread")] -async fn old_protocol_rejects_input_required() -> anyhow::Result<()> { - let server = MrtrServer::default(); - // The client negotiated 2025-11-25, so the server must refuse to emit an - // InputRequiredResult and return a protocol error instead. - with_pair(server, ProtocolVersion::V_2025_11_25, |client| async move { - let err = client - .call_tool_once(CallToolRequestParams::new("single")) - .await - .expect_err("MRTR must be rejected for pre-2026 peers"); - match err { - ServiceError::McpError(error) => { - assert!( - error.message.contains("2026-07-28"), - "unexpected error message: {}", - error.message - ); - } - other => panic!("expected an McpError, got {other:?}"), - } - Ok(()) - }) - .await -} - -#[cfg(feature = "request-state")] -#[tokio::test(flavor = "current_thread")] -async fn request_state_codec_seals_and_verifies_through_the_loop() -> anyhow::Result<()> { - use std::sync::OnceLock; - - use rmcp::model::RequestStateCodec; - - // A shared per-process signing key, mirroring how a real server would derive one. - static KEY: &[u8] = b"integration-signing-key-32-bytes!"; - - fn codec() -> &'static RequestStateCodec { - static CODEC: OnceLock = OnceLock::new(); - CODEC.get_or_init(|| RequestStateCodec::new(KEY)) - } - - #[derive(Clone, Default)] - struct SealingServer; - - impl ServerHandler for SealingServer { - fn get_info(&self) -> ServerInfo { - let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build()); - info.protocol_version = ProtocolVersion::V_2026_07_28; - info - } - - async fn call_tool( - &self, - request: CallToolRequestParams, - _context: RequestContext, - ) -> Result { - match request.request_state { - None => { - let sealed = codec() - .seal_json(&json!({ "step": 1, "tool": request.name })) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - let mut requests = InputRequests::new(); - requests.insert("answer".to_string(), elicitation_request("Name?")); - Ok(InputRequiredResult::new(Some(requests), Some(sealed)).into()) - } - Some(sealed) => { - // The echoed state is untrusted; verify it before use. - let state: serde_json::Value = codec() - .open_json(&sealed) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - assert_eq!(state["step"], 1); - Ok(CallToolResult::success(vec![ContentBlock::text("sealed-done")]).into()) - } - } - } - } - - tokio::task::LocalSet::new() - .run_until(async move { - let (server_transport, client_transport) = tokio::io::duplex(8192); - let server_task = tokio::task::spawn_local(async move { - let running = serve_directly::( - SealingServer, - server_transport, - Some(client_info(ProtocolVersion::V_2026_07_28)), - ); - running.waiting().await?; - anyhow::Ok(()) - }); - - let client = serve_directly::( - MrtrClient, - client_transport, - Some(server_info(ProtocolVersion::V_2026_07_28).into()), - ); - - let result = client - .call_tool(CallToolRequestParams::new("sealed")) - .await?; - assert_eq!(result.content[0].as_text().unwrap().text, "sealed-done"); - - server_task.abort(); - anyhow::Ok(()) - }) - .await -} diff --git a/vendor/rmcp/tests/test_notification.rs b/vendor/rmcp/tests/test_notification.rs deleted file mode 100644 index 9aafd3ccd..000000000 --- a/vendor/rmcp/tests/test_notification.rs +++ /dev/null @@ -1,234 +0,0 @@ -#![cfg(not(feature = "local"))] -#![allow(deprecated)] -use std::sync::Arc; - -use rmcp::{ - ClientHandler, ServerHandler, ServiceExt, - model::{ - ClientNotification, CustomNotification, ResourceUpdatedNotificationParam, - ServerCapabilities, ServerInfo, ServerNotification, SubscribeRequestParams, - }, -}; -use serde_json::json; -use tokio::sync::{Mutex, Notify}; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -struct Server {} - -impl ServerHandler for Server { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( - ServerCapabilities::builder() - .enable_resources() - .enable_resources_subscribe() - .enable_resources_list_changed() - .build(), - ) - } - - async fn subscribe( - &self, - request: rmcp::model::SubscribeRequestParams, - context: rmcp::service::RequestContext, - ) -> Result<(), rmcp::ErrorData> { - let uri = request.uri; - let peer = context.peer; - - tokio::spawn(async move { - let span = tracing::info_span!("subscribe", uri = %uri); - let _enter = span.enter(); - - if let Err(e) = peer - .notify_resource_updated(ResourceUpdatedNotificationParam::new(uri.clone())) - .await - { - panic!("Failed to send notification: {}", e); - } - }); - - Ok(()) - } -} - -pub struct Client { - receive_signal: Arc, -} - -impl ClientHandler for Client { - async fn on_resource_updated( - &self, - params: rmcp::model::ResourceUpdatedNotificationParam, - _context: rmcp::service::NotificationContext, - ) { - let uri = params.uri; - tracing::info!("Resource updated: {}", uri); - self.receive_signal.notify_one(); - } -} - -#[tokio::test] -async fn test_server_notification() -> anyhow::Result<()> { - let _ = tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "debug".to_string().into()), - ) - .with(tracing_subscriber::fmt::layer()) - .try_init(); - let (server_transport, client_transport) = tokio::io::duplex(4096); - tokio::spawn(async move { - let server = Server {}.serve(server_transport).await?; - server.waiting().await?; - anyhow::Ok(()) - }); - let receive_signal = Arc::new(Notify::new()); - let client = Client { - receive_signal: receive_signal.clone(), - } - .serve(client_transport) - .await?; - client - .subscribe(SubscribeRequestParams::new("test://test-resource")) - .await?; - receive_signal.notified().await; - client.cancel().await?; - Ok(()) -} - -type CustomNotificationPayload = (String, Option); - -struct CustomServer { - receive_signal: Arc, - payload: Arc>>, -} - -impl ServerHandler for CustomServer { - async fn on_custom_notification( - &self, - notification: CustomNotification, - _context: rmcp::service::NotificationContext, - ) { - let CustomNotification { method, params, .. } = notification; - *self.payload.lock().await = Some((method, params)); - self.receive_signal.notify_one(); - } -} - -#[tokio::test] -async fn test_custom_client_notification_reaches_server() -> anyhow::Result<()> { - let _ = tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "debug".to_string().into()), - ) - .with(tracing_subscriber::fmt::layer()) - .try_init(); - - let (server_transport, client_transport) = tokio::io::duplex(4096); - let receive_signal = Arc::new(Notify::new()); - let payload = Arc::new(Mutex::new(None)); - - { - let receive_signal = receive_signal.clone(); - let payload = payload.clone(); - tokio::spawn(async move { - let server = CustomServer { - receive_signal, - payload, - } - .serve(server_transport) - .await?; - server.waiting().await?; - anyhow::Ok(()) - }); - } - - let client = ().serve(client_transport).await?; - - client - .send_notification(ClientNotification::CustomNotification( - CustomNotification::new("notifications/custom-test", Some(json!({ "foo": "bar" }))), - )) - .await?; - - tokio::time::timeout(std::time::Duration::from_secs(5), receive_signal.notified()).await?; - - let (method, params) = payload.lock().await.take().expect("payload set"); - assert_eq!("notifications/custom-test", method); - assert_eq!(Some(json!({ "foo": "bar" })), params); - - client.cancel().await?; - Ok(()) -} - -struct CustomServerNotifier; - -impl ServerHandler for CustomServerNotifier { - async fn on_initialized(&self, context: rmcp::service::NotificationContext) { - let peer = context.peer.clone(); - tokio::spawn(async move { - peer.send_notification(ServerNotification::CustomNotification( - CustomNotification::new( - "notifications/custom-test", - Some(json!({ "hello": "world" })), - ), - )) - .await - .expect("send custom notification"); - }); - } -} - -struct CustomClient { - receive_signal: Arc, - payload: Arc>>, -} - -impl ClientHandler for CustomClient { - async fn on_custom_notification( - &self, - notification: CustomNotification, - _context: rmcp::service::NotificationContext, - ) { - let CustomNotification { method, params, .. } = notification; - *self.payload.lock().await = Some((method, params)); - self.receive_signal.notify_one(); - } -} - -#[tokio::test] -async fn test_custom_server_notification_reaches_client() -> anyhow::Result<()> { - let _ = tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "debug".to_string().into()), - ) - .with(tracing_subscriber::fmt::layer()) - .try_init(); - - let (server_transport, client_transport) = tokio::io::duplex(4096); - tokio::spawn(async move { - let server = CustomServerNotifier {}.serve(server_transport).await?; - server.waiting().await?; - anyhow::Ok(()) - }); - - let receive_signal = Arc::new(Notify::new()); - let payload = Arc::new(Mutex::new(None)); - - let client = CustomClient { - receive_signal: receive_signal.clone(), - payload: payload.clone(), - } - .serve(client_transport) - .await?; - - tokio::time::timeout(std::time::Duration::from_secs(5), receive_signal.notified()).await?; - - let (method, params) = payload.lock().await.take().expect("payload set"); - assert_eq!("notifications/custom-test", method); - assert_eq!(Some(json!({ "hello": "world" })), params); - - client.cancel().await?; - Ok(()) -} diff --git a/vendor/rmcp/tests/test_progress_subscriber.rs b/vendor/rmcp/tests/test_progress_subscriber.rs deleted file mode 100644 index 8df1b7cad..000000000 --- a/vendor/rmcp/tests/test_progress_subscriber.rs +++ /dev/null @@ -1,134 +0,0 @@ -#![cfg(not(feature = "local"))] -use futures::StreamExt; -use rmcp::{ - ClientHandler, Peer, RoleServer, ServerHandler, ServiceExt, - handler::{client::progress::ProgressDispatcher, server::tool::ToolRouter}, - model::{ - CallToolRequestParams, ClientRequest, ProgressNotificationParam, Request, RequestMetaObject, - }, - service::PeerRequestOptions, - tool, tool_handler, tool_router, -}; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -pub struct MyClient { - progress_handler: ProgressDispatcher, -} - -impl MyClient { - pub fn new() -> Self { - Self { - progress_handler: ProgressDispatcher::new(), - } - } -} - -impl Default for MyClient { - fn default() -> Self { - Self::new() - } -} - -impl ClientHandler for MyClient { - async fn on_progress( - &self, - params: rmcp::model::ProgressNotificationParam, - _context: rmcp::service::NotificationContext, - ) { - tracing::info!("Received progress notification: {:?}", params); - self.progress_handler.handle_notification(params).await; - } -} - -pub struct MyServer { - #[expect(dead_code, reason = "tool_handler macro accesses this router field")] - tool_router: ToolRouter, -} - -impl MyServer { - pub fn new() -> Self { - Self { - tool_router: Self::tool_router(), - } - } -} - -impl Default for MyServer { - fn default() -> Self { - Self::new() - } -} - -#[tool_router] -impl MyServer { - #[tool] - pub async fn some_progress( - meta: RequestMetaObject, - client: Peer, - ) -> Result<(), rmcp::ErrorData> { - let progress_token = meta - .get_progress_token() - .ok_or(rmcp::ErrorData::invalid_params( - "Progress token is required for this tool", - None, - ))?; - for step in 0..10 { - let _ = client - .notify_progress( - ProgressNotificationParam::new(progress_token.clone(), step as f64) - .with_total(10.0) - .with_message("Some message"), - ) - .await; - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - } - Ok(()) - } -} - -#[tool_handler] -impl ServerHandler for MyServer {} - -#[tokio::test] -async fn test_progress_subscriber() -> anyhow::Result<()> { - let _ = tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "debug".to_string().into()), - ) - .with(tracing_subscriber::fmt::layer()) - .try_init(); - let client = MyClient::new(); - - let server = MyServer::new(); - let (transport_server, transport_client) = tokio::io::duplex(4096); - tokio::spawn(async move { - let service = server.serve(transport_server).await?; - service.waiting().await?; - anyhow::Ok(()) - }); - let client_service = client.serve(transport_client).await?; - let handle = client_service - .send_cancellable_request( - ClientRequest::CallToolRequest(Request::new(CallToolRequestParams::new( - "some_progress", - ))), - PeerRequestOptions::no_options(), - ) - .await?; - let mut progress_subscriber = client_service - .service() - .progress_handler - .subscribe(handle.progress_token.clone()) - .await; - tokio::spawn(async move { - while let Some(notification) = progress_subscriber.next().await { - tracing::info!("Progress notification: {:?}", notification); - } - }); - let _response = handle.await_response().await?; - - // Simulate some delay to allow the async task to complete - tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; - Ok(()) -} diff --git a/vendor/rmcp/tests/test_prompt_handler.rs b/vendor/rmcp/tests/test_prompt_handler.rs deleted file mode 100644 index 97018a55a..000000000 --- a/vendor/rmcp/tests/test_prompt_handler.rs +++ /dev/null @@ -1,155 +0,0 @@ -//cargo test --test test_prompt_handler --features "client server" -// Tests for verifying that the #[prompt_handler] macro correctly generates -// the ServerHandler trait implementation methods. -#![allow(dead_code)] - -use rmcp::{ServerHandler, handler::server::router::prompt::PromptRouter, prompt_handler}; - -#[derive(Debug, Clone)] -pub struct TestPromptServer { - prompt_router: PromptRouter, -} - -impl Default for TestPromptServer { - fn default() -> Self { - Self::new() - } -} - -impl TestPromptServer { - pub fn new() -> Self { - Self { - prompt_router: PromptRouter::new(), - } - } -} - -#[prompt_handler(router = self.prompt_router)] -impl ServerHandler for TestPromptServer {} - -#[derive(Debug, Clone)] -pub struct CustomRouterServer { - custom_router: PromptRouter, -} - -impl Default for CustomRouterServer { - fn default() -> Self { - Self::new() - } -} - -impl CustomRouterServer { - pub fn new() -> Self { - Self { - custom_router: PromptRouter::new(), - } - } - - pub fn get_custom_router(&self) -> &PromptRouter { - &self.custom_router - } -} - -#[prompt_handler(router = self.custom_router)] -impl ServerHandler for CustomRouterServer {} - -#[derive(Debug, Clone)] -pub struct GenericPromptServer { - prompt_router: PromptRouter, - _marker: std::marker::PhantomData, -} - -impl Default for GenericPromptServer { - fn default() -> Self { - Self::new() - } -} - -impl GenericPromptServer { - pub fn new() -> Self { - Self { - prompt_router: PromptRouter::new(), - _marker: std::marker::PhantomData, - } - } -} - -#[prompt_handler(router = self.prompt_router)] -impl ServerHandler for GenericPromptServer {} - -#[test] -fn test_prompt_handler_basic() { - let server = TestPromptServer::new(); - - // Test that the server implements ServerHandler - fn assert_server_handler(_: &T) {} - assert_server_handler(&server); - - // Test that the prompt router is accessible - assert_eq!(server.prompt_router.list_all().len(), 0); -} - -#[test] -fn test_prompt_handler_custom_router() { - let server = CustomRouterServer::new(); - - // Test that the server implements ServerHandler - fn assert_server_handler(_: &T) {} - assert_server_handler(&server); - - // Test that the custom router is used - assert_eq!(server.custom_router.list_all().len(), 0); -} - -#[test] -fn test_prompt_handler_with_generics() { - let server = GenericPromptServer::::new(); - - // Test that generic server implements ServerHandler - fn assert_server_handler(_: &T) {} - assert_server_handler(&server); - - // Test with a different generic type - let server2 = GenericPromptServer::::new(); - assert_server_handler(&server2); -} - -#[test] -fn test_prompt_handler_trait_implementation() { - // This test verifies that the prompt_handler macro generates proper ServerHandler implementation - // The actual method signatures are tested through the ServerHandler trait bound - fn compile_time_check() {} - - compile_time_check::(); - compile_time_check::(); - compile_time_check::>(); -} - -// Test that the macro works with different server configurations -mod nested { - use super::*; - - #[derive(Debug, Clone)] - pub struct NestedServer { - prompt_router: PromptRouter, - } - - impl NestedServer { - pub fn new() -> Self { - Self { - prompt_router: PromptRouter::new(), - } - } - } - - #[prompt_handler(router = self.prompt_router)] - impl ServerHandler for NestedServer {} - - #[test] - fn test_nested_prompt_handler() { - let server = NestedServer::new(); - // Verify it implements ServerHandler - fn assert_server_handler(_: &T) {} - assert_server_handler(&server); - } -} diff --git a/vendor/rmcp/tests/test_prompt_macro_annotations.rs b/vendor/rmcp/tests/test_prompt_macro_annotations.rs deleted file mode 100644 index b5b6ef6af..000000000 --- a/vendor/rmcp/tests/test_prompt_macro_annotations.rs +++ /dev/null @@ -1,274 +0,0 @@ -//cargo test --test test_prompt_macro_annotations --features "client server" -#![allow(dead_code)] - -use rmcp::{ - ServerHandler, - handler::server::wrapper::Parameters, - model::{GetPromptResult, Prompt, PromptMessage, Role}, - prompt, -}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone)] -struct TestServer; - -impl ServerHandler for TestServer {} - -#[derive(Serialize, Deserialize, JsonSchema)] -struct TestArgs { - /// The input text to process - input: String, - /// Optional configuration - #[serde(skip_serializing_if = "Option::is_none")] - config: Option, -} - -#[derive(Serialize, Deserialize, JsonSchema)] -struct ComplexArgs { - /// Required field - required_field: String, - /// Optional string field - #[schemars(description = "An optional string parameter")] - optional_string: Option, - /// Optional number field - optional_number: Option, - /// Array field - items: Vec, -} - -// Test basic prompt attribute generation -#[prompt] -async fn basic_prompt(_server: &TestServer) -> Vec { - vec![PromptMessage::new_text(Role::Assistant, "Basic response")] -} - -// Test prompt with custom name -#[prompt(name = "custom_name")] -async fn named_prompt(_server: &TestServer) -> Vec { - vec![PromptMessage::new_text(Role::Assistant, "Named response")] -} - -// Test prompt with custom description -#[prompt(description = "This is a custom description")] -async fn described_prompt(_server: &TestServer) -> Vec { - vec![PromptMessage::new_text( - Role::Assistant, - "Described response", - )] -} - -// Test prompt with both name and description -#[prompt(name = "full_custom", description = "Fully customized prompt")] -async fn fully_custom_prompt(_server: &TestServer) -> Vec { - vec![PromptMessage::new_text( - Role::Assistant, - "Fully custom response", - )] -} - -// Test prompt with doc comments -/// This is a doc comment description -/// that spans multiple lines -#[prompt] -async fn doc_comment_prompt(_server: &TestServer) -> Vec { - vec![PromptMessage::new_text( - Role::Assistant, - "Doc comment response", - )] -} - -// Test prompt with doc comments and explicit description (explicit wins) -/// This is a doc comment -#[prompt(description = "This overrides the doc comment")] -async fn override_doc_prompt(_server: &TestServer) -> Vec { - vec![PromptMessage::new_text( - Role::Assistant, - "Override response", - )] -} - -// Test prompt with arguments -#[prompt] -async fn args_prompt(_server: &TestServer, _args: Parameters) -> Vec { - vec![PromptMessage::new_text(Role::Assistant, "Args response")] -} - -// Test prompt with complex arguments -#[prompt] -async fn complex_args_prompt( - _server: &TestServer, - _args: Parameters, -) -> GetPromptResult { - GetPromptResult::new(vec![PromptMessage::new_text( - Role::Assistant, - "Complex response", - )]) - .with_description("Complex args result") -} - -// Test sync prompt -#[prompt] -fn sync_prompt(_server: &TestServer) -> Vec { - vec![PromptMessage::new_text(Role::Assistant, "Sync response")] -} - -#[test] -fn test_basic_prompt_attr() { - let attr = basic_prompt_prompt_attr(); - assert_eq!(attr.name, "basic_prompt"); - assert_eq!(attr.description, None); - assert!(attr.arguments.is_none()); -} - -#[test] -fn test_named_prompt_attr() { - let attr = named_prompt_prompt_attr(); - assert_eq!(attr.name, "custom_name"); - assert_eq!(attr.description, None); - assert!(attr.arguments.is_none()); -} - -#[test] -fn test_described_prompt_attr() { - let attr = described_prompt_prompt_attr(); - assert_eq!(attr.name, "described_prompt"); - assert_eq!( - attr.description.as_deref(), - Some("This is a custom description") - ); - assert!(attr.arguments.is_none()); -} - -#[test] -fn test_fully_custom_prompt_attr() { - let attr = fully_custom_prompt_prompt_attr(); - assert_eq!(attr.name, "full_custom"); - assert_eq!(attr.description.as_deref(), Some("Fully customized prompt")); - assert!(attr.arguments.is_none()); -} - -#[test] -fn test_doc_comment_prompt_attr() { - let attr = doc_comment_prompt_prompt_attr(); - assert_eq!(attr.name, "doc_comment_prompt"); - assert!(attr.description.is_some()); - let desc = attr.description.unwrap(); - assert!(desc.contains("This is a doc comment description")); - assert!(desc.contains("that spans multiple lines")); -} - -#[test] -fn test_override_doc_prompt_attr() { - let attr = override_doc_prompt_prompt_attr(); - assert_eq!(attr.name, "override_doc_prompt"); - assert_eq!( - attr.description.as_deref(), - Some("This overrides the doc comment") - ); -} - -#[test] -fn test_args_prompt_attr() { - let attr = args_prompt_prompt_attr(); - assert_eq!(attr.name, "args_prompt"); - - let args = attr.arguments.as_ref().unwrap(); - assert_eq!(args.len(), 2); - - // Check input field - let input_arg = args.iter().find(|a| a.name == "input").unwrap(); - assert_eq!(input_arg.required, Some(true)); - assert_eq!( - input_arg.description.as_deref(), - Some("The input text to process") - ); - - // Check config field - let config_arg = args.iter().find(|a| a.name == "config").unwrap(); - assert_eq!(config_arg.required, Some(false)); - assert_eq!( - config_arg.description.as_deref(), - Some("Optional configuration") - ); -} - -#[test] -fn test_complex_args_prompt_attr() { - let attr = complex_args_prompt_prompt_attr(); - assert_eq!(attr.name, "complex_args_prompt"); - - let args = attr.arguments.as_ref().unwrap(); - assert_eq!(args.len(), 4); - - // Check required_field - let required_arg = args.iter().find(|a| a.name == "required_field").unwrap(); - assert_eq!(required_arg.required, Some(true)); - assert_eq!(required_arg.description.as_deref(), Some("Required field")); - - // Check optional_string - let optional_string_arg = args.iter().find(|a| a.name == "optional_string").unwrap(); - assert_eq!(optional_string_arg.required, Some(false)); - assert_eq!( - optional_string_arg.description.as_deref(), - Some("An optional string parameter") - ); - - // Check optional_number - let optional_number_arg = args.iter().find(|a| a.name == "optional_number").unwrap(); - assert_eq!(optional_number_arg.required, Some(false)); - assert_eq!( - optional_number_arg.description.as_deref(), - Some("Optional number field") - ); - - // Check items - let items_arg = args.iter().find(|a| a.name == "items").unwrap(); - assert_eq!(items_arg.required, Some(true)); - assert_eq!(items_arg.description.as_deref(), Some("Array field")); -} - -#[test] -fn test_sync_prompt_attr() { - let attr = sync_prompt_prompt_attr(); - assert_eq!(attr.name, "sync_prompt"); - assert!(attr.arguments.is_none()); -} - -#[test] -fn test_prompt_attr_function_type() { - // Test that the generated function returns the correct type - fn assert_prompt_attr_fn(_: impl Fn() -> Prompt) {} - - assert_prompt_attr_fn(basic_prompt_prompt_attr); - assert_prompt_attr_fn(named_prompt_prompt_attr); - assert_prompt_attr_fn(described_prompt_prompt_attr); - assert_prompt_attr_fn(fully_custom_prompt_prompt_attr); - assert_prompt_attr_fn(doc_comment_prompt_prompt_attr); - assert_prompt_attr_fn(override_doc_prompt_prompt_attr); - assert_prompt_attr_fn(args_prompt_prompt_attr); - assert_prompt_attr_fn(complex_args_prompt_prompt_attr); - assert_prompt_attr_fn(sync_prompt_prompt_attr); -} - -// Test generic prompts -#[derive(Debug, Clone)] -struct GenericServer { - _marker: std::marker::PhantomData, -} - -impl ServerHandler for GenericServer {} - -#[prompt] -async fn generic_prompt( - _server: &GenericServer, -) -> Vec { - vec![PromptMessage::new_text(Role::Assistant, "Generic response")] -} - -#[test] -fn test_generic_prompt_attr() { - let attr = generic_prompt_prompt_attr(); - assert_eq!(attr.name, "generic_prompt"); - assert!(attr.arguments.is_none()); -} diff --git a/vendor/rmcp/tests/test_prompt_macros.rs b/vendor/rmcp/tests/test_prompt_macros.rs deleted file mode 100644 index 7a00249a4..000000000 --- a/vendor/rmcp/tests/test_prompt_macros.rs +++ /dev/null @@ -1,375 +0,0 @@ -#![cfg(not(feature = "local"))] -//cargo test --test test_prompt_macros --features "client server" -#![allow(dead_code)] -use std::sync::Arc; - -use rmcp::{ - ClientHandler, RoleServer, ServerHandler, ServiceExt, - handler::server::{router::prompt::PromptRouter, wrapper::Parameters}, - model::{ - ClientInfo, ContentBlock, GetPromptRequestParams, GetPromptResult, ListPromptsResult, - PaginatedRequestParams, PromptMessage, Role, - }, - prompt, prompt_handler, prompt_router, - service::RequestContext, -}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize, JsonSchema)] -struct CodeReviewRequest { - file_path: String, - language: String, -} - -#[prompt_handler(router = self.prompt_router)] -impl ServerHandler for Server {} - -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct Server { - prompt_router: PromptRouter, -} - -impl Default for Server { - fn default() -> Self { - Self::new() - } -} - -#[prompt_router] -impl Server { - pub fn new() -> Self { - Self { - prompt_router: Self::prompt_router(), - } - } - - /// This prompt is used to review code for best practices. - #[prompt( - name = "code-review", - description = "Review code for best practices and issues." - )] - pub async fn code_review(&self, params: Parameters) -> Vec { - vec![ - PromptMessage::new_text( - Role::User, - format!( - "Please review the {} code in: {}", - params.0.language, params.0.file_path - ), - ), - PromptMessage::new_text( - Role::Assistant, - "I'll review this code for best practices and potential issues.".to_string(), - ), - ] - } - - #[prompt] - async fn empty_param(&self) -> Vec { - vec![PromptMessage::new_text( - Role::Assistant, - "This is a prompt with no parameters.".to_string(), - )] - } -} - -// define generic service trait -pub trait DataService: Send + Sync + 'static { - fn get_context(&self) -> String; -} - -// mock service for test -#[derive(Clone)] -struct MockDataService; -impl DataService for MockDataService { - fn get_context(&self) -> String { - "mock context data".to_string() - } -} - -// define generic server -#[derive(Debug, Clone)] -pub struct GenericServer { - data_service: Arc, - prompt_router: PromptRouter, -} - -#[prompt_router] -impl GenericServer { - pub fn new(data_service: DS) -> Self { - Self { - data_service: Arc::new(data_service), - prompt_router: Self::prompt_router(), - } - } - - #[prompt(description = "Get contextual help from the service")] - async fn get_help(&self) -> GetPromptResult { - let context = self.data_service.get_context(); - GetPromptResult::new(vec![ - PromptMessage::new_text( - Role::User, - "I need help with the current context.".to_string(), - ), - PromptMessage::new_text( - Role::Assistant, - format!( - "Based on the context '{}', here's how I can help...", - context - ), - ), - ]) - .with_description("Contextual help based on service data") - } -} - -#[prompt_handler] -impl ServerHandler for GenericServer {} - -#[tokio::test] -async fn test_prompt_macros() { - let server = Server::new(); - let _attr = Server::code_review_prompt_attr(); - let _code_review_prompt_attr_fn = Server::code_review_prompt_attr; - let _code_review_fn = Server::code_review; - let result = server - .code_review(Parameters(CodeReviewRequest { - file_path: "/src/main.rs".into(), - language: "rust".into(), - })) - .await; - assert_eq!(result.len(), 2); - assert_eq!(result[0].role, Role::User); - assert_eq!(result[1].role, Role::Assistant); -} - -#[tokio::test] -async fn test_prompt_macros_with_empty_param() { - let _attr = Server::empty_param_prompt_attr(); - println!("{_attr:?}"); - assert!( - _attr.arguments.is_none(), - "Empty param prompt should have no arguments" - ); -} - -#[tokio::test] -async fn test_prompt_macros_with_generics() { - let mock_service = MockDataService; - let server = GenericServer::new(mock_service); - let _attr = GenericServer::::get_help_prompt_attr(); - let _get_help_call_fn = GenericServer::::get_help; - let _get_help_fn = GenericServer::::get_help; - let result = server.get_help().await; - assert!(result.description.is_some()); - assert_eq!(result.messages.len(), 2); - match &result.messages[1].content { - ContentBlock::Text(text_content) => { - assert!(text_content.text.contains("mock context data")); - } - _ => panic!("Expected text content"), - } -} - -#[tokio::test] -async fn test_prompt_macros_with_optional_param() { - let _attr = Server::code_review_prompt_attr(); - let arguments = _attr.arguments.as_ref().unwrap(); - - // Check that we have the expected number of arguments - assert_eq!(arguments.len(), 2); - - // Verify file_path is required - let file_path_arg = arguments.iter().find(|a| a.name == "file_path").unwrap(); - assert_eq!(file_path_arg.required, Some(true)); - - // Verify language is required - let language_arg = arguments.iter().find(|a| a.name == "language").unwrap(); - assert_eq!(language_arg.required, Some(true)); -} - -impl CodeReviewRequest {} - -// Struct defined for testing optional field schema generation -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -struct OptionalFieldTestSchema { - #[schemars(description = "An optional description field")] - description: Option, -} - -// Struct defined for testing optional i64 field schema generation and null handling -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -struct OptionalI64TestSchema { - #[schemars(description = "An optional i64 field")] - count: Option, - mandatory_field: String, // Added to ensure non-empty object schema -} - -// Dummy struct to host the test prompt method -#[derive(Debug, Clone)] -pub struct OptionalSchemaTester { - prompt_router: PromptRouter, -} - -impl Default for OptionalSchemaTester { - fn default() -> Self { - Self::new() - } -} - -impl OptionalSchemaTester { - pub fn new() -> Self { - Self { - prompt_router: Self::prompt_router(), - } - } -} - -#[prompt_router] -impl OptionalSchemaTester { - // Dummy prompt function using the test schema as an aggregated parameter - #[prompt(description = "A prompt to test optional schema generation")] - async fn test_optional(&self, _req: Parameters) -> Vec { - vec![PromptMessage::new_text( - Role::Assistant, - "Testing optional fields".to_string(), - )] - } - - // Prompt function to test optional i64 handling - #[prompt(description = "A prompt to test optional i64 schema generation")] - async fn test_optional_i64( - &self, - Parameters(req): Parameters, - ) -> GetPromptResult { - let message = match req.count { - Some(c) => format!("Received count: {}", c), - None => "Received null count".to_string(), - }; - - GetPromptResult::new(vec![PromptMessage::new_text(Role::Assistant, message)]) - .with_description("Test result for optional i64") - } -} - -#[prompt_handler] -// Implement ServerHandler to route prompt calls for OptionalSchemaTester -impl ServerHandler for OptionalSchemaTester {} - -#[test] -fn test_optional_field_schema_generation_via_macro() { - // tests https://github.com/modelcontextprotocol/rust-sdk/issues/135 - - // Get the attributes generated by the #[prompt] macro helper - let prompt_attr = OptionalSchemaTester::test_optional_prompt_attr(); - - // Print the actual generated schema for debugging - println!( - "Actual arguments generated by macro: {:#?}", - prompt_attr.arguments - ); - - // Verify the schema generated for the aggregated OptionalFieldTestSchema - let arguments = prompt_attr.arguments.expect("Should have arguments"); - - // Check that we have an argument for the optional description field - let description_arg = arguments - .iter() - .find(|arg| arg.name == "description") - .expect("Should have description argument"); - - // Assert that optional fields are marked as not required - assert_eq!( - description_arg.required, - Some(false), - "Optional fields should be marked as not required" - ); - - // Check the description is correct - assert_eq!( - description_arg.description.as_deref(), - Some("An optional description field") - ); -} - -// Define a dummy client handler -#[derive(Debug, Clone, Default)] -struct DummyClientHandler {} - -impl ClientHandler for DummyClientHandler { - fn get_info(&self) -> ClientInfo { - ClientInfo::default() - } -} - -#[tokio::test] -async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - // Server setup - let server = OptionalSchemaTester::new(); - let server_handle = tokio::spawn(async move { - server.serve(server_transport).await?.waiting().await?; - anyhow::Ok(()) - }); - - // Create a simple client handler that just forwards prompt calls - let client_handler = DummyClientHandler::default(); - let client = client_handler.serve(client_transport).await?; - - // Test null case - let result = client - .get_prompt( - GetPromptRequestParams::new("test_optional_i64").with_arguments( - serde_json::json!({ - "count": null, - "mandatory_field": "test_null" - }) - .as_object() - .unwrap() - .clone(), - ), - ) - .await?; - - let result_text = match &result.messages.first().unwrap().content { - ContentBlock::Text(text_content) => text_content.text.as_str(), - _ => panic!("Expected text content"), - }; - - assert_eq!( - result_text, "Received null count", - "Null case should return expected message" - ); - - // Test Some case - let some_result = client - .get_prompt( - GetPromptRequestParams::new("test_optional_i64").with_arguments( - serde_json::json!({ - "count": 42, - "mandatory_field": "test_some" - }) - .as_object() - .unwrap() - .clone(), - ), - ) - .await?; - - let some_result_text = match &some_result.messages.first().unwrap().content { - ContentBlock::Text(text_content) => text_content.text.as_str(), - _ => panic!("Expected text content"), - }; - - assert_eq!( - some_result_text, "Received count: 42", - "Some case should return expected message" - ); - - client.cancel().await?; - server_handle.await??; - Ok(()) -} diff --git a/vendor/rmcp/tests/test_prompt_routers.rs b/vendor/rmcp/tests/test_prompt_routers.rs deleted file mode 100644 index eecdb1e4a..000000000 --- a/vendor/rmcp/tests/test_prompt_routers.rs +++ /dev/null @@ -1,140 +0,0 @@ -#![cfg(not(feature = "local"))] -use std::collections::HashMap; - -use futures::future::BoxFuture; -use rmcp::{ - ServerHandler, - handler::server::wrapper::Parameters, - model::{GetPromptResult, PromptMessage, Role}, -}; - -#[derive(Debug, Default)] -struct TestHandler { - _marker: std::marker::PhantomData, -} - -impl ServerHandler for TestHandler {} - -#[derive(Debug, schemars::JsonSchema, serde::Deserialize, serde::Serialize)] -struct Request { - fields: HashMap, -} - -#[derive(Debug, schemars::JsonSchema, serde::Deserialize, serde::Serialize)] -struct Sum { - a: i32, - b: i32, -} - -#[rmcp::prompt_router(router = "test_router")] -impl TestHandler { - #[rmcp::prompt] - async fn async_method( - &self, - Parameters(Request { fields }): Parameters, - ) -> Vec { - drop(fields); - vec![PromptMessage::new_text( - Role::Assistant, - "Async method response", - )] - } - - #[rmcp::prompt] - fn sync_method( - &self, - Parameters(Request { fields }): Parameters, - ) -> Vec { - drop(fields); - vec![PromptMessage::new_text( - Role::Assistant, - "Sync method response", - )] - } -} - -#[rmcp::prompt] -async fn async_function(Parameters(Request { fields }): Parameters) -> Vec { - drop(fields); - vec![PromptMessage::new_text( - Role::Assistant, - "Async function response", - )] -} - -#[rmcp::prompt] -fn async_function2(_callee: &TestHandler) -> BoxFuture<'_, GetPromptResult> { - Box::pin(async move { - GetPromptResult::new(vec![PromptMessage::new_text( - Role::Assistant, - "Async function 2 response", - )]) - .with_description("Async function 2") - }) -} - -#[test] -fn test_prompt_router() { - let test_prompt_router = TestHandler::<()>::test_router() - .with_route(rmcp::handler::server::router::prompt::PromptRoute::new_dyn( - async_function_prompt_attr(), - |mut context| { - Box::pin(async move { - use rmcp::handler::server::{ - common::FromContextPart, prompt::IntoGetPromptResult, - }; - let params = Parameters::::from_context_part(&mut context)?; - let result = async_function(params).await; - result.into_get_prompt_result() - }) - }, - )) - .with_route(rmcp::handler::server::router::prompt::PromptRoute::new_dyn( - async_function2_prompt_attr(), - |context| { - Box::pin(async move { - use rmcp::handler::server::prompt::IntoGetPromptResult; - let result = async_function2(context.server).await; - result.into_get_prompt_result() - }) - }, - )); - let prompts = test_prompt_router.list_all(); - assert_eq!(prompts.len(), 4); -} - -#[test] -fn test_prompt_router_list_all_is_sorted() { - let router = TestHandler::<()>::test_router() - .with_route(rmcp::handler::server::router::prompt::PromptRoute::new_dyn( - async_function_prompt_attr(), - |mut context| { - Box::pin(async move { - use rmcp::handler::server::{ - common::FromContextPart, prompt::IntoGetPromptResult, - }; - let params = Parameters::::from_context_part(&mut context)?; - let result = async_function(params).await; - result.into_get_prompt_result() - }) - }, - )) - .with_route(rmcp::handler::server::router::prompt::PromptRoute::new_dyn( - async_function2_prompt_attr(), - |context| { - Box::pin(async move { - use rmcp::handler::server::prompt::IntoGetPromptResult; - let result = async_function2(context.server).await; - result.into_get_prompt_result() - }) - }, - )); - let prompts = router.list_all(); - let names: Vec<&str> = prompts.iter().map(|p| p.name.as_ref()).collect(); - let mut sorted = names.clone(); - sorted.sort(); - assert_eq!( - names, sorted, - "list_all() should return prompts sorted alphabetically by name" - ); -} diff --git a/vendor/rmcp/tests/test_protocol_version_negotiation.rs b/vendor/rmcp/tests/test_protocol_version_negotiation.rs deleted file mode 100644 index e91ecf97a..000000000 --- a/vendor/rmcp/tests/test_protocol_version_negotiation.rs +++ /dev/null @@ -1,171 +0,0 @@ -//! Tests for protocol version negotiation in the default ServerHandler::initialize impl. -//! -//! Known versions are echoed back; unknown versions fall back to LATEST. -#![cfg(not(feature = "local"))] -#![cfg(feature = "client")] - -use std::borrow::Cow; - -use rmcp::{ - ClientHandler, ErrorData, RoleServer, ServerHandler, ServiceExt, - model::{ClientInfo, InitializeRequestParams, InitializeResult, ProtocolVersion, ServerInfo}, - service::RequestContext, -}; - -#[derive(Debug, Clone, Default)] -struct EchoServer; - -impl ServerHandler for EchoServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::default() - } -} - -/// Every known version except `2026-07-28`, standing in for a server that has -/// not implemented that revision. -const NARROWED_VERSIONS: &[ProtocolVersion] = &[ - ProtocolVersion::V_2024_11_05, - ProtocolVersion::V_2025_03_26, - ProtocolVersion::V_2025_06_18, - ProtocolVersion::V_2025_11_25, -]; - -#[derive(Debug, Clone, Default)] -struct NarrowedServer; - -impl ServerHandler for NarrowedServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::default() - } - - fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { - Cow::Borrowed(NARROWED_VERSIONS) - } -} - -/// Narrows the supported versions *and* overrides `initialize`, so the -/// handler's own answer never runs the default negotiation. The handshake layer -/// must still honor the narrowed list. -#[derive(Debug, Clone, Default)] -struct NarrowedOverridingServer; - -impl ServerHandler for NarrowedOverridingServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::default() - } - - fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { - Cow::Borrowed(NARROWED_VERSIONS) - } - - async fn initialize( - &self, - _request: InitializeRequestParams, - _context: RequestContext, - ) -> Result { - Ok(self.get_info()) - } -} - -#[derive(Debug, Clone)] -struct VersionedClient { - protocol_version: ProtocolVersion, -} - -impl ClientHandler for VersionedClient { - fn get_info(&self) -> ClientInfo { - let mut info = ClientInfo::default(); - info.protocol_version = self.protocol_version.clone(); - info - } -} - -async fn negotiated_version(client_version: ProtocolVersion) -> ProtocolVersion { - negotiated_version_with(EchoServer, client_version).await -} - -async fn negotiated_version_with( - server: S, - client_version: ProtocolVersion, -) -> ProtocolVersion { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - tokio::spawn(async move { - let _ = server - .serve(server_transport) - .await - .expect("server should start") - .waiting() - .await; - }); - - let client = VersionedClient { - protocol_version: client_version, - } - .serve(client_transport) - .await - .expect("client should connect"); - - let version = client - .peer_info() - .expect("peer_info should be set") - .protocol_version - .clone(); - - client.cancel().await.expect("client should cancel"); - version -} - -#[tokio::test] -async fn known_version_echoed_back() { - for version in ProtocolVersion::KNOWN_VERSIONS { - let negotiated = negotiated_version(version.clone()).await; - assert_eq!( - negotiated, *version, - "known version {version} should be echoed back" - ); - } -} - -#[tokio::test] -async fn unknown_version_falls_back_to_latest() { - let unknown: ProtocolVersion = serde_json::from_str(r#""1999-01-01""#).unwrap(); - let negotiated = negotiated_version(unknown).await; - assert_eq!( - negotiated, - ProtocolVersion::LATEST, - "unknown version should fall back to LATEST" - ); -} - -#[tokio::test] -async fn narrowed_server_still_echoes_versions_it_supports() { - for version in NARROWED_VERSIONS { - let negotiated = negotiated_version_with(NarrowedServer, version.clone()).await; - assert_eq!( - negotiated, *version, - "supported version {version} should be echoed back" - ); - } -} - -#[tokio::test] -async fn narrowed_server_does_not_agree_to_version_it_excludes() { - let negotiated = negotiated_version_with(NarrowedServer, ProtocolVersion::V_2026_07_28).await; - assert_eq!( - negotiated, - ProtocolVersion::V_2025_11_25, - "a version outside supported_protocol_versions should not be echoed back" - ); -} - -#[tokio::test] -async fn narrowed_server_caps_even_when_it_overrides_initialize() { - let negotiated = - negotiated_version_with(NarrowedOverridingServer, ProtocolVersion::V_2026_07_28).await; - assert_eq!( - negotiated, - ProtocolVersion::V_2025_11_25, - "the handshake layer should not raise the version above what the server supports" - ); -} diff --git a/vendor/rmcp/tests/test_request_timeout_progress.rs b/vendor/rmcp/tests/test_request_timeout_progress.rs deleted file mode 100644 index f37101f8e..000000000 --- a/vendor/rmcp/tests/test_request_timeout_progress.rs +++ /dev/null @@ -1,214 +0,0 @@ -#![cfg(not(feature = "local"))] - -use std::{ - sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }, - time::Duration, -}; - -use rmcp::{ - ClientHandler, Peer, RoleServer, ServiceError, ServiceExt, - model::{ - CallToolRequestParams, ClientRequest, NumberOrString, ProgressNotificationParam, - ProgressToken, Request, RequestMetaObject, - }, - service::PeerRequestOptions, - tool, tool_router, -}; - -#[derive(Clone, Default)] -struct ProgressCountingClient { - progress_count: Arc, -} - -impl ClientHandler for ProgressCountingClient { - async fn on_progress( - &self, - _params: ProgressNotificationParam, - _context: rmcp::service::NotificationContext, - ) { - self.progress_count.fetch_add(1, Ordering::SeqCst); - } -} - -struct ProgressTimeoutServer; - -#[tool_router(server_handler)] -impl ProgressTimeoutServer { - #[tool] - async fn delayed_without_progress(&self) -> Result<(), rmcp::ErrorData> { - tokio::time::sleep(Duration::from_millis(250)).await; - Ok(()) - } - - #[tool] - async fn delayed_with_progress( - &self, - meta: RequestMetaObject, - client: Peer, - ) -> Result<(), rmcp::ErrorData> { - let progress_token = meta - .get_progress_token() - .ok_or(rmcp::ErrorData::invalid_params( - "Progress token is required", - None, - ))?; - - for step in 0..4 { - tokio::time::sleep(Duration::from_millis(50)).await; - let _ = client - .notify_progress( - ProgressNotificationParam::new(progress_token.clone(), step as f64) - .with_total(4.0) - .with_message("working"), - ) - .await; - } - - Ok(()) - } - - #[tool] - async fn delayed_with_unrelated_progress( - &self, - client: Peer, - ) -> Result<(), rmcp::ErrorData> { - for step in 0..4 { - tokio::time::sleep(Duration::from_millis(50)).await; - let _ = client - .notify_progress( - ProgressNotificationParam::new( - ProgressToken(NumberOrString::Number(999_999)), - step as f64, - ) - .with_total(4.0) - .with_message("unrelated"), - ) - .await; - } - - Ok(()) - } -} - -async fn start_pair() --> anyhow::Result> { - let server = ProgressTimeoutServer; - let client = ProgressCountingClient::default(); - let (transport_server, transport_client) = tokio::io::duplex(4096); - - tokio::spawn(async move { - let service = server.serve(transport_server).await?; - service.waiting().await?; - anyhow::Ok(()) - }); - - Ok(client.serve(transport_client).await?) -} - -async fn call_tool_with_options( - client: &rmcp::service::RunningService, - name: &str, - options: PeerRequestOptions, -) -> Result { - client - .send_request_with_option( - ClientRequest::CallToolRequest(Request::new(CallToolRequestParams::new( - name.to_owned(), - ))), - options, - ) - .await? - .await_response() - .await -} - -#[tokio::test] -async fn request_timeout_still_expires_without_progress() -> anyhow::Result<()> { - let client = start_pair().await?; - let result = call_tool_with_options( - &client, - "delayed_without_progress", - PeerRequestOptions::with_timeout(Duration::from_millis(75)), - ) - .await; - - assert!(matches!(result, Err(ServiceError::Timeout { .. }))); - Ok(()) -} - -#[tokio::test] -async fn progress_does_not_reset_timeout_by_default() -> anyhow::Result<()> { - let client = start_pair().await?; - let result = call_tool_with_options( - &client, - "delayed_with_progress", - PeerRequestOptions::with_timeout(Duration::from_millis(75)), - ) - .await; - - assert!(matches!(result, Err(ServiceError::Timeout { .. }))); - Ok(()) -} - -#[tokio::test] -async fn matching_progress_resets_timeout_when_enabled() -> anyhow::Result<()> { - let client = start_pair().await?; - let result = call_tool_with_options( - &client, - "delayed_with_progress", - PeerRequestOptions::with_timeout(Duration::from_millis(75)).reset_timeout_on_progress(), - ) - .await; - - assert!(result.is_ok()); - assert!(client.service().progress_count.load(Ordering::SeqCst) > 0); - Ok(()) -} - -#[tokio::test] -async fn generated_progress_token_overrides_option_meta_token() -> anyhow::Result<()> { - let client = start_pair().await?; - let mut options = - PeerRequestOptions::with_timeout(Duration::from_millis(75)).reset_timeout_on_progress(); - options.meta = Some(RequestMetaObject::with_progress_token(ProgressToken( - NumberOrString::Number(999_999), - ))); - - let result = call_tool_with_options(&client, "delayed_with_progress", options).await; - - assert!(result.is_ok()); - Ok(()) -} - -#[tokio::test] -async fn max_total_timeout_wins_over_progress_reset() -> anyhow::Result<()> { - let client = start_pair().await?; - let result = call_tool_with_options( - &client, - "delayed_with_progress", - PeerRequestOptions::with_timeout(Duration::from_millis(75)) - .reset_timeout_on_progress() - .with_max_total_timeout(Duration::from_millis(125)), - ) - .await; - - assert!(matches!(result, Err(ServiceError::Timeout { .. }))); - Ok(()) -} - -#[tokio::test] -async fn unrelated_progress_does_not_reset_timeout() -> anyhow::Result<()> { - let client = start_pair().await?; - let result = call_tool_with_options( - &client, - "delayed_with_unrelated_progress", - PeerRequestOptions::with_timeout(Duration::from_millis(75)).reset_timeout_on_progress(), - ) - .await; - - assert!(matches!(result, Err(ServiceError::Timeout { .. }))); - Ok(()) -} diff --git a/vendor/rmcp/tests/test_resource_link.rs b/vendor/rmcp/tests/test_resource_link.rs deleted file mode 100644 index 7db5b06b7..000000000 --- a/vendor/rmcp/tests/test_resource_link.rs +++ /dev/null @@ -1,86 +0,0 @@ -use rmcp::model::{CallToolResult, ContentBlock, Resource}; - -#[test] -fn test_resource_link_in_tool_result() { - // Test creating a tool result with resource links - let resource = Resource::new("file:///test/file.txt", "test.txt"); - - // Create a tool result with a resource link - let result = CallToolResult::success(vec![ - ContentBlock::text("Found a file"), - ContentBlock::resource_link(resource), - ]); - - // Serialize to JSON to verify format - let json = serde_json::to_string_pretty(&result).unwrap(); - println!("Tool result with resource link:\n{}", json); - - // Verify JSON contains expected structure - assert!( - json.contains("\"type\":\"resource_link\"") || json.contains("\"type\": \"resource_link\"") - ); - assert!( - json.contains("\"uri\":\"file:///test/file.txt\"") - || json.contains("\"uri\": \"file:///test/file.txt\"") - ); - assert!(json.contains("\"name\":\"test.txt\"") || json.contains("\"name\": \"test.txt\"")); - - // Test deserialization - let deserialized: CallToolResult = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized.content.len(), 2); - - // Check the text content - assert!(deserialized.content[0].as_text().is_some()); - - // Check the resource link - let resource_link = deserialized.content[1] - .as_resource_link() - .expect("Expected resource link in content[1]"); - assert_eq!(resource_link.uri, "file:///test/file.txt"); - assert_eq!(resource_link.name, "test.txt"); -} - -#[test] -fn test_resource_link_with_full_metadata() { - let resource = Resource::new("https://example.com/data.json", "API Data") - .with_description("JSON data from external API") - .with_mime_type("application/json") - .with_size(1024); - - let result = CallToolResult::success(vec![ContentBlock::resource_link(resource)]); - - let json = serde_json::to_string(&result).unwrap(); - let deserialized: CallToolResult = serde_json::from_str(&json).unwrap(); - - let resource_link = deserialized.content[0] - .as_resource_link() - .expect("Expected resource link"); - assert_eq!(resource_link.uri, "https://example.com/data.json"); - assert_eq!(resource_link.name, "API Data"); - assert_eq!( - resource_link.description, - Some("JSON data from external API".to_string()) - ); - assert_eq!( - resource_link.mime_type, - Some("application/json".to_string()) - ); - assert_eq!(resource_link.size, Some(1024)); -} - -#[test] -fn test_mixed_content_types() { - // Test that resource links can be mixed with other content types - let resource = Resource::new("file:///doc.pdf", "Document"); - - let result = CallToolResult::success(vec![ - ContentBlock::text("Processing complete"), - ContentBlock::resource_link(resource), - ContentBlock::embedded_text("memo://result", "Analysis results here"), - ]); - - assert_eq!(result.content.len(), 3); - assert!(result.content[0].as_text().is_some()); - assert!(result.content[1].as_resource_link().is_some()); - assert!(result.content[2].as_resource().is_some()); -} diff --git a/vendor/rmcp/tests/test_resource_link_integration.rs b/vendor/rmcp/tests/test_resource_link_integration.rs deleted file mode 100644 index 2cc687285..000000000 --- a/vendor/rmcp/tests/test_resource_link_integration.rs +++ /dev/null @@ -1,114 +0,0 @@ -/// Integration tests for resource_link support in both tools and prompts -use rmcp::model::{CallToolResult, ContentBlock, PromptMessage, Resource, Role}; - -#[test] -fn test_tool_and_prompt_resource_link_compatibility() { - let resource = Resource::new("file:///shared/data.json", "Shared Data"); - - // Test 1: Tool returning a resource link - let tool_result = CallToolResult::success(vec![ - ContentBlock::text("Found shared data"), - ContentBlock::resource_link(resource.clone()), - ]); - - let tool_json = serde_json::to_string(&tool_result).unwrap(); - assert!(tool_json.contains("\"type\":\"resource_link\"")); - - // Test 2: Prompt returning a resource link - let prompt_message = PromptMessage::new_resource_link(Role::Assistant, resource.clone()); - - let prompt_json = serde_json::to_string(&prompt_message).unwrap(); - assert!(prompt_json.contains("\"type\":\"resource_link\"")); - - // Test 3: Verify both serialize to the same resource link structure - let tool_content = &tool_result.content[1]; - let prompt_content = &prompt_message.content; - - let tool_resource_json = serde_json::to_value(tool_content).unwrap(); - let prompt_resource_json = serde_json::to_value(prompt_content).unwrap(); - - assert_eq!( - tool_resource_json.get("type").unwrap(), - prompt_resource_json.get("type").unwrap() - ); - assert_eq!( - tool_resource_json.get("uri").unwrap(), - prompt_resource_json.get("uri").unwrap() - ); - assert_eq!( - tool_resource_json.get("name").unwrap(), - prompt_resource_json.get("name").unwrap() - ); -} - -#[test] -fn test_resource_link_roundtrip() { - let resource = Resource::new("https://api.example.com/resource", "API Resource") - .with_description("External API resource") - .with_mime_type("application/json") - .with_size(2048); - - // Test with tool result - let tool_result = CallToolResult::success(vec![ContentBlock::resource_link(resource.clone())]); - - let tool_json = serde_json::to_string(&tool_result).unwrap(); - let tool_deserialized: CallToolResult = serde_json::from_str(&tool_json).unwrap(); - - if let Some(resource_link) = tool_deserialized.content[0].as_resource_link() { - assert_eq!(resource_link.uri, "https://api.example.com/resource"); - assert_eq!(resource_link.name, "API Resource"); - assert_eq!( - resource_link.description, - Some("External API resource".to_string()) - ); - assert_eq!( - resource_link.mime_type, - Some("application/json".to_string()) - ); - assert_eq!(resource_link.size, Some(2048)); - } else { - panic!("Expected resource link in tool result"); - } - - // Test with prompt message - let prompt_message = PromptMessage::new(Role::User, ContentBlock::resource_link(resource)); - - let prompt_json = serde_json::to_string(&prompt_message).unwrap(); - let prompt_deserialized: PromptMessage = serde_json::from_str(&prompt_json).unwrap(); - - if let ContentBlock::ResourceLink(link) = &prompt_deserialized.content { - assert_eq!(link.uri, "https://api.example.com/resource"); - assert_eq!(link.name, "API Resource"); - assert_eq!(link.description, Some("External API resource".to_string())); - assert_eq!(link.mime_type, Some("application/json".to_string())); - assert_eq!(link.size, Some(2048)); - } else { - panic!("Expected resource link in prompt message"); - } -} - -#[test] -fn test_mixed_content_in_prompts_and_tools() { - let resource1 = Resource::new("file:///doc1.md", "Document 1"); - let resource2 = Resource::new("file:///doc2.md", "Document 2"); - - // Tool with mixed content - let tool_result = CallToolResult::success(vec![ - ContentBlock::text("Processing complete. Found documents:"), - ContentBlock::resource_link(resource1), - ContentBlock::resource_link(resource2), - ContentBlock::embedded_text("summary://result", "Both documents processed successfully"), - ]); - - assert_eq!(tool_result.content.len(), 4); - assert!(tool_result.content[0].as_text().is_some()); - assert!(tool_result.content[1].as_resource_link().is_some()); - assert!(tool_result.content[2].as_resource_link().is_some()); - assert!(tool_result.content[3].as_resource().is_some()); - - // Verify serialization includes all types - let json = serde_json::to_string(&tool_result).unwrap(); - assert!(json.contains("\"type\":\"text\"")); - assert!(json.contains("\"type\":\"resource_link\"")); - assert!(json.contains("\"type\":\"resource\"")); -} diff --git a/vendor/rmcp/tests/test_resource_not_found_version.rs b/vendor/rmcp/tests/test_resource_not_found_version.rs deleted file mode 100644 index 44eb3631f..000000000 --- a/vendor/rmcp/tests/test_resource_not_found_version.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! SEP-2164: the resource-not-found error code follows the negotiated protocol version. -//! -//! `2026-07-28` and newer get the standard `INVALID_PARAMS` (-32602); older versions -//! keep the legacy `RESOURCE_NOT_FOUND` (-32002). -#![cfg(not(feature = "local"))] -#![cfg(feature = "client")] - -use rmcp::{ - ClientHandler, RoleServer, ServerHandler, ServiceError, ServiceExt, - model::{ - ClientInfo, ErrorCode, ErrorData, ProtocolVersion, ReadResourceRequestParams, - ReadResourceResponse, - }, - service::RequestContext, -}; - -#[derive(Debug, Clone, Default)] -struct ResourceServer; - -impl ServerHandler for ResourceServer { - async fn read_resource( - &self, - _request: ReadResourceRequestParams, - _context: RequestContext, - ) -> Result { - Err(ErrorData::resource_not_found("resource not found", None)) - } -} - -#[derive(Debug, Clone)] -struct VersionedClient { - protocol_version: ProtocolVersion, -} - -impl ClientHandler for VersionedClient { - fn get_info(&self) -> ClientInfo { - let mut info = ClientInfo::default(); - info.protocol_version = self.protocol_version.clone(); - info - } -} - -async fn not_found_code(client_version: ProtocolVersion) -> ErrorCode { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - let server_handle = tokio::spawn(async move { - ResourceServer - .serve(server_transport) - .await? - .waiting() - .await?; - anyhow::Ok(()) - }); - - let client = VersionedClient { - protocol_version: client_version, - } - .serve(client_transport) - .await - .expect("client should connect"); - - let error = client - .read_resource(ReadResourceRequestParams::new("missing://resource")) - .await - .expect_err("missing resource should error"); - - let code = match error { - ServiceError::McpError(data) => data.code, - other => panic!("expected McpError, got: {other:?}"), - }; - - client.cancel().await.expect("client should cancel"); - server_handle.await.expect("server task").expect("server"); - code -} - -#[tokio::test] -async fn legacy_version_gets_resource_not_found_code() { - assert_eq!( - not_found_code(ProtocolVersion::V_2025_11_25).await, - ErrorCode::RESOURCE_NOT_FOUND, - ); -} - -#[tokio::test] -async fn sep_2164_version_gets_invalid_params_code() { - assert_eq!( - not_found_code(ProtocolVersion::V_2026_07_28).await, - ErrorCode::INVALID_PARAMS, - ); -} diff --git a/vendor/rmcp/tests/test_result_type_version.rs b/vendor/rmcp/tests/test_result_type_version.rs deleted file mode 100644 index 849a6610e..000000000 --- a/vendor/rmcp/tests/test_result_type_version.rs +++ /dev/null @@ -1,82 +0,0 @@ -//! SEP-2322: the `resultType` discriminator follows the negotiated protocol version. -//! -//! Peers negotiating `2026-07-28` or newer receive `resultType: "complete"` on -//! ordinary results; older peers keep the legacy wire shape without the field. -#![cfg(not(feature = "local"))] -#![cfg(feature = "client")] - -use rmcp::{ - ClientHandler, RoleServer, ServerHandler, ServiceExt, - model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ClientInfo, ContentBlock, - ErrorData, ProtocolVersion, ResultType, - }, - service::RequestContext, -}; - -#[derive(Debug, Clone, Default)] -struct ToolServer; - -impl ServerHandler for ToolServer { - async fn call_tool( - &self, - _request: CallToolRequestParams, - _context: RequestContext, - ) -> Result { - Ok(CallToolResult::success(vec![ContentBlock::text("ok")]).into()) - } -} - -#[derive(Debug, Clone)] -struct VersionedClient { - protocol_version: ProtocolVersion, -} - -impl ClientHandler for VersionedClient { - fn get_info(&self) -> ClientInfo { - let mut info = ClientInfo::default(); - info.protocol_version = self.protocol_version.clone(); - info - } -} - -async fn call_tool_result_type(client_version: ProtocolVersion) -> Option { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - let server_handle = tokio::spawn(async move { - ToolServer.serve(server_transport).await?.waiting().await?; - anyhow::Ok(()) - }); - - let client = VersionedClient { - protocol_version: client_version, - } - .serve(client_transport) - .await - .expect("client should connect"); - - let result = client - .call_tool(CallToolRequestParams::new("echo")) - .await - .expect("tool call should succeed"); - - client.cancel().await.expect("client should cancel"); - server_handle.await.expect("server task").expect("server"); - result.result_type -} - -#[tokio::test] -async fn legacy_version_omits_result_type() { - assert_eq!( - call_tool_result_type(ProtocolVersion::V_2025_11_25).await, - None - ); -} - -#[tokio::test] -async fn sep_2322_version_gets_complete_result_type() { - assert_eq!( - call_tool_result_type(ProtocolVersion::V_2026_07_28).await, - Some(ResultType::COMPLETE), - ); -} diff --git a/vendor/rmcp/tests/test_result_type_wire.rs b/vendor/rmcp/tests/test_result_type_wire.rs deleted file mode 100644 index d5f201532..000000000 --- a/vendor/rmcp/tests/test_result_type_wire.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! Wire-shape regression guards for the SEP-2322 `resultType` discriminator. -//! -//! These pin the behavior that keeps older/strict peers working: -//! - `EmptyResult` stays a bare `{}` (some peers strict-validate empty results -//! and reject extra keys), -//! - ordinary results carry `resultType: "complete"` by default, and -//! - [`ServerResult::strip_result_type_for_legacy_peer`] removes the -//! `"complete"` discriminator so legacy sessions keep their historical wire -//! shape (round-tripping a legacy result never adds the field). - -use rmcp::model::{ - CallToolResult, CompleteResult, ContentBlock, EmptyResult, GetPromptResult, ListToolsResult, - ReadResourceResult, ResultType, ServerResult, -}; -use serde_json::json; - -#[test] -fn empty_result_serializes_without_result_type() { - let value = serde_json::to_value(EmptyResult {}).expect("serialize EmptyResult"); - assert_eq!(value, json!({})); -} - -#[test] -fn call_tool_result_serializes_complete_result_type() { - let value = serde_json::to_value(CallToolResult::success(vec![ContentBlock::text("ok")])) - .expect("serialize CallToolResult"); - assert_eq!(value["resultType"], "complete"); -} - -#[test] -fn paginated_result_serializes_complete_result_type() { - let value = - serde_json::to_value(ListToolsResult::default()).expect("serialize ListToolsResult"); - assert_eq!(value["resultType"], "complete"); -} - -// Guards the convention the server handler relies on: every constructor and -// `Default` impl produces `Some(COMPLETE)`, so 2026-07-28 sessions always -// include the spec-required field unless a handler clears it explicitly. -#[test] -fn constructors_and_defaults_produce_complete_result_type() { - let complete = Some(ResultType::COMPLETE); - assert_eq!(CallToolResult::default().result_type, complete); - assert_eq!(CallToolResult::success(vec![]).result_type, complete); - assert_eq!(CallToolResult::error(vec![]).result_type, complete); - assert_eq!(CallToolResult::structured(json!({})).result_type, complete); - assert_eq!( - CallToolResult::structured_error(json!({})).result_type, - complete - ); - assert_eq!(ListToolsResult::default().result_type, complete); - assert_eq!( - ListToolsResult::with_all_items(vec![]).result_type, - complete - ); - assert_eq!(ReadResourceResult::new(vec![]).result_type, complete); - assert_eq!(GetPromptResult::default().result_type, complete); - assert_eq!(GetPromptResult::new(vec![]).result_type, complete); - assert_eq!(CompleteResult::default().result_type, complete); -} - -#[test] -fn absent_result_type_deserializes_as_none() { - let legacy = json!({ - "content": [], - "isError": false, - }); - - let result: CallToolResult = - serde_json::from_value(legacy).expect("deserialize legacy CallToolResult"); - - assert_eq!(result.result_type, None); -} - -#[test] -fn legacy_call_tool_result_round_trips_without_result_type() { - let legacy = json!({ - "content": [], - "isError": false, - }); - - let result: CallToolResult = - serde_json::from_value(legacy.clone()).expect("deserialize legacy CallToolResult"); - let reserialized = serde_json::to_value(result).expect("serialize CallToolResult"); - - assert_eq!(reserialized, legacy); -} - -#[test] -fn strip_removes_complete_result_type() { - let mut result = - ServerResult::CallToolResult(CallToolResult::success(vec![ContentBlock::text("ok")])); - result.strip_result_type_for_legacy_peer(); - - let value = serde_json::to_value(result).expect("serialize ServerResult"); - assert_eq!( - value, - json!({ - "content": [{ "type": "text", "text": "ok" }], - "isError": false, - }) - ); -} - -#[test] -fn strip_removes_complete_result_type_from_paginated_result() { - let mut result = ServerResult::ListToolsResult(ListToolsResult::default()); - result.strip_result_type_for_legacy_peer(); - - let value = serde_json::to_value(result).expect("serialize ServerResult"); - assert_eq!(value, json!({ "tools": [] })); -} - -#[test] -fn strip_preserves_custom_result_type() { - let streaming: ResultType = - serde_json::from_value(json!("streaming")).expect("deserialize ResultType"); - let mut tool_result = CallToolResult::success(vec![ContentBlock::text("ok")]); - tool_result.result_type = Some(streaming); - - let mut result = ServerResult::CallToolResult(tool_result); - result.strip_result_type_for_legacy_peer(); - - let value = serde_json::to_value(result).expect("serialize ServerResult"); - assert_eq!(value["resultType"], "streaming"); -} diff --git a/vendor/rmcp/tests/test_sampling.rs b/vendor/rmcp/tests/test_sampling.rs deleted file mode 100644 index b108ff412..000000000 --- a/vendor/rmcp/tests/test_sampling.rs +++ /dev/null @@ -1,744 +0,0 @@ -#![cfg(not(feature = "local"))] -#![allow(deprecated)] -mod common; - -use anyhow::Result; -use common::handlers::{TestClientHandler, TestServer}; -use rmcp::{ - ServiceExt, - model::*, - service::{RequestContext, Service}, -}; -use rstest::rstest; - -#[tokio::test] -async fn test_basic_sampling_message_creation() -> Result<()> { - let message = SamplingMessage::user_text("What is the capital of France?"); - - let json = serde_json::to_string(&message)?; - let deserialized: SamplingMessage = serde_json::from_str(&json)?; - assert_eq!(message, deserialized); - assert_eq!(message.role, Role::User); - - Ok(()) -} - -#[tokio::test] -async fn test_sampling_request_params() -> Result<()> { - let params = - CreateMessageRequestParams::new(vec![SamplingMessage::user_text("Hello, world!")], 100) - .with_model_preferences( - ModelPreferences::new() - .with_hints(vec![ModelHint::new("claude")]) - .with_cost_priority(0.5) - .with_speed_priority(0.8) - .with_intelligence_priority(0.7), - ) - .with_system_prompt("You are a helpful assistant.") - .with_temperature(0.7) - .with_stop_sequences(vec!["STOP".to_string()]) - .with_include_context(ContextInclusion::None) - .with_metadata(serde_json::json!({"test": "value"})); - - let json = serde_json::to_string(¶ms)?; - let deserialized: CreateMessageRequestParams = serde_json::from_str(&json)?; - assert_eq!(params, deserialized); - - assert_eq!(params.messages.len(), 1); - assert_eq!(params.max_tokens, 100); - assert_eq!(params.temperature, Some(0.7)); - - Ok(()) -} - -#[tokio::test] -async fn test_sampling_result_structure() -> Result<()> { - let result = CreateMessageResult::new( - SamplingMessage::assistant_text("The capital of France is Paris."), - "test-model".to_string(), - ) - .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN); - - let json = serde_json::to_string(&result)?; - let deserialized: CreateMessageResult = serde_json::from_str(&json)?; - assert_eq!(result, deserialized); - - assert_eq!(result.message.role, Role::Assistant); - assert_eq!(result.model, "test-model"); - assert_eq!( - result.stop_reason, - Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()) - ); - - Ok(()) -} - -#[tokio::test] -async fn test_sampling_context_inclusion_enum() -> Result<()> { - let test_cases = vec![ - (ContextInclusion::None, "none"), - (ContextInclusion::ThisServer, "thisServer"), - (ContextInclusion::AllServers, "allServers"), - ]; - - for (context, expected_json) in test_cases { - let json = serde_json::to_string(&context)?; - assert_eq!(json, format!("\"{}\"", expected_json)); - - let deserialized: ContextInclusion = serde_json::from_str(&json)?; - assert_eq!(context, deserialized); - } - - Ok(()) -} - -#[tokio::test] -async fn test_sampling_integration_with_test_handlers() -> Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - let server_handle = tokio::spawn(async move { - let server = TestServer::new().serve(server_transport).await?; - server.waiting().await?; - anyhow::Ok(()) - }); - - let handler = TestClientHandler::new(true, true); - let client = handler.clone().serve(client_transport).await?; - - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( - CreateMessageRequestParams::new( - vec![SamplingMessage::user_text("What is the capital of France?")], - 100, - ) - .with_include_context(ContextInclusion::ThisServer) - .with_model_preferences( - ModelPreferences::new() - .with_hints(vec![ModelHint::new("test-model")]) - .with_cost_priority(0.5) - .with_speed_priority(0.8) - .with_intelligence_priority(0.7), - ) - .with_system_prompt("You are a helpful assistant.") - .with_temperature(0.7), - )); - - let result = handler - .handle_request( - request.clone(), - RequestContext::new(NumberOrString::Number(1), client.peer().clone()), - ) - .await?; - - if let ClientResult::CreateMessageResult(result) = result { - assert_eq!(result.message.role, Role::Assistant); - assert_eq!(result.model, "test-model"); - assert_eq!( - result.stop_reason, - Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()) - ); - - let response_text = result - .message - .content - .first() - .unwrap() - .as_text() - .unwrap() - .text - .as_str(); - assert!( - response_text.contains("test context"), - "Response should include context for ThisServer inclusion" - ); - } else { - panic!("Expected CreateMessageResult"); - } - - client.cancel().await?; - server_handle.await??; - Ok(()) -} - -#[tokio::test] -async fn test_sampling_no_context_inclusion() -> Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - let server_handle = tokio::spawn(async move { - let server = TestServer::new().serve(server_transport).await?; - server.waiting().await?; - anyhow::Ok(()) - }); - - let handler = TestClientHandler::new(true, true); - let client = handler.clone().serve(client_transport).await?; - - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( - CreateMessageRequestParams::new(vec![SamplingMessage::user_text("Hello")], 50) - .with_include_context(ContextInclusion::None), - )); - - let result = handler - .handle_request( - request.clone(), - RequestContext::new(NumberOrString::Number(2), client.peer().clone()), - ) - .await?; - - if let ClientResult::CreateMessageResult(result) = result { - assert_eq!(result.message.role, Role::Assistant); - assert_eq!(result.model, "test-model"); - - let response_text = result - .message - .content - .first() - .unwrap() - .as_text() - .unwrap() - .text - .as_str(); - assert!( - !response_text.contains("test context"), - "Response should not include context for None inclusion" - ); - } else { - panic!("Expected CreateMessageResult"); - } - - client.cancel().await?; - server_handle.await??; - Ok(()) -} - -#[tokio::test] -async fn test_sampling_error_invalid_message_sequence() -> Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - let server_handle = tokio::spawn(async move { - let server = TestServer::new().serve(server_transport).await?; - server.waiting().await?; - anyhow::Ok(()) - }); - - let handler = TestClientHandler::new(true, true); - let client = handler.clone().serve(client_transport).await?; - - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( - CreateMessageRequestParams::new( - vec![SamplingMessage::assistant_text( - "I'm an assistant message without a user message", - )], - 50, - ) - .with_include_context(ContextInclusion::None), - )); - - let result = handler - .handle_request( - request.clone(), - RequestContext::new(NumberOrString::Number(3), client.peer().clone()), - ) - .await; - - assert!(result.is_err()); - - client.cancel().await?; - server_handle.await??; - Ok(()) -} - -#[tokio::test] -async fn test_tool_choice_serialization() -> Result<()> { - let auto = ToolChoice::auto(); - let json = serde_json::to_string(&auto)?; - assert!(json.contains("auto")); - let deserialized: ToolChoice = serde_json::from_str(&json)?; - assert_eq!(auto, deserialized); - - let required = ToolChoice::required(); - let json = serde_json::to_string(&required)?; - assert!(json.contains("required")); - let deserialized: ToolChoice = serde_json::from_str(&json)?; - assert_eq!(required, deserialized); - - let none = ToolChoice::none(); - let json = serde_json::to_string(&none)?; - assert!(json.contains("none")); - let deserialized: ToolChoice = serde_json::from_str(&json)?; - assert_eq!(none, deserialized); - - Ok(()) -} - -#[tokio::test] -async fn test_sampling_with_tools() -> Result<()> { - use std::sync::Arc; - - let tool = Tool::new( - "get_weather", - "Get the current weather for a location", - Arc::new( - serde_json::json!({ - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "The city and state, e.g. San Francisco, CA" - } - }, - "required": ["location"] - }) - .as_object() - .unwrap() - .clone(), - ), - ); - - let params = CreateMessageRequestParams::new( - vec![SamplingMessage::user_text( - "What's the weather in San Francisco?", - )], - 100, - ) - .with_tools(vec![tool]) - .with_tool_choice(ToolChoice::auto()); - - let json = serde_json::to_string(¶ms)?; - let deserialized: CreateMessageRequestParams = serde_json::from_str(&json)?; - - assert!(deserialized.tools.is_some()); - assert_eq!(deserialized.tools.as_ref().unwrap().len(), 1); - assert_eq!(deserialized.tools.as_ref().unwrap()[0].name, "get_weather"); - assert!(deserialized.tool_choice.is_some()); - - Ok(()) -} - -#[tokio::test] -async fn test_tool_use_content_serialization() -> Result<()> { - let tool_use = ToolUseContent::new( - "call_123", - "get_weather", - serde_json::json!({ - "location": "San Francisco, CA" - }) - .as_object() - .unwrap() - .clone(), - ); - - let json = serde_json::to_string(&tool_use)?; - let deserialized: ToolUseContent = serde_json::from_str(&json)?; - assert_eq!(tool_use, deserialized); - assert_eq!(deserialized.id, "call_123"); - assert_eq!(deserialized.name, "get_weather"); - - Ok(()) -} - -#[tokio::test] -async fn test_tool_result_content_serialization() -> Result<()> { - let tool_result = ToolResultContent::new( - "call_123", - vec![ContentBlock::text( - "The weather in San Francisco is 72°F and sunny.", - )], - ); - - let json = serde_json::to_string(&tool_result)?; - let deserialized: ToolResultContent = serde_json::from_str(&json)?; - assert_eq!(tool_result, deserialized); - assert_eq!(deserialized.tool_use_id, "call_123"); - assert!(!deserialized.content.is_empty()); - - Ok(()) -} - -#[test] -fn test_tool_result_content_requires_content() { - let raw = serde_json::json!({ - "toolUseId": "call_123" - }); - - let err = serde_json::from_value::(raw).unwrap_err(); - - assert!(err.to_string().contains("missing field `content`")); -} - -#[rstest] -#[case::array(serde_json::json!([{ "city": "SF", "temp": 72 }, { "city": "NY", "temp": 65 }]))] -#[case::string(serde_json::json!("sunny"))] -#[case::integer(serde_json::json!(42))] -#[case::float(serde_json::json!(3.14))] -#[case::boolean(serde_json::json!(true))] -fn tool_result_content_round_trips_non_object_structured_content( - #[case] structured: serde_json::Value, -) -> Result<()> { - let mut tool_result = ToolResultContent::new("call_123", vec![ContentBlock::text("x")]); - tool_result.structured_content = Some(structured); - - let json = serde_json::to_string(&tool_result)?; - let deserialized: ToolResultContent = serde_json::from_str(&json)?; - assert_eq!(tool_result, deserialized); - - Ok(()) -} - -// `null` is a valid JSON value, but `Option` deserializes JSON `null` -// as `None`, so `Some(Value::Null)` collapses to `None` on round-trip. -#[test] -fn tool_result_content_null_structured_content_round_trips_to_none() -> Result<()> { - let mut tool_result = ToolResultContent::new("call_123", vec![ContentBlock::text("x")]); - tool_result.structured_content = Some(serde_json::Value::Null); - - let json = serde_json::to_string(&tool_result)?; - let deserialized: ToolResultContent = serde_json::from_str(&json)?; - assert_eq!(deserialized.structured_content, None); - - Ok(()) -} - -#[tokio::test] -async fn test_sampling_message_with_tool_use() -> Result<()> { - let message = SamplingMessage::assistant_tool_use( - "call_123", - "get_weather", - serde_json::json!({ - "location": "San Francisco, CA" - }) - .as_object() - .unwrap() - .clone(), - ); - - let json = serde_json::to_string(&message)?; - let deserialized: SamplingMessage = serde_json::from_str(&json)?; - assert_eq!(message, deserialized); - assert_eq!(deserialized.role, Role::Assistant); - - let tool_use = deserialized.content.first().unwrap().as_tool_use().unwrap(); - assert_eq!(tool_use.name, "get_weather"); - - Ok(()) -} - -#[tokio::test] -async fn test_sampling_message_with_tool_result() -> Result<()> { - let message = - SamplingMessage::user_tool_result("call_123", vec![ContentBlock::text("72°F and sunny")]); - - let json = serde_json::to_string(&message)?; - let deserialized: SamplingMessage = serde_json::from_str(&json)?; - assert_eq!(message, deserialized); - assert_eq!(deserialized.role, Role::User); - - let tool_result = deserialized - .content - .first() - .unwrap() - .as_tool_result() - .unwrap(); - assert_eq!(tool_result.tool_use_id, "call_123"); - - Ok(()) -} - -#[tokio::test] -async fn test_create_message_result_tool_use_stop_reason() -> Result<()> { - let result = CreateMessageResult::new( - SamplingMessage::assistant_tool_use( - "call_123", - "get_weather", - serde_json::json!({ - "location": "San Francisco" - }) - .as_object() - .unwrap() - .clone(), - ), - "test-model".to_string(), - ) - .with_stop_reason(CreateMessageResult::STOP_REASON_TOOL_USE); - - let json = serde_json::to_string(&result)?; - let deserialized: CreateMessageResult = serde_json::from_str(&json)?; - assert_eq!(result, deserialized); - assert_eq!(deserialized.stop_reason, Some("toolUse".to_string())); - - Ok(()) -} - -#[tokio::test] -async fn test_sampling_capability() -> Result<()> { - let mut cap = SamplingCapability::default(); - cap.tools = Some(JsonObject::default()); - - let json = serde_json::to_string(&cap)?; - let deserialized: SamplingCapability = serde_json::from_str(&json)?; - assert_eq!(cap, deserialized); - assert!(deserialized.tools.is_some()); - assert!(deserialized.context.is_none()); - - let client_cap = ClientCapabilities::builder() - .enable_sampling() - .enable_sampling_tools() - .build(); - - assert!(client_cap.sampling.is_some()); - assert!(client_cap.sampling.as_ref().unwrap().tools.is_some()); - - Ok(()) -} - -#[tokio::test] -async fn test_backward_compat_sampling_message_deserialization() -> Result<()> { - let old_format_json = r#"{ - "role": "user", - "content": { - "type": "text", - "text": "Hello, world!" - } - }"#; - - let message: SamplingMessage = serde_json::from_str(old_format_json)?; - assert_eq!(message.role, Role::User); - let text = message.content.first().unwrap().as_text().unwrap(); - assert_eq!(text.text, "Hello, world!"); - - Ok(()) -} - -#[tokio::test] -async fn test_backward_compat_sampling_message_with_image() -> Result<()> { - let old_format_json = r#"{ - "role": "user", - "content": { - "type": "image", - "data": "base64data", - "mimeType": "image/png" - } - }"#; - - let message: SamplingMessage = serde_json::from_str(old_format_json)?; - assert_eq!(message.role, Role::User); - assert_eq!(message.content.len(), 1); - - Ok(()) -} - -#[tokio::test] -async fn test_backward_compat_sampling_capability_empty_object() -> Result<()> { - let empty_json = "{}"; - let cap: SamplingCapability = serde_json::from_str(empty_json)?; - assert!(cap.tools.is_none()); - assert!(cap.context.is_none()); - - let client_cap_json = r#"{"sampling": {}}"#; - let client_cap: ClientCapabilities = serde_json::from_str(client_cap_json)?; - assert!(client_cap.sampling.is_some()); - - Ok(()) -} - -#[tokio::test] -async fn test_content_to_sampling_message_content_conversion() -> Result<()> { - use std::convert::TryInto; - - let content = ContentBlock::text("Hello"); - let sampling_content: SamplingMessageContentBlock = - content.try_into().map_err(|e: &str| anyhow::anyhow!(e))?; - assert!(sampling_content.as_text().is_some()); - assert_eq!(sampling_content.as_text().unwrap().text, "Hello"); - - let content = ContentBlock::image("base64data", "image/png"); - let sampling_content: SamplingMessageContentBlock = - content.try_into().map_err(|e: &str| anyhow::anyhow!(e))?; - assert!(matches!( - sampling_content, - SamplingMessageContentBlock::Image(_) - )); - - Ok(()) -} - -#[tokio::test] -async fn test_content_to_sampling_content_conversion() -> Result<()> { - use std::convert::TryInto; - - let content = ContentBlock::text("Hello"); - let sampling_content: SamplingContent = - content.try_into().map_err(|e: &str| anyhow::anyhow!(e))?; - assert_eq!(sampling_content.len(), 1); - assert!(sampling_content.first().unwrap().as_text().is_some()); - - Ok(()) -} - -#[tokio::test] -async fn test_content_conversion_unsupported_variants() { - use std::convert::TryInto; - - use rmcp::model::ResourceContents; - - let resource_content = ContentBlock::resource(ResourceContents::TextResourceContents { - uri: "file:///test.txt".to_string(), - mime_type: Some("text/plain".to_string()), - text: "test".to_string(), - meta: None, - }); - - let result: Result = resource_content.try_into(); - assert!(result.is_err()); - assert_eq!( - result.unwrap_err(), - "Resource content is not supported in sampling messages" - ); -} - -#[tokio::test] -async fn test_validate_rejects_tool_use_in_user_message() { - let params = CreateMessageRequestParams::new( - vec![SamplingMessage::new( - Role::User, - SamplingMessageContentBlock::tool_use("call_1", "some_tool", Default::default()), - )], - 100, - ); - - let err = params.validate().unwrap_err(); - assert!( - err.contains("ToolUse content is only allowed in assistant messages"), - "unexpected error: {err}" - ); -} - -#[tokio::test] -async fn test_validate_rejects_tool_result_in_assistant_message() { - let params = CreateMessageRequestParams::new( - vec![SamplingMessage::new( - Role::Assistant, - SamplingMessageContentBlock::tool_result("call_1", vec![ContentBlock::text("result")]), - )], - 100, - ); - - let err = params.validate().unwrap_err(); - assert!( - err.contains("ToolResult content is only allowed in user messages"), - "unexpected error: {err}" - ); -} - -#[tokio::test] -async fn test_validate_rejects_mixed_content_with_tool_result() { - let params = CreateMessageRequestParams::new( - vec![SamplingMessage::new_multiple( - Role::User, - vec![ - SamplingMessageContentBlock::tool_result( - "call_1", - vec![ContentBlock::text("result")], - ), - SamplingMessageContentBlock::text("some extra text"), - ], - )], - 100, - ); - - let err = params.validate().unwrap_err(); - assert!( - err.contains("MUST NOT contain other content types"), - "unexpected error: {err}" - ); -} - -#[tokio::test] -async fn test_validate_rejects_unbalanced_tool_use_result() { - let params = CreateMessageRequestParams::new( - vec![ - SamplingMessage::user_text("Hello"), - SamplingMessage::assistant_tool_use("call_1", "some_tool", Default::default()), - ], - 100, - ); - - let err = params.validate().unwrap_err(); - assert!( - err.contains("not balanced with ToolResult"), - "unexpected error: {err}" - ); -} - -#[tokio::test] -async fn test_validate_rejects_tool_result_without_matching_use() { - let params = CreateMessageRequestParams::new( - vec![ - SamplingMessage::user_text("Hello"), - SamplingMessage::user_tool_result( - "nonexistent_call", - vec![ContentBlock::text("result")], - ), - ], - 100, - ); - - let err = params.validate().unwrap_err(); - assert!( - err.contains("has no matching ToolUse"), - "unexpected error: {err}" - ); -} - -#[tokio::test] -async fn test_validate_accepts_valid_tool_conversation() { - let params = CreateMessageRequestParams::new( - vec![ - SamplingMessage::user_text("What's the weather?"), - SamplingMessage::assistant_tool_use( - "call_1", - "get_weather", - serde_json::json!({"location": "SF"}) - .as_object() - .unwrap() - .clone(), - ), - SamplingMessage::user_tool_result("call_1", vec![ContentBlock::text("72°F and sunny")]), - SamplingMessage::assistant_text("It's 72°F and sunny in SF."), - ], - 100, - ); - - assert!(params.validate().is_ok()); -} - -#[tokio::test] -async fn test_create_message_result_validate_rejects_user_role() { - let result = CreateMessageResult::new( - SamplingMessage::user_text("This should not be a user message"), - "test-model".to_string(), - ) - .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN); - - let err = result.validate().unwrap_err(); - assert!( - err.contains("role must be 'assistant'"), - "unexpected error: {err}" - ); -} - -#[tokio::test] -async fn test_create_message_result_validate_accepts_assistant_role() { - let result = CreateMessageResult::new( - SamplingMessage::assistant_text("Hello!"), - "test-model".to_string(), - ) - .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN); - - assert!(result.validate().is_ok()); -} diff --git a/vendor/rmcp/tests/test_sep_2260_request_association.rs b/vendor/rmcp/tests/test_sep_2260_request_association.rs deleted file mode 100644 index d4e20e1e9..000000000 --- a/vendor/rmcp/tests/test_sep_2260_request_association.rs +++ /dev/null @@ -1,273 +0,0 @@ -#![cfg(all(feature = "server", feature = "client", not(feature = "local")))] -#![allow(deprecated)] - -use std::sync::{Arc, Mutex}; - -use rmcp::{ - ClientHandler, RoleClient, RoleServer, ServerHandler, ServiceError, ServiceExt, - model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ClientInfo, ContentBlock, - CreateMessageRequest, CreateMessageRequestParams, CreateMessageResult, ProtocolVersion, - SamplingMessage, ServerCapabilities, ServerInfo, ServerRequest, - }, - service::RequestContext, -}; -use serde_json::{Value, json}; -use tokio::{ - io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream, Lines, ReadHalf, WriteHalf}, - sync::oneshot, -}; - -#[derive(Clone)] -struct SamplingServer { - outside: Arc>>>>, -} - -impl ServerHandler for SamplingServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - } - - async fn call_tool( - &self, - request: CallToolRequestParams, - context: RequestContext, - ) -> Result { - let peer = context.peer.clone(); - let slot = self.outside.clone(); - - let use_generic = request.name == "sample_generic"; - tokio::spawn(async move { - let outside = if use_generic { - peer.send_request(ServerRequest::CreateMessageRequest( - CreateMessageRequest::new(CreateMessageRequestParams::new( - vec![SamplingMessage::user_text("standalone-generic")], - 16, - )), - )) - .await - .map(|_| ()) - } else { - peer.create_message(CreateMessageRequestParams::new( - vec![SamplingMessage::user_text("standalone")], - 16, - )) - .await - .map(|_| ()) - }; - if let Some(tx) = slot.lock().unwrap().take() { - let _ = tx.send(outside); - } - }); - - let nested = context - .peer - .create_message(CreateMessageRequestParams::new( - vec![SamplingMessage::user_text("nested")], - 16, - )) - .await; - nested.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; - Ok(CallToolResult::success(vec![ContentBlock::text("ok")]).into()) - } -} - -#[derive(Clone)] -struct SamplingClient; - -impl ClientHandler for SamplingClient { - async fn create_message( - &self, - _params: CreateMessageRequestParams, - _context: RequestContext, - ) -> Result { - Ok(CreateMessageResult::new( - SamplingMessage::assistant_text("pong"), - "test-model".to_string(), - ) - .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN)) - } - - fn get_info(&self) -> ClientInfo { - let mut info = ClientInfo::default(); - info.protocol_version = ProtocolVersion::V_2026_07_28; - info - } -} - -#[tokio::test] -async fn nested_sampling_allowed_standalone_rejected() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let (tx, rx) = oneshot::channel(); - let server = SamplingServer { - outside: Arc::new(Mutex::new(Some(tx))), - }; - let server_handle = tokio::spawn(async move { - let running = server.serve(server_transport).await?; - running.waiting().await?; - anyhow::Ok(()) - }); - - let client = SamplingClient.serve(client_transport).await?; - - let result = client - .peer() - .call_tool(CallToolRequestParams::new("sample")) - .await?; - assert_eq!( - result.content.first().unwrap().as_text().unwrap().text, - "ok" - ); - - let outside = rx.await?; - assert!(matches!(outside, Err(ServiceError::McpError(_)))); - - client.cancel().await?; - let _ = server_handle.await?; - Ok(()) -} - -#[tokio::test] -async fn generic_send_request_bypass_rejected() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let (tx, rx) = oneshot::channel(); - let server = SamplingServer { - outside: Arc::new(Mutex::new(Some(tx))), - }; - let server_handle = tokio::spawn(async move { - let running = server.serve(server_transport).await?; - running.waiting().await?; - anyhow::Ok(()) - }); - - let client = SamplingClient.serve(client_transport).await?; - - let result = client - .peer() - .call_tool(CallToolRequestParams::new("sample_generic")) - .await?; - assert_eq!( - result.content.first().unwrap().as_text().unwrap().text, - "ok" - ); - - let outside = rx.await?; - assert!( - matches!(outside, Err(ServiceError::McpError(_))), - "generic send_request must not bypass SEP-2260 enforcement" - ); - - client.cancel().await?; - let _ = server_handle.await?; - Ok(()) -} - -// A compliant rmcp server cannot produce an unassociated server-to-client -// request at >= 2026-07-28 (send-side enforcement blocks it), so the client's -// receive-side enforcement is exercised with a raw JSON-RPC server. -type RawServer = ( - Lines>>, - WriteHalf, -); - -async fn raw_initialize(io: DuplexStream, protocol_version: &str) -> anyhow::Result { - let (read, mut write) = tokio::io::split(io); - let mut lines = BufReader::new(read).lines(); - let init: Value = serde_json::from_str(&lines.next_line().await?.expect("initialize request"))?; - assert_eq!(init["method"], "initialize"); - let response = json!({ - "jsonrpc": "2.0", - "id": init["id"], - "result": { - "protocolVersion": protocol_version, - "capabilities": {}, - "serverInfo": { "name": "raw-server", "version": "0.0.0" } - } - }); - write.write_all(format!("{response}\n").as_bytes()).await?; - let initialized: Value = - serde_json::from_str(&lines.next_line().await?.expect("initialized notification"))?; - assert_eq!(initialized["method"], "notifications/initialized"); - Ok((lines, write)) -} - -async fn raw_request(server: &mut RawServer, request: Value) -> anyhow::Result { - let (lines, write) = server; - write.write_all(format!("{request}\n").as_bytes()).await?; - Ok(serde_json::from_str( - &lines.next_line().await?.expect("response"), - )?) -} - -fn raw_sampling_request(id: u32) -> Value { - json!({ - "jsonrpc": "2.0", - "id": id, - "method": "sampling/createMessage", - "params": { - "messages": [{ "role": "user", "content": { "type": "text", "text": "hi" } }], - "maxTokens": 16 - } - }) -} - -#[tokio::test] -async fn unassociated_server_request_rejected_with_invalid_params() -> anyhow::Result<()> { - let (client_io, server_io) = tokio::io::duplex(4096); - let raw = tokio::spawn(async move { - let mut server = raw_initialize(server_io, "2026-07-28").await?; - raw_request(&mut server, raw_sampling_request(100)).await - }); - - let client = SamplingClient.serve(client_io).await?; - let response = raw.await??; - assert_eq!( - response["error"]["code"], -32602, - "SEP-2260: unassociated server-to-client request must be rejected with invalid params, got {response}" - ); - - client.cancel().await?; - Ok(()) -} - -#[tokio::test] -async fn unassociated_server_request_allowed_on_legacy_protocol() -> anyhow::Result<()> { - let (client_io, server_io) = tokio::io::duplex(4096); - let raw = tokio::spawn(async move { - let mut server = raw_initialize(server_io, "2025-11-25").await?; - raw_request(&mut server, raw_sampling_request(100)).await - }); - - let client = SamplingClient.serve(client_io).await?; - let response = raw.await??; - assert_eq!( - response["result"]["model"], "test-model", - "pre-2026-07-28 peers keep the permissive behavior, got {response}" - ); - - client.cancel().await?; - Ok(()) -} - -#[tokio::test] -async fn unassociated_ping_allowed() -> anyhow::Result<()> { - let (client_io, server_io) = tokio::io::duplex(4096); - let raw = tokio::spawn(async move { - let mut server = raw_initialize(server_io, "2026-07-28").await?; - raw_request( - &mut server, - json!({ "jsonrpc": "2.0", "id": 101, "method": "ping" }), - ) - .await - }); - - let client = SamplingClient.serve(client_io).await?; - let response = raw.await??; - assert!( - response.get("error").is_none(), - "SEP-2260 excepts ping from request association, got {response}" - ); - - client.cancel().await?; - Ok(()) -} diff --git a/vendor/rmcp/tests/test_sep_2260_stream_enforcement.rs b/vendor/rmcp/tests/test_sep_2260_stream_enforcement.rs deleted file mode 100644 index 36595e05d..000000000 --- a/vendor/rmcp/tests/test_sep_2260_stream_enforcement.rs +++ /dev/null @@ -1,309 +0,0 @@ -//! SEP-2260 follow-up (#1033): stream-based receive-side enforcement. -//! -//! Scripted streamable HTTP "server": answers a legacy initialize with -//! protocol 2026-07-28 AND a session id. That combination is NOT -//! spec-compliant: the 2026-07-28 revision removes protocol-level sessions -//! and the standalone GET stream (SEP-2567; transports spec: "do not mint -//! or echo session IDs"). Receive-side enforcement (#1033) exists precisely -//! to protect the client from non-conforming servers, and rmcp's client -//! tolerates the session id and opens the standalone GET stream — so this -//! is the reachable path where the client has BOTH a GET stream and strict -//! SEP-2260 enforcement. -#![cfg(all( - feature = "client", - feature = "transport-streamable-http-client", - not(feature = "local") -))] -#![expect( - deprecated, - reason = "Sampling is deprecated by SEP-2577 but remains the canonical restricted request" -)] - -use std::{collections::HashMap, sync::Arc, time::Duration}; - -use futures::{StreamExt, stream::BoxStream}; -use http::{HeaderName, HeaderValue}; -use rmcp::{ - ClientHandler, - model::{ - ClientInfo, ClientJsonRpcMessage, CreateMessageRequestParams, CreateMessageResult, - ProtocolVersion, SamplingMessage, ServerCapabilities, ServerInfo, ServerJsonRpcMessage, - }, - service::{ClientLifecycleMode, RequestContext, RoleClient, serve_client_with_lifecycle}, - transport::streamable_http_client::{ - StreamableHttpClient, StreamableHttpClientTransport, StreamableHttpClientTransportConfig, - StreamableHttpError, StreamableHttpPostResponse, - }, -}; -use serde_json::{Value, json}; -use sse_stream::{Error as SseError, Sse}; -use tokio::sync::{Mutex, mpsc}; - -fn to_sse(message: Value) -> Result { - Ok(Sse { - event: None, - data: Some(message.to_string()), - id: None, - retry: None, - }) -} - -fn message_stream(rx: mpsc::Receiver) -> BoxStream<'static, Result> { - tokio_stream::wrappers::ReceiverStream::new(rx) - .map(to_sse) - .boxed() -} - -/// Scripted server: initialize -> JSON init result (2026-07-28 + session); -/// first non-initialize request POST -> SSE stream fed by `post_stream`; -/// everything else -> Accepted. Every message the client POSTs is forwarded -/// to `posted`. -#[derive(Clone)] -struct ScriptedServer { - get_stream: Arc>>>, - post_stream: Arc>>>, - posted: mpsc::UnboundedSender, -} - -impl StreamableHttpClient for ScriptedServer { - type Error = std::io::Error; - - async fn post_message( - &self, - _uri: Arc, - message: ClientJsonRpcMessage, - _session_id: Option>, - _auth_header: Option, - _custom_headers: HashMap, - ) -> Result> { - let value = serde_json::to_value(&message).expect("serialize client message"); - // Receiver drop is normal at test teardown; never panic in the transport task. - let _ = self.posted.send(value.clone()); - if value["method"] == "initialize" { - let mut info = ServerInfo::new(ServerCapabilities::default()); - info.protocol_version = ProtocolVersion::V_2026_07_28; - let response = ServerJsonRpcMessage::response( - rmcp::model::ServerResult::InitializeResult(info), - serde_json::from_value(value["id"].clone()).expect("request id"), - ); - return Ok(StreamableHttpPostResponse::Json( - response, - Some("scripted-session".into()), - )); - } - if matches!(message, ClientJsonRpcMessage::Request(_)) { - // Fail as a transport error rather than panicking: this code runs - // in the transport task, where a panic is swallowed and shows up - // only as an opaque timeout in the test. - let rx = self.post_stream.lock().await.take().ok_or_else(|| { - StreamableHttpError::Client(std::io::Error::other( - "scripted server expects exactly one non-initialize request POST", - )) - })?; - return Ok(StreamableHttpPostResponse::Sse(message_stream(rx), None)); - } - Ok(StreamableHttpPostResponse::Accepted) - } - - async fn delete_session( - &self, - _uri: Arc, - _session_id: Arc, - _auth_header: Option, - _custom_headers: HashMap, - ) -> Result<(), StreamableHttpError> { - Ok(()) - } - - async fn get_stream( - &self, - _uri: Arc, - _session_id: Option>, - _last_event_id: Option, - _auth_header: Option, - _custom_headers: HashMap, - ) -> Result>, StreamableHttpError> { - match self.get_stream.lock().await.take() { - Some(rx) => Ok(message_stream(rx)), - // Reconnect after the scripted stream ends: stay silent. - None => Ok(futures::stream::pending().boxed()), - } - } -} - -#[derive(Clone)] -struct SamplingClient { - // Every invocation is recorded here. Tests assert on the receiver (the - // negative test asserts it stays empty); a panic in this handler would - // run in a spawned task and be silently swallowed. - sampled: mpsc::UnboundedSender, -} - -impl ClientHandler for SamplingClient { - async fn create_message( - &self, - params: CreateMessageRequestParams, - _context: RequestContext, - ) -> Result { - let _ = self.sampled.send(params); - Ok(CreateMessageResult::new( - SamplingMessage::assistant_text("pong"), - "test-model".to_string(), - )) - } - - fn get_info(&self) -> ClientInfo { - ClientInfo::default() - } -} - -fn sampling_request(id: u32) -> Value { - json!({ - "jsonrpc": "2.0", - "id": id, - "method": "sampling/createMessage", - "params": { - "messages": [{ "role": "user", "content": { "type": "text", "text": "hi" } }], - "maxTokens": 16 - } - }) -} - -fn tools_list_response(id: &Value) -> Value { - json!({ "jsonrpc": "2.0", "id": id, "result": { "tools": [] } }) -} - -async fn next_posted(posted: &mut mpsc::UnboundedReceiver) -> Value { - tokio::time::timeout(Duration::from_secs(5), posted.recv()) - .await - .expect("posted message within 5s") - .expect("channel open") -} - -struct Harness { - client: rmcp::service::RunningService, - /// Every message the client POSTs to the scripted server. - posted: mpsc::UnboundedReceiver, - /// Feeds the standalone GET stream. - get_tx: mpsc::Sender, - /// Feeds the SSE stream of the in-flight tools/list POST. - post_tx: mpsc::Sender, - /// In-flight tools/list call (response withheld until the test releases it). - call: tokio::task::JoinHandle>, - tools_list_id: Value, - /// Sampling params seen by the client handler. - sampled: mpsc::UnboundedReceiver, -} - -/// Drive startup + one in-flight tools/list. -async fn setup() -> Harness { - let (get_tx, get_rx) = mpsc::channel(8); - let (post_tx, post_rx) = mpsc::channel(8); - let (posted_tx, mut posted_rx) = mpsc::unbounded_channel(); - let (sampled_tx, sampled_rx) = mpsc::unbounded_channel(); - let server = ScriptedServer { - get_stream: Arc::new(Mutex::new(Some(get_rx))), - post_stream: Arc::new(Mutex::new(Some(post_rx))), - posted: posted_tx, - }; - let transport = StreamableHttpClientTransport::with_client( - server, - StreamableHttpClientTransportConfig::with_uri("http://scripted/mcp"), - ); - let client = serve_client_with_lifecycle( - SamplingClient { - sampled: sampled_tx, - }, - transport, - ClientLifecycleMode::Initialize, - ) - .await - .expect("initialize against scripted server"); - - // initialize + notifications/initialized already posted during startup. - assert_eq!(next_posted(&mut posted_rx).await["method"], "initialize"); - assert_eq!( - next_posted(&mut posted_rx).await["method"], - "notifications/initialized" - ); - - // Unrelated outbound request, kept in flight (response withheld). - let peer = client.peer().clone(); - let call = tokio::spawn(async move { peer.list_tools(None).await }); - let tools_list = next_posted(&mut posted_rx).await; - assert_eq!(tools_list["method"], "tools/list"); - let tools_list_id = tools_list["id"].clone(); - - Harness { - client, - posted: posted_rx, - get_tx, - post_tx, - call, - tools_list_id, - sampled: sampled_rx, - } -} - -/// #1033 scenario 1: a restricted request on the standalone GET stream while -/// an unrelated outbound request is in flight must be rejected with -32602. -/// (The coarse check from #1029 incorrectly accepted this.) -#[tokio::test] -async fn restricted_request_on_get_stream_rejected_while_unrelated_request_in_flight() --> anyhow::Result<()> { - let mut h = setup().await; - - h.get_tx.send(sampling_request(100)).await?; - - let rejection = next_posted(&mut h.posted).await; - assert_eq!( - rejection["id"], 100, - "reply to the sampling request: {rejection}" - ); - assert_eq!( - rejection["error"]["code"], -32602, - "SEP-2260: GET-stream request must be rejected even with an unrelated \ - request in flight, got {rejection}" - ); - - h.post_tx - .send(tools_list_response(&h.tools_list_id)) - .await?; - tokio::time::timeout(Duration::from_secs(5), h.call).await???; - // Rejected means rejected: the handler must not ALSO have been invoked. - assert!( - h.sampled.try_recv().is_err(), - "handler must not see the rejected sampling request" - ); - tokio::time::timeout(Duration::from_secs(5), h.client.cancel()).await??; - Ok(()) -} - -/// Positive twin: the same restricted request arriving on the SSE stream of -/// the originating POST is dispatched to the handler and answered. -#[tokio::test] -async fn restricted_request_on_originating_post_stream_is_dispatched() -> anyhow::Result<()> { - let mut h = setup().await; - - h.post_tx.send(sampling_request(200)).await?; - - let response = next_posted(&mut h.posted).await; - assert_eq!( - response["id"], 200, - "reply to the sampling request: {response}" - ); - assert_eq!( - response["result"]["model"], "test-model", - "request on the originating POST stream must reach the handler, got {response}" - ); - tokio::time::timeout(Duration::from_secs(5), h.sampled.recv()) - .await? - .expect("handler invoked"); - - h.post_tx - .send(tools_list_response(&h.tools_list_id)) - .await?; - tokio::time::timeout(Duration::from_secs(5), h.call).await???; - tokio::time::timeout(Duration::from_secs(5), h.client.cancel()).await??; - Ok(()) -} diff --git a/vendor/rmcp/tests/test_sep_2260_stream_routing.rs b/vendor/rmcp/tests/test_sep_2260_stream_routing.rs deleted file mode 100644 index 824ef0c78..000000000 --- a/vendor/rmcp/tests/test_sep_2260_stream_routing.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! SEP-2260 end-to-end: in-handler server→client requests ride the originating -//! POST's SSE stream, never the standalone GET stream. -#![cfg(not(feature = "local"))] - -use std::{collections::BTreeMap, sync::Arc, time::Duration}; - -use futures::StreamExt; -use rmcp::{ - ErrorData as McpError, RoleServer, ServerHandler, - model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ElicitRequestParams, - ElicitationSchema, ServerCapabilities, ServerInfo, - }, - service::RequestContext, - transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, - }, -}; -use serde_json::json; -use tokio_util::sync::CancellationToken; - -#[derive(Clone)] -struct ElicitingServer; - -impl ServerHandler for ElicitingServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - } - - async fn call_tool( - &self, - _request: CallToolRequestParams, - context: RequestContext, - ) -> Result { - // Never answered: the test only checks the request is emitted on the right stream. - let _ = context - .peer - .create_elicitation(ElicitRequestParams::FormElicitationParams { - meta: None, - message: "need input".to_string(), - requested_schema: ElicitationSchema::new(BTreeMap::new()), - }) - .await; - Ok(CallToolResult::success(vec![ContentBlock::text("done")]).into()) - } -} - -async fn start_server(ct: CancellationToken) -> String { - let service = StreamableHttpService::new( - move || Ok(ElicitingServer), - Arc::new(LocalSessionManager::default()), - StreamableHttpServerConfig::default().with_cancellation_token(ct.child_token()), - ); - let router = axum::Router::new().nest_service("/mcp", service); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!( - "http://127.0.0.1:{}/mcp", - listener.local_addr().unwrap().port() - ); - let ct = ct.clone(); - tokio::spawn(async move { - axum::serve(listener, router) - .with_graceful_shutdown(async move { ct.cancelled().await }) - .await - .unwrap(); - }); - tokio::time::sleep(Duration::from_millis(100)).await; - url -} - -/// Read an SSE byte stream until `needle` appears or timeout. -async fn sse_contains(resp: reqwest::Response, needle: &str, timeout: Duration) -> bool { - let mut stream = resp.bytes_stream(); - tokio::time::timeout(timeout, async { - let mut buffer = String::new(); - while let Some(Ok(chunk)) = stream.next().await { - buffer.push_str(&String::from_utf8_lossy(&chunk)); - if buffer.contains(needle) { - return true; - } - } - false - }) - .await - .unwrap_or(false) -} - -#[tokio::test] -async fn elicitation_rides_originating_post_stream_not_standalone_get() { - let ct = CancellationToken::new(); - let url = start_server(ct.clone()).await; - let client = reqwest::Client::new(); - - // 2025-11-25 is the latest session-carrying version; SEP-2567 serves - // 2026-07-28+ statelessly, with no standalone GET stream to test against. - let resp = client - .post(&url) - .header("Accept", "text/event-stream, application/json") - .header("Content-Type", "application/json") - .json(&json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": { - "protocolVersion": "2025-11-25", - "capabilities": { "elicitation": {} }, - "clientInfo": { "name": "test-client", "version": "1.0.0" } - } - })) - .send() - .await - .unwrap(); - assert!(resp.status().is_success()); - let session_id = resp - .headers() - .get("Mcp-Session-Id") - .expect("session id") - .to_str() - .unwrap() - .to_string(); - - client - .post(&url) - .header("Accept", "text/event-stream, application/json") - .header("Content-Type", "application/json") - .header("Mcp-Session-Id", &session_id) - .json(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})) - .send() - .await - .unwrap(); - - // Standalone GET stream — must NEVER carry the elicitation request. - let get_stream = client - .get(&url) - .header("Accept", "text/event-stream") - .header("Mcp-Session-Id", &session_id) - .send() - .await - .unwrap(); - assert_eq!(get_stream.status(), 200); - - // The in-handler elicitation must appear on this tools/call SSE stream. - let post_stream = client - .post(&url) - .header("Accept", "text/event-stream, application/json") - .header("Content-Type", "application/json") - .header("Mcp-Session-Id", &session_id) - .json(&json!({ - "jsonrpc": "2.0", "id": 2, "method": "tools/call", - "params": { "name": "ask", "arguments": {} } - })) - .send() - .await - .unwrap(); - assert!(post_stream.status().is_success()); - - assert!( - sse_contains(post_stream, "elicitation/create", Duration::from_secs(5)).await, - "elicitation request must be delivered on the originating POST SSE stream" - ); - assert!( - !sse_contains(get_stream, "elicitation/create", Duration::from_millis(500)).await, - "standalone GET stream must not carry the elicitation request" - ); - - ct.cancel(); -} diff --git a/vendor/rmcp/tests/test_server_discover.rs b/vendor/rmcp/tests/test_server_discover.rs deleted file mode 100644 index f2ca3df20..000000000 --- a/vendor/rmcp/tests/test_server_discover.rs +++ /dev/null @@ -1,255 +0,0 @@ -use rmcp::model::{ - ClientCapabilities, ClientJsonRpcMessage, ClientRequest, DiscoverResult, ErrorCode, ErrorData, - JsonRpcRequest, JsonRpcResponse, ProtocolVersion, ServerJsonRpcMessage, ServerResult, -}; -use serde_json::json; - -#[test] -fn discover_request_deserializes_with_request_meta() { - let message: ClientJsonRpcMessage = serde_json::from_value(json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "server/discover", - "params": { - "_meta": { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientInfo": { - "name": "test-client", - "version": "1.0.0" - }, - "io.modelcontextprotocol/clientCapabilities": {} - } - } - })) - .expect("discover request should deserialize"); - - let ClientJsonRpcMessage::Request(JsonRpcRequest { request, .. }) = message else { - panic!("expected request"); - }; - let ClientRequest::DiscoverRequest(request) = request else { - panic!("expected discover request"); - }; - - assert_eq!( - request - .extensions - .get::() - .and_then(|meta| meta.protocol_version()), - Some(ProtocolVersion::V_2026_07_28) - ); -} - -#[test] -fn discover_result_deserializes_to_typed_variant() { - let message: ServerJsonRpcMessage = serde_json::from_value(json!({ - "jsonrpc": "2.0", - "id": 1, - "result": { - "resultType": "complete", - "supportedVersions": ["2025-11-25", "2026-07-28"], - "capabilities": { "tools": {} }, - "serverInfo": { - "name": "test-server", - "version": "1.0.0" - }, - "ttlMs": 0, - "cacheScope": "private" - } - })) - .expect("discover result should deserialize"); - - let ServerJsonRpcMessage::Response(JsonRpcResponse { result, .. }) = message else { - panic!("expected response"); - }; - let ServerResult::DiscoverResult(DiscoverResult { - supported_versions, .. - }) = result - else { - panic!("expected discover result"); - }; - - assert_eq!( - supported_versions, - vec![ProtocolVersion::V_2025_11_25, ProtocolVersion::V_2026_07_28] - ); -} - -#[test] -fn discover_result_accepts_server_info_in_namespaced_metadata() { - let message: ServerJsonRpcMessage = serde_json::from_value(json!({ - "jsonrpc": "2.0", - "id": 1, - "result": { - "resultType": "complete", - "supportedVersions": ["2026-07-28"], - "capabilities": {}, - "ttlMs": 0, - "cacheScope": "private", - "_meta": { - "io.modelcontextprotocol/serverInfo": { - "name": "conformance-mock-server", - "version": "1.0.0" - }, - "unrelated": { "preserved": true } - } - } - })) - .expect("discovery response with namespaced server info should deserialize"); - - let ServerJsonRpcMessage::Response(JsonRpcResponse { result, .. }) = message else { - panic!("expected response"); - }; - let ServerResult::DiscoverResult(result) = result else { - panic!("expected discovery response, not a tool-call result"); - }; - - let server_info = result.server_info().expect("server info should be present"); - assert_eq!(server_info.name, "conformance-mock-server"); - assert_eq!(server_info.version, "1.0.0"); - - let metadata = result.meta.expect("discovery metadata should be preserved"); - assert_eq!( - metadata.0.get("io.modelcontextprotocol/serverInfo"), - Some(&json!({ - "name": "conformance-mock-server", - "version": "1.0.0" - })) - ); - assert_eq!( - metadata.0.get("unrelated"), - Some(&json!({ "preserved": true })) - ); -} - -#[test] -fn discover_result_serializes_server_info_in_namespaced_metadata() { - let mut result = DiscoverResult::new( - vec![ProtocolVersion::V_2026_07_28], - rmcp::model::ServerCapabilities::default(), - ); - result.meta = Some(rmcp::model::MetaObject( - json!({ - "io.modelcontextprotocol/serverInfo": { - "name": "stale-server", - "version": "0.1.0" - }, - "unrelated": { "preserved": true } - }) - .as_object() - .expect("metadata is an object") - .clone(), - )); - result.set_server_info(rmcp::model::Implementation::new("test-server", "1.0.0")); - - let serialized = serde_json::to_value(result).expect("serialize discovery result"); - assert!(serialized.get("serverInfo").is_none()); - assert_eq!( - serialized["_meta"]["io.modelcontextprotocol/serverInfo"], - json!({ - "name": "test-server", - "version": "1.0.0" - }) - ); - assert_eq!( - serialized["_meta"]["unrelated"], - json!({ "preserved": true }) - ); -} - -#[test] -fn discover_result_ignores_legacy_top_level_server_info() { - let result: DiscoverResult = serde_json::from_value(json!({ - "resultType": "complete", - "supportedVersions": ["2026-07-28"], - "capabilities": {}, - "serverInfo": { - "name": "top-level-server", - "version": "2.0.0" - }, - "ttlMs": 0, - "cacheScope": "private", - "_meta": { - "io.modelcontextprotocol/serverInfo": { - "name": "metadata-server", - "version": "1.0.0" - }, - "unrelated": true - } - })) - .expect("top-level server info should remain supported"); - - let server_info = result - .server_info() - .expect("namespaced server info is present"); - assert_eq!(server_info.name, "metadata-server"); - assert_eq!(server_info.version, "1.0.0"); - assert_eq!( - result - .meta - .as_ref() - .and_then(|metadata| metadata.0.get("unrelated")), - Some(&json!(true)) - ); -} - -#[test] -fn discover_result_allows_missing_or_malformed_optional_server_info() { - let result = json!({ - "resultType": "complete", - "supportedVersions": ["2026-07-28"], - "capabilities": {}, - "ttlMs": 0, - "cacheScope": "private", - "_meta": { "unrelated": true } - }); - - let result = - serde_json::from_value::(result).expect("server info metadata is optional"); - assert_eq!(result.server_info(), None); - - let malformed_server_info = json!({ - "resultType": "complete", - "supportedVersions": ["2026-07-28"], - "capabilities": {}, - "ttlMs": 0, - "cacheScope": "private", - "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "missing-version" } } - }); - - let result = serde_json::from_value::(malformed_server_info) - .expect("opaque metadata should not prevent deserialization"); - assert_eq!(result.server_info(), None); -} - -#[test] -fn unsupported_protocol_version_error_matches_draft_schema() { - let error = ErrorData::unsupported_protocol_version( - ProtocolVersion::V_2026_07_28, - &[ProtocolVersion::V_2025_11_25], - ); - - assert_eq!(error.code, ErrorCode::UNSUPPORTED_PROTOCOL_VERSION); - assert_eq!( - error.data, - Some(json!({ - "requested": "2026-07-28", - "supported": ["2025-11-25"] - })) - ); -} - -#[test] -fn missing_required_capability_error_matches_draft_schema() { - let required = ClientCapabilities::builder().enable_elicitation().build(); - let error = ErrorData::missing_required_client_capability(required); - - assert_eq!(error.code, ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY); - assert_eq!( - error.data, - Some(json!({ - "requiredCapabilities": { - "elicitation": {} - } - })) - ); -} diff --git a/vendor/rmcp/tests/test_server_discover_client.rs b/vendor/rmcp/tests/test_server_discover_client.rs deleted file mode 100644 index 3b309ddca..000000000 --- a/vendor/rmcp/tests/test_server_discover_client.rs +++ /dev/null @@ -1,77 +0,0 @@ -#![cfg(all(feature = "client", not(feature = "local")))] - -use rmcp::{ - ClientHandler, ServerHandler, ServiceExt, - model::{ - ClientCapabilities, Implementation, ProtocolVersion, RequestMetaObject, ServerCapabilities, - ServerInfo, - }, - select_protocol_version, -}; - -#[derive(Clone, Default)] -struct DiscoveryServer; - -impl ServerHandler for DiscoveryServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - .with_server_info(Implementation::new("discovery-server", "1.0.0")) - } -} - -#[derive(Clone, Default)] -struct DiscoveryClient; - -impl ClientHandler for DiscoveryClient {} - -#[test] -fn select_protocol_version_uses_client_preference_order() { - let selected = select_protocol_version( - &[ProtocolVersion::V_2026_07_28, ProtocolVersion::V_2025_11_25], - &[ProtocolVersion::V_2025_11_25, ProtocolVersion::V_2026_07_28], - ); - - assert_eq!(selected, Some(ProtocolVersion::V_2026_07_28)); -} - -#[test] -fn select_protocol_version_returns_none_without_overlap() { - let selected = select_protocol_version( - &[ProtocolVersion::V_2026_07_28], - &[ProtocolVersion::V_2025_11_25], - ); - - assert_eq!(selected, None); -} - -#[tokio::test] -async fn client_discover_helper_returns_typed_result() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - tokio::spawn(async move { - let _ = DiscoveryServer - .serve(server_transport) - .await - .expect("server should start") - .waiting() - .await; - }); - let client = DiscoveryClient - .serve(client_transport) - .await - .expect("client should connect"); - let mut meta = RequestMetaObject::new(); - meta.set_protocol_version(ProtocolVersion::V_2026_07_28); - meta.set_client_info(Implementation::new("discovery-client", "1.0.0")); - meta.set_client_capabilities(ClientCapabilities::default()); - - let result = client - .discover(meta) - .await - .expect("discover should succeed"); - - assert_eq!( - result.server_info(), - Some(Implementation::new("discovery-server", "1.0.0")) - ); - client.cancel().await.expect("client should cancel"); -} diff --git a/vendor/rmcp/tests/test_server_discover_http.rs b/vendor/rmcp/tests/test_server_discover_http.rs deleted file mode 100644 index 7a42f06a0..000000000 --- a/vendor/rmcp/tests/test_server_discover_http.rs +++ /dev/null @@ -1,380 +0,0 @@ -#![cfg(all( - not(feature = "local"), - feature = "reqwest", - feature = "transport-streamable-http-server" -))] - -use std::borrow::Cow; - -use rmcp::{ - ServerHandler, - model::{Implementation, ProtocolVersion, ServerCapabilities, ServerInfo}, - transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, - }, -}; -use serde_json::json; -use tokio_util::sync::CancellationToken; - -#[derive(Clone, Default)] -struct DiscoveryServer; - -impl ServerHandler for DiscoveryServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - .with_server_info(Implementation::new("discovery-server", "1.0.0")) - .with_instructions("Use the tools carefully") - } - - fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { - Cow::Borrowed(&[ProtocolVersion::V_2025_11_25]) - } -} - -async fn spawn_server(json_response: bool) -> (reqwest::Client, String, CancellationToken) { - spawn_server_with_legacy_session_mode(json_response, false).await -} - -async fn spawn_server_with_legacy_session_mode( - json_response: bool, - legacy_session_mode: bool, -) -> (reqwest::Client, String, CancellationToken) { - let cancellation_token = CancellationToken::new(); - let config = StreamableHttpServerConfig::default() - .with_legacy_session_mode(legacy_session_mode) - .with_json_response(json_response) - .with_sse_keep_alive(None) - .with_cancellation_token(cancellation_token.clone()); - let service: StreamableHttpService = - StreamableHttpService::new(|| Ok(DiscoveryServer), Default::default(), config); - let router = axum::Router::new().nest_service("/mcp", service); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("listener should bind"); - let address = listener.local_addr().expect("listener should have address"); - - tokio::spawn({ - let cancellation_token = cancellation_token.clone(); - async move { - let _ = axum::serve(listener, router) - .with_graceful_shutdown(async move { - cancellation_token.cancelled_owned().await; - }) - .await; - } - }); - - ( - reqwest::Client::new(), - format!("http://{address}/mcp"), - cancellation_token, - ) -} - -fn discover_body(version: Option<&str>) -> serde_json::Value { - let meta = version.map(|version| { - json!({ - "io.modelcontextprotocol/protocolVersion": version, - "io.modelcontextprotocol/clientInfo": { - "name": "test-client", - "version": "1.0.0" - }, - "io.modelcontextprotocol/clientCapabilities": {} - }) - }); - json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "server/discover", - "params": meta.map(|meta| json!({ "_meta": meta })).unwrap_or_else(|| json!({})) - }) -} - -async fn post_discover( - client: &reqwest::Client, - url: &str, - header_version: &str, - body_version: Option<&str>, -) -> reqwest::Response { - client - .post(url) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("MCP-Protocol-Version", header_version) - .header("Mcp-Method", "server/discover") - .json(&discover_body(body_version)) - .send() - .await - .expect("discover request should send") -} - -#[tokio::test] -async fn discover_returns_server_metadata_without_session() { - let (client, url, cancellation_token) = spawn_server(true).await; - - let response = post_discover(&client, &url, "2025-11-25", Some("2025-11-25")).await; - - assert_eq!(response.status(), 200); - let body: serde_json::Value = response.json().await.expect("response should be JSON"); - assert_eq!( - body["result"], - json!({ - "resultType": "complete", - "supportedVersions": ["2025-11-25"], - "capabilities": { "tools": {} }, - "_meta": { - "io.modelcontextprotocol/serverInfo": { - "name": "discovery-server", - "version": "1.0.0" - } - }, - "instructions": "Use the tools carefully", - "ttlMs": 0, - "cacheScope": "private" - }) - ); - - cancellation_token.cancel(); -} - -#[tokio::test] -async fn discover_does_not_require_initialization_in_legacy_session_mode() { - let (client, url, cancellation_token) = spawn_server_with_legacy_session_mode(true, true).await; - - let response = post_discover(&client, &url, "2025-11-25", Some("2025-11-25")).await; - - assert_eq!(response.status(), 200); - assert!(response.headers().get("Mcp-Session-Id").is_none()); - - cancellation_token.cancel(); -} - -#[tokio::test] -async fn discover_rejects_unsupported_version_with_http_400() { - let (client, url, cancellation_token) = spawn_server(true).await; - - let response = post_discover(&client, &url, "2026-07-28", Some("2026-07-28")).await; - - assert_eq!(response.status(), 400); - let body: serde_json::Value = response.json().await.expect("response should be JSON"); - assert_eq!(body["error"]["code"], -32022); - assert_eq!( - body["error"]["data"], - json!({ - "requested": "2026-07-28", - "supported": ["2025-11-25"] - }) - ); - - cancellation_token.cancel(); -} - -#[tokio::test] -async fn discover_rejects_unknown_version_with_typed_error() { - let (client, url, cancellation_token) = spawn_server(true).await; - - let response = post_discover(&client, &url, "2099-01-01", Some("2099-01-01")).await; - - assert_eq!(response.status(), 400); - let body: serde_json::Value = response.json().await.expect("response should be JSON"); - assert_eq!(body["error"]["code"], -32022); - - cancellation_token.cancel(); -} - -#[tokio::test] -async fn regular_request_rejects_server_unsupported_meta_version() { - let (client, url, cancellation_token) = spawn_server(true).await; - let body = json!({ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/list", - "params": { - "_meta": { - "io.modelcontextprotocol/protocolVersion": "2026-07-28" - } - } - }); - - let response = client - .post(&url) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("MCP-Protocol-Version", "2026-07-28") - .header("Mcp-Method", "tools/list") - .json(&body) - .send() - .await - .expect("request should send"); - - assert_eq!(response.status(), 400); - let body: serde_json::Value = response.json().await.expect("response should be JSON"); - assert_eq!(body["error"]["code"], -32022); - - cancellation_token.cancel(); -} - -#[tokio::test] -async fn unknown_rpc_uses_http_404_for_per_request_protocol() { - let (client, url, cancellation_token) = spawn_server(true).await; - let body = json!({ - "jsonrpc": "2.0", - "id": 3, - "method": "unknown/method", - "params": { - "_meta": { - "io.modelcontextprotocol/protocolVersion": "2025-11-25" - } - } - }); - - let response = client - .post(&url) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("MCP-Protocol-Version", "2025-11-25") - .json(&body) - .send() - .await - .expect("request should send"); - - assert_eq!(response.status(), 404); - - cancellation_token.cancel(); -} - -#[tokio::test] -async fn legacy_unknown_rpc_preserves_http_200_jsonrpc_error() { - let (client, url, cancellation_token) = spawn_server(true).await; - let body = json!({ - "jsonrpc": "2.0", - "id": 5, - "method": "unknown/method", - "params": {} - }); - - let response = client - .post(&url) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("MCP-Protocol-Version", "2025-11-25") - .json(&body) - .send() - .await - .expect("request should send"); - - assert_eq!(response.status(), 200); - let body: serde_json::Value = response.json().await.expect("response should be JSON"); - assert_eq!(body["error"]["code"], -32601); - - cancellation_token.cancel(); -} - -#[tokio::test] -async fn discover_rejects_header_meta_version_mismatch() { - let (client, url, cancellation_token) = spawn_server(true).await; - - let response = post_discover(&client, &url, "2026-07-28", Some("2025-11-25")).await; - - assert_eq!(response.status(), 400); - - cancellation_token.cancel(); -} - -#[tokio::test] -async fn discover_rejects_missing_request_meta() { - let (client, url, cancellation_token) = spawn_server(true).await; - - let response = post_discover(&client, &url, "2025-11-25", None).await; - - assert_eq!(response.status(), 400); - let body: serde_json::Value = response.json().await.expect("response should be JSON"); - assert_eq!(body["error"]["code"], -32602); - - cancellation_token.cancel(); -} - -#[tokio::test] -async fn discover_rejects_missing_client_capabilities() { - let (client, url, cancellation_token) = spawn_server(true).await; - let body = json!({ - "jsonrpc": "2.0", - "id": 4, - "method": "server/discover", - "params": { - "_meta": { - "io.modelcontextprotocol/protocolVersion": "2025-11-25", - "io.modelcontextprotocol/clientInfo": { - "name": "test-client", - "version": "1.0.0" - } - } - } - }); - - let response = client - .post(&url) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("MCP-Protocol-Version", "2025-11-25") - .json(&body) - .send() - .await - .expect("request should send"); - - assert_eq!(response.status(), 400); - let body: serde_json::Value = response.json().await.expect("response should be JSON"); - assert_eq!(body["error"]["code"], -32602); - - cancellation_token.cancel(); -} - -#[tokio::test] -async fn discover_accepts_missing_optional_client_info() { - let (client, url, cancellation_token) = spawn_server(true).await; - let body = json!({ - "jsonrpc": "2.0", - "id": 5, - "method": "server/discover", - "params": { - "_meta": { - "io.modelcontextprotocol/protocolVersion": "2025-11-25", - "io.modelcontextprotocol/clientCapabilities": {} - } - } - }); - - let response = client - .post(&url) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("MCP-Protocol-Version", "2025-11-25") - .json(&body) - .send() - .await - .expect("request should send"); - - assert_eq!(response.status(), 200); - let body: serde_json::Value = response.json().await.expect("response should be JSON"); - assert!(body.get("result").is_some()); - - cancellation_token.cancel(); -} - -#[tokio::test] -async fn discover_error_uses_http_400_when_sse_is_configured() { - let (client, url, cancellation_token) = spawn_server(false).await; - - let response = post_discover(&client, &url, "2026-07-28", Some("2026-07-28")).await; - - assert_eq!(response.status(), 400); - assert_eq!( - response - .headers() - .get("Content-Type") - .and_then(|value| value.to_str().ok()), - Some("application/json") - ); - - cancellation_token.cancel(); -} diff --git a/vendor/rmcp/tests/test_server_initialization.rs b/vendor/rmcp/tests/test_server_initialization.rs deleted file mode 100644 index 6542e295b..000000000 --- a/vendor/rmcp/tests/test_server_initialization.rs +++ /dev/null @@ -1,396 +0,0 @@ -// cargo test --features "client" --package rmcp -- server_init -#![cfg(all(feature = "client", not(feature = "local")))] -mod common; - -use common::handlers::TestServer; -use rmcp::{ - ServerHandler, ServiceExt, - model::{ - ClientJsonRpcMessage, ProtocolVersion, ServerCapabilities, ServerInfo, - ServerJsonRpcMessage, ServerResult, - }, - transport::{IntoTransport, Transport}, -}; - -fn msg(raw: &str) -> ClientJsonRpcMessage { - serde_json::from_str(raw).expect("invalid test message JSON") -} - -fn init_request() -> ClientJsonRpcMessage { - msg(r#"{ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-11-25", - "capabilities": {}, - "clientInfo": { "name": "test-client", "version": "0.0.1" } - } - }"#) -} - -fn initialized_notification() -> ClientJsonRpcMessage { - msg(r#"{ "jsonrpc": "2.0", "method": "notifications/initialized" }"#) -} - -fn set_level_request(id: u64) -> ClientJsonRpcMessage { - msg(&format!( - r#"{{ "jsonrpc": "2.0", "id": {id}, "method": "logging/setLevel", "params": {{ "level": "info" }} }}"# - )) -} - -fn ping_request(id: u64) -> ClientJsonRpcMessage { - msg(&format!( - r#"{{ "jsonrpc": "2.0", "id": {id}, "method": "ping" }}"# - )) -} - -fn list_tools_request(id: u64) -> ClientJsonRpcMessage { - msg(&format!( - r#"{{ "jsonrpc": "2.0", "id": {id}, "method": "tools/list" }}"# - )) -} - -async fn do_initialize(client: &mut impl Transport) { - client.send(init_request()).await.unwrap(); - let _response = client.receive().await.unwrap(); -} - -// Server handles setLevel sent before initialized notification (processed by serve_inner). -#[tokio::test] -async fn server_init_set_level_response_is_empty_result() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let _server = tokio::spawn(async move { TestServer::new().serve(server_transport).await }); - let mut client = IntoTransport::::into_transport(client_transport); - - do_initialize(&mut client).await; - client.send(set_level_request(2)).await.unwrap(); - - // The handler may send logging notifications before the response; - // skip notifications to find the EmptyResult response. - let response = loop { - let msg = client.receive().await.unwrap(); - if matches!(msg, ServerJsonRpcMessage::Response(_)) { - break msg; - } - }; - assert!( - matches!( - response, - ServerJsonRpcMessage::Response(ref r) - if matches!(r.result, ServerResult::EmptyResult(_)) - ), - "expected EmptyResult for setLevel, got: {response:?}" - ); -} - -// Server initializes successfully when setLevel is sent before the initialized notification. -#[tokio::test] -async fn server_init_succeeds_after_set_level_before_initialized() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let server_handle = - tokio::spawn(async move { TestServer::new().serve(server_transport).await }); - let mut client = IntoTransport::::into_transport(client_transport); - - do_initialize(&mut client).await; - client.send(set_level_request(2)).await.unwrap(); - // Skip notifications until we get the response - loop { - let msg = client.receive().await.unwrap(); - if matches!(msg, ServerJsonRpcMessage::Response(_)) { - break; - } - } - client.send(initialized_notification()).await.unwrap(); - - let result = server_handle.await.unwrap(); - assert!( - result.is_ok(), - "server should initialize successfully after setLevel" - ); - result.unwrap().cancel().await.unwrap(); -} - -// Server responds with EmptyResult to ping received before initialize request. -#[tokio::test] -async fn server_init_ping_response_is_empty_result_before_initialize() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let _server = tokio::spawn(async move { TestServer::new().serve(server_transport).await }); - let mut client = IntoTransport::::into_transport(client_transport); - - client.send(ping_request(1)).await.unwrap(); - - let response = client.receive().await.unwrap(); - assert!( - matches!( - response, - ServerJsonRpcMessage::Response(ref r) - if matches!(r.result, ServerResult::EmptyResult(_)) - ), - "expected EmptyResult for pre-initialize ping, got: {response:?}" - ); -} - -// Server initializes successfully when ping is sent before the initialize request. -#[tokio::test] -async fn server_init_succeeds_after_ping_before_initialize() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let server_handle = - tokio::spawn(async move { TestServer::new().serve(server_transport).await }); - let mut client = IntoTransport::::into_transport(client_transport); - - client.send(ping_request(1)).await.unwrap(); - let _pong = client.receive().await.unwrap(); - do_initialize(&mut client).await; - client.send(initialized_notification()).await.unwrap(); - - let result = server_handle.await.unwrap(); - assert!( - result.is_ok(), - "server should initialize successfully after pre-initialize ping" - ); - result.unwrap().cancel().await.unwrap(); -} - -// Server responds with EmptyResult to ping received before initialized. -#[tokio::test] -async fn server_init_ping_response_is_empty_result() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let _server = tokio::spawn(async move { TestServer::new().serve(server_transport).await }); - let mut client = IntoTransport::::into_transport(client_transport); - - do_initialize(&mut client).await; - client.send(ping_request(2)).await.unwrap(); - - let response = client.receive().await.unwrap(); - assert!( - matches!( - response, - ServerJsonRpcMessage::Response(ref r) - if matches!(r.result, ServerResult::EmptyResult(_)) - ), - "expected EmptyResult for ping, got: {response:?}" - ); -} - -// Server initializes successfully when ping is sent before the initialized notification. -#[tokio::test] -async fn server_init_succeeds_after_ping_before_initialized() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let server_handle = - tokio::spawn(async move { TestServer::new().serve(server_transport).await }); - let mut client = IntoTransport::::into_transport(client_transport); - - do_initialize(&mut client).await; - client.send(ping_request(2)).await.unwrap(); - let _response = client.receive().await.unwrap(); - client.send(initialized_notification()).await.unwrap(); - - let result = server_handle.await.unwrap(); - assert!( - result.is_ok(), - "server should initialize successfully after ping" - ); - result.unwrap().cancel().await.unwrap(); -} - -// Server buffers tools/list sent before initialized and processes it after initialization. -#[tokio::test] -async fn server_init_buffers_request_before_initialized() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let server_handle = - tokio::spawn(async move { TestServer::new().serve(server_transport).await }); - let mut client = IntoTransport::::into_transport(client_transport); - - do_initialize(&mut client).await; - // Send tools/list before initialized notification - client.send(list_tools_request(2)).await.unwrap(); - // Now send initialized notification - client.send(initialized_notification()).await.unwrap(); - - // The buffered tools/list should be processed — expect a response - let response = client.receive().await.unwrap(); - assert!( - matches!(response, ServerJsonRpcMessage::Response(_)), - "expected response for buffered tools/list, got: {response:?}" - ); - - let result = server_handle.await.unwrap(); - assert!( - result.is_ok(), - "server should initialize successfully when buffering pre-init messages" - ); - result.unwrap().cancel().await.unwrap(); -} - -fn init_request_with_version(v: &str) -> ClientJsonRpcMessage { - msg(&format!( - r#"{{ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": {{ - "protocolVersion": "{v}", - "capabilities": {{}}, - "clientInfo": {{ "name": "test-client", "version": "0.0.1" }} - }} - }}"# - )) -} - -async fn negotiate_version(handler: H, client_version: &str) -> ProtocolVersion -where - H: ServerHandler + 'static, -{ - let (server_transport, client_transport) = tokio::io::duplex(4096); - let _server = tokio::spawn(async move { handler.serve(server_transport).await }); - let mut client = IntoTransport::::into_transport(client_transport); - - client - .send(init_request_with_version(client_version)) - .await - .unwrap(); - let response = client.receive().await.unwrap(); - let ServerJsonRpcMessage::Response(r) = response else { - panic!("expected initialize response, got {response:?}"); - }; - let ServerResult::InitializeResult(init) = r.result else { - panic!("expected InitializeResult"); - }; - init.protocol_version -} - -#[tokio::test] -async fn server_echoes_client_protocol_version_when_known_old() { - let negotiated = negotiate_version(TestServer::new(), "2024-11-05").await; - assert_eq!(negotiated, ProtocolVersion::V_2024_11_05); -} - -#[tokio::test] -async fn server_echoes_client_protocol_version_when_latest() { - let negotiated = negotiate_version(TestServer::new(), "2025-11-25").await; - assert_eq!(negotiated, ProtocolVersion::LATEST); -} - -#[tokio::test] -async fn server_falls_back_when_client_protocol_version_unknown() { - let negotiated = negotiate_version(TestServer::new(), "2099-99-99").await; - assert_eq!(negotiated, ProtocolVersion::LATEST); -} - -struct PinnedServer; - -impl ServerHandler for PinnedServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().build()) - .with_protocol_version(ProtocolVersion::V_2025_06_18) - } -} - -#[tokio::test] -async fn server_pinned_version_does_not_override_known_client_request() { - let negotiated = negotiate_version(PinnedServer, "2025-11-25").await; - assert_eq!(negotiated, ProtocolVersion::LATEST); -} - -#[tokio::test] -async fn server_pinned_version_used_as_fallback_for_unknown_client_request() { - let negotiated = negotiate_version(PinnedServer, "2099-99-99").await; - assert_eq!(negotiated, ProtocolVersion::V_2025_06_18); -} - -fn duplicate_init_request(id: u64, version: &str) -> ClientJsonRpcMessage { - msg(&format!( - r#"{{ - "jsonrpc": "2.0", - "id": {id}, - "method": "initialize", - "params": {{ - "protocolVersion": "{version}", - "capabilities": {{ "sampling": {{}} }}, - "clientInfo": {{ "name": "renegotiated-client", "version": "9.9.9" }} - }} - }}"# - )) -} - -#[tokio::test] -async fn server_accepts_duplicate_initialize() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let _server = tokio::spawn(async move { TestServer::new().serve(server_transport).await }); - let mut client = IntoTransport::::into_transport(client_transport); - - do_initialize(&mut client).await; - client.send(initialized_notification()).await.unwrap(); - - client - .send(duplicate_init_request(2, "2025-11-25")) - .await - .unwrap(); - let response = client.receive().await.unwrap(); - assert!( - matches!(response, ServerJsonRpcMessage::Response(_)), - "expected successful InitializeResult, got: {response:?}" - ); -} - -#[tokio::test] -async fn server_session_remains_usable_after_renegotiation() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let _server = tokio::spawn(async move { TestServer::new().serve(server_transport).await }); - let mut client = IntoTransport::::into_transport(client_transport); - - do_initialize(&mut client).await; - client.send(initialized_notification()).await.unwrap(); - client - .send(duplicate_init_request(2, "2025-11-25")) - .await - .unwrap(); - let _renegotiated = client.receive().await.unwrap(); - - client.send(ping_request(3)).await.unwrap(); - let pong = client.receive().await.unwrap(); - assert!( - matches!( - pong, - ServerJsonRpcMessage::Response(ref r) - if matches!(r.result, ServerResult::EmptyResult(_)) - ), - "expected EmptyResult ping after renegotiation, got: {pong:?}" - ); -} - -// Server buffers multiple requests before initialized and processes them in order. -#[tokio::test] -async fn server_init_buffers_multiple_requests_before_initialized() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let server_handle = - tokio::spawn(async move { TestServer::new().serve(server_transport).await }); - let mut client = IntoTransport::::into_transport(client_transport); - - do_initialize(&mut client).await; - // Send two requests before initialized - client.send(list_tools_request(2)).await.unwrap(); - client.send(ping_request(3)).await.unwrap(); - // Now send initialized notification - client.send(initialized_notification()).await.unwrap(); - - // Both buffered messages should get responses - let response1 = client.receive().await.unwrap(); - let response2 = client.receive().await.unwrap(); - assert!( - matches!(response1, ServerJsonRpcMessage::Response(_)), - "expected response for first buffered message, got: {response1:?}" - ); - assert!( - matches!(response2, ServerJsonRpcMessage::Response(_)), - "expected response for second buffered message, got: {response2:?}" - ); - - let result = server_handle.await.unwrap(); - assert!( - result.is_ok(), - "server should initialize successfully with multiple buffered messages" - ); - result.unwrap().cancel().await.unwrap(); -} diff --git a/vendor/rmcp/tests/test_sse_concurrent_streams.rs b/vendor/rmcp/tests/test_sse_concurrent_streams.rs deleted file mode 100644 index e1e885282..000000000 --- a/vendor/rmcp/tests/test_sse_concurrent_streams.rs +++ /dev/null @@ -1,775 +0,0 @@ -#![cfg(not(feature = "local"))] -/// Tests for concurrent SSE stream handling (shadow channels) -/// -/// These tests verify that multiple GET SSE streams on the same session -/// don't kill each other by replacing the common channel sender. -/// -/// Root cause: When POST SSE responses include `retry`, the EventSource API -/// reconnects via GET after the stream ends. Each GET was unconditionally -/// replacing `self.common.tx`, killing the other stream's receiver — causing -/// an infinite reconnect loop every `sse_retry` seconds. -/// -/// Fix: `resume_or_shadow_common()` checks if the primary common channel is -/// still active. If so, it creates a "shadow" stream (idle, keep-alive only) -/// instead of replacing the primary. -use std::sync::Arc; -use std::time::Duration; - -use futures::StreamExt; -use rmcp::{ - RoleServer, ServerHandler, - model::{Implementation, ServerCapabilities, ServerInfo, ToolsCapability}, - service::NotificationContext, - transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, - }, -}; -use serde_json::json; -use tokio::sync::Notify; -use tokio_util::sync::CancellationToken; - -const ACCEPT_SSE: &str = "text/event-stream"; -const ACCEPT_BOTH: &str = "text/event-stream, application/json"; - -// ─── Test server ──────────────────────────────────────────────────────────── - -#[derive(Clone)] -pub struct TestServer { - trigger: Arc, -} - -impl TestServer { - fn new(trigger: Arc) -> Self { - Self { trigger } - } -} - -impl ServerHandler for TestServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( - ServerCapabilities::builder() - .enable_tools_with({ - let mut tools = ToolsCapability::default(); - tools.list_changed = Some(true); - tools - }) - .build(), - ) - .with_server_info(Implementation::new("test-server", "1.0.0")) - } - - async fn on_initialized(&self, context: NotificationContext) { - let peer = context.peer.clone(); - let trigger = self.trigger.clone(); - - tokio::spawn(async move { - trigger.notified().await; - let _ = peer.notify_tool_list_changed().await; - }); - } -} - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -async fn start_test_server(ct: CancellationToken, trigger: Arc) -> String { - let server = TestServer::new(trigger); - let service = StreamableHttpService::new( - move || Ok(server.clone()), - Arc::new(LocalSessionManager::default()), - StreamableHttpServerConfig::default().with_cancellation_token(ct.child_token()), - ); - - let router = axum::Router::new().nest_service("/mcp", service); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind"); - let addr = listener.local_addr().unwrap(); - let url = format!("http://127.0.0.1:{}/mcp", addr.port()); - - let ct_clone = ct.clone(); - tokio::spawn(async move { - axum::serve(listener, router) - .with_graceful_shutdown(async move { ct_clone.cancelled().await }) - .await - .unwrap(); - }); - - tokio::time::sleep(Duration::from_millis(100)).await; - url -} - -/// POST initialize and return session ID. -async fn initialize_session(client: &reqwest::Client, url: &str) -> String { - let resp = client - .post(url) - .header("Accept", ACCEPT_BOTH) - .header("Content-Type", "application/json") - .json(&json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": { "name": "test-client", "version": "1.0.0" } - } - })) - .timeout(Duration::from_millis(500)) - .send() - .await - .expect("POST initialize"); - - assert!(resp.status().is_success(), "initialize should succeed"); - - resp.headers() - .get("Mcp-Session-Id") - .expect("session ID header") - .to_str() - .unwrap() - .to_string() -} - -/// POST `notifications/initialized` to complete the MCP handshake. -/// This triggers the server's `on_initialized` handler. -async fn send_initialized_notification(client: &reqwest::Client, url: &str, session_id: &str) { - let resp = client - .post(url) - .header("Accept", ACCEPT_BOTH) - .header("Content-Type", "application/json") - .header("Mcp-Session-Id", session_id) - .json(&json!({ - "jsonrpc": "2.0", - "method": "notifications/initialized" - })) - .send() - .await - .expect("POST notifications/initialized"); - - assert_eq!( - resp.status().as_u16(), - 202, - "notifications/initialized should return 202 Accepted" - ); -} - -/// Open a standalone GET SSE stream (no Last-Event-ID). -async fn open_standalone_get( - client: &reqwest::Client, - url: &str, - session_id: &str, -) -> reqwest::Response { - client - .get(url) - .header("Accept", ACCEPT_SSE) - .header("Mcp-Session-Id", session_id) - .send() - .await - .expect("GET SSE stream") -} - -/// Open a GET SSE stream with Last-Event-ID (resume). -async fn open_resume_get( - client: &reqwest::Client, - url: &str, - session_id: &str, - last_event_id: &str, -) -> reqwest::Response { - client - .get(url) - .header("Accept", ACCEPT_SSE) - .header("Mcp-Session-Id", session_id) - .header("Last-Event-ID", last_event_id) - .send() - .await - .expect("GET SSE stream with Last-Event-ID") -} - -/// Read from an SSE byte stream until we find a specific text or timeout. -async fn wait_for_sse_event(resp: reqwest::Response, needle: &str, timeout: Duration) -> bool { - let mut stream = resp.bytes_stream(); - let result = tokio::time::timeout(timeout, async { - while let Some(Ok(chunk)) = stream.next().await { - let text = String::from_utf8_lossy(&chunk); - if text.contains(needle) { - return true; - } - } - false - }) - .await; - - matches!(result, Ok(true)) -} - -// ─── Tests: Shadow stream creation ────────────────────────────────────────── - -/// Second standalone GET with same session ID should return 200 OK -/// (shadow stream), NOT 409 Conflict. -#[tokio::test] -async fn shadow_second_standalone_get_returns_200() { - let ct = CancellationToken::new(); - let trigger = Arc::new(Notify::new()); - let url = start_test_server(ct.clone(), trigger).await; - let client = reqwest::Client::new(); - - let session_id = initialize_session(&client, &url).await; - - // First GET — becomes primary common channel - let get1 = open_standalone_get(&client, &url, &session_id).await; - assert_eq!(get1.status(), 200, "First GET should succeed"); - - tokio::time::sleep(Duration::from_millis(100)).await; - - // Second GET — should get 200 (shadow), NOT 409 - let get2 = open_standalone_get(&client, &url, &session_id).await; - assert_eq!( - get2.status(), - 200, - "Second GET should return 200 (shadow stream), not 409 Conflict" - ); - - ct.cancel(); -} - -/// Multiple standalone GETs should all return 200 — the server can handle -/// many shadow streams concurrently. -#[tokio::test] -async fn shadow_multiple_standalone_gets_all_succeed() { - let ct = CancellationToken::new(); - let trigger = Arc::new(Notify::new()); - let url = start_test_server(ct.clone(), trigger).await; - let client = reqwest::Client::new(); - - let session_id = initialize_session(&client, &url).await; - - // Open 5 concurrent standalone GETs - let mut responses = Vec::new(); - for i in 0..5 { - let resp = open_standalone_get(&client, &url, &session_id).await; - assert_eq!(resp.status(), 200, "GET #{i} should succeed"); - responses.push(resp); - tokio::time::sleep(Duration::from_millis(50)).await; - } - - // All 5 should be alive (first is primary, rest are shadows) - assert_eq!(responses.len(), 5); - - ct.cancel(); -} - -// ─── Tests: Dead primary replacement ──────────────────────────────────────── - -/// When the primary common channel is dead (first GET dropped), the next GET -/// should replace it and become the new primary. -#[tokio::test] -async fn dead_primary_gets_replaced_by_next_get() { - let ct = CancellationToken::new(); - let trigger = Arc::new(Notify::new()); - let url = start_test_server(ct.clone(), trigger).await; - let client = reqwest::Client::new(); - - let session_id = initialize_session(&client, &url).await; - - // First GET — becomes primary - let get1 = open_standalone_get(&client, &url, &session_id).await; - assert_eq!(get1.status(), 200); - - // Drop primary — kills receiver, making sender closed - drop(get1); - tokio::time::sleep(Duration::from_millis(100)).await; - - // Second GET — primary is dead, should replace it - let get2 = open_standalone_get(&client, &url, &session_id).await; - assert_eq!( - get2.status(), - 200, - "GET should succeed as new primary after old primary was dropped" - ); - - ct.cancel(); -} - -/// After primary dies, the replacement primary should be able to receive -/// notifications (verifies the channel was actually replaced, not shadowed). -#[tokio::test] -async fn dead_primary_replacement_receives_notifications() { - let ct = CancellationToken::new(); - let trigger = Arc::new(Notify::new()); - let url = start_test_server(ct.clone(), trigger.clone()).await; - let client = reqwest::Client::new(); - - let session_id = initialize_session(&client, &url).await; - send_initialized_notification(&client, &url, &session_id).await; - tokio::time::sleep(Duration::from_millis(100)).await; - - // First GET — becomes primary - let get1 = open_standalone_get(&client, &url, &session_id).await; - assert_eq!(get1.status(), 200); - - // Drop primary - drop(get1); - tokio::time::sleep(Duration::from_millis(100)).await; - - // Second GET — becomes new primary (replacement) - let get2 = open_standalone_get(&client, &url, &session_id).await; - assert_eq!(get2.status(), 200); - - // Trigger notification — should arrive on get2 (the new primary) - trigger.notify_one(); - - assert!( - wait_for_sse_event(get2, "tools/list_changed", Duration::from_secs(3)).await, - "Replacement primary should receive notifications" - ); - - ct.cancel(); -} - -/// Multiple drops and replacements should work: primary can be replaced -/// more than once. -#[tokio::test] -async fn dead_primary_can_be_replaced_multiple_times() { - let ct = CancellationToken::new(); - let trigger = Arc::new(Notify::new()); - let url = start_test_server(ct.clone(), trigger).await; - let client = reqwest::Client::new(); - - let session_id = initialize_session(&client, &url).await; - - for i in 0..3 { - let get = open_standalone_get(&client, &url, &session_id).await; - assert_eq!(get.status(), 200, "GET #{i} should succeed"); - drop(get); - tokio::time::sleep(Duration::from_millis(100)).await; - } - - // Final GET should still work - let final_get = open_standalone_get(&client, &url, &session_id).await; - assert_eq!( - final_get.status(), - 200, - "GET after multiple replacements should succeed" - ); - - ct.cancel(); -} - -// ─── Tests: Notification routing ──────────────────────────────────────────── - -/// Notification should arrive on the primary stream even after shadow streams -/// are created by subsequent GETs. -#[tokio::test] -async fn notification_reaches_primary_not_shadow() { - let ct = CancellationToken::new(); - let trigger = Arc::new(Notify::new()); - let url = start_test_server(ct.clone(), trigger.clone()).await; - let client = reqwest::Client::new(); - - let session_id = initialize_session(&client, &url).await; - send_initialized_notification(&client, &url, &session_id).await; - tokio::time::sleep(Duration::from_millis(200)).await; - - // First GET — primary common channel - let get1 = open_standalone_get(&client, &url, &session_id).await; - assert_eq!(get1.status(), 200); - tokio::time::sleep(Duration::from_millis(100)).await; - - // Second GET — shadow stream (should NOT steal notifications) - let _get2 = open_standalone_get(&client, &url, &session_id).await; - assert_eq!(_get2.status(), 200); - tokio::time::sleep(Duration::from_millis(100)).await; - - // Trigger notification - trigger.notify_one(); - - // Primary stream should receive the notification - assert!( - wait_for_sse_event(get1, "tools/list_changed", Duration::from_secs(3)).await, - "Primary stream should receive notification even after shadow was created" - ); - - ct.cancel(); -} - -// ─── Tests: Resume with Last-Event-ID ─────────────────────────────────────── - -/// GET with Last-Event-ID referencing a completed request-wise channel should -/// fall through to shadow (not crash or return 500). -/// -/// This simulates the real-world scenario: POST SSE response ends, the -/// EventSource reconnects via GET with the last event ID from the POST stream. -/// The request-wise channel no longer exists, so the server should create a -/// shadow stream. -#[tokio::test] -async fn resume_completed_request_wise_creates_shadow() { - let ct = CancellationToken::new(); - let trigger = Arc::new(Notify::new()); - let url = start_test_server(ct.clone(), trigger).await; - let client = reqwest::Client::new(); - - let session_id = initialize_session(&client, &url).await; - - // First GET — establish primary - let _get1 = open_standalone_get(&client, &url, &session_id).await; - assert_eq!(_get1.status(), 200); - tokio::time::sleep(Duration::from_millis(100)).await; - - // GET with Last-Event-ID for non-existent request-wise channel - let get_resume = open_resume_get(&client, &url, &session_id, "0/999").await; - assert_eq!( - get_resume.status(), - 200, - "Resume of completed request-wise channel should return 200 (shadow)" - ); - - ct.cancel(); -} - -/// GET with Last-Event-ID "0" (common channel resume) while primary is alive -/// should create a shadow. -#[tokio::test] -async fn resume_common_while_primary_alive_creates_shadow() { - let ct = CancellationToken::new(); - let trigger = Arc::new(Notify::new()); - let url = start_test_server(ct.clone(), trigger).await; - let client = reqwest::Client::new(); - - let session_id = initialize_session(&client, &url).await; - - // First GET — establish primary - let _get1 = open_standalone_get(&client, &url, &session_id).await; - assert_eq!(_get1.status(), 200); - tokio::time::sleep(Duration::from_millis(100)).await; - - // GET with Last-Event-ID "0" — resume common while primary alive → shadow - let get_resume = open_resume_get(&client, &url, &session_id, "0").await; - assert_eq!( - get_resume.status(), - 200, - "Common channel resume while primary alive should return 200 (shadow)" - ); - - ct.cancel(); -} - -/// GET with Last-Event-ID "0" (common channel resume) while primary is dead -/// should become the new primary. -#[tokio::test] -async fn resume_common_while_primary_dead_becomes_primary() { - let ct = CancellationToken::new(); - let trigger = Arc::new(Notify::new()); - let url = start_test_server(ct.clone(), trigger.clone()).await; - let client = reqwest::Client::new(); - - let session_id = initialize_session(&client, &url).await; - send_initialized_notification(&client, &url, &session_id).await; - tokio::time::sleep(Duration::from_millis(200)).await; - - // First GET — establish primary - let get1 = open_standalone_get(&client, &url, &session_id).await; - assert_eq!(get1.status(), 200); - - // Drop primary - drop(get1); - tokio::time::sleep(Duration::from_millis(100)).await; - - // GET with Last-Event-ID "0" — primary dead → becomes new primary - let get_resume = open_resume_get(&client, &url, &session_id, "0").await; - assert_eq!(get_resume.status(), 200); - - // New primary should receive notifications - trigger.notify_one(); - - assert!( - wait_for_sse_event(get_resume, "tools/list_changed", Duration::from_secs(3)).await, - "Resumed stream that replaced dead primary should receive notifications" - ); - - ct.cancel(); -} - -// ─── Tests: Mixed scenarios ───────────────────────────────────────────────── - -/// POST SSE reconnections and standalone GET should coexist: POST initialize -/// creates a request-wise channel, its EventSource reconnects via GET after -/// the stream ends, while a standalone GET is also active. -#[tokio::test] -async fn post_reconnect_and_standalone_coexist() { - let ct = CancellationToken::new(); - let trigger = Arc::new(Notify::new()); - let url = start_test_server(ct.clone(), trigger).await; - let client = reqwest::Client::new(); - - let session_id = initialize_session(&client, &url).await; - - // Standalone GET — becomes primary - let _standalone = open_standalone_get(&client, &url, &session_id).await; - assert_eq!(_standalone.status(), 200); - tokio::time::sleep(Duration::from_millis(100)).await; - - // Simulate POST SSE response reconnection (EventSource reconnects with - // Last-Event-ID from the initialize POST stream). The request-wise channel - // for the initialize request is already completed. - let reconnect1 = open_resume_get(&client, &url, &session_id, "0/0").await; - assert_eq!( - reconnect1.status(), - 200, - "POST reconnection should get shadow, not replace primary" - ); - - tokio::time::sleep(Duration::from_millis(100)).await; - - // Another POST reconnection (e.g. from tools/list response) - let reconnect2 = open_resume_get(&client, &url, &session_id, "0/1").await; - assert_eq!( - reconnect2.status(), - 200, - "Second POST reconnection should also succeed" - ); - - ct.cancel(); -} - -/// Standalone GET is dropped (e.g. client timeout), a new standalone GET -/// connects. The new one should become the primary and receive notifications. -#[tokio::test] -async fn reconnect_after_stream_timeout() { - let ct = CancellationToken::new(); - let trigger = Arc::new(Notify::new()); - let url = start_test_server(ct.clone(), trigger.clone()).await; - let client = reqwest::Client::new(); - - let session_id = initialize_session(&client, &url).await; - send_initialized_notification(&client, &url, &session_id).await; - tokio::time::sleep(Duration::from_millis(200)).await; - - // First standalone GET — primary - let get1 = open_standalone_get(&client, &url, &session_id).await; - assert_eq!(get1.status(), 200); - - // Client drops the stream (e.g. timeout or reconnection) - drop(get1); - tokio::time::sleep(Duration::from_millis(100)).await; - - // Client reconnects with a new standalone GET - let get2 = open_standalone_get(&client, &url, &session_id).await; - assert_eq!(get2.status(), 200); - - // Notification should reach the new primary - trigger.notify_one(); - - assert!( - wait_for_sse_event(get2, "tools/list_changed", Duration::from_secs(3)).await, - "Reconnected stream should receive notifications" - ); - - ct.cancel(); -} - -// ─── Tests: Edge cases ────────────────────────────────────────────────────── - -/// GET with an unknown session ID should return 404 Not Found per MCP spec. -/// This signals the client to re-initialize (not re-authenticate). -#[tokio::test] -async fn get_without_valid_session_returns_404() { - let ct = CancellationToken::new(); - let trigger = Arc::new(Notify::new()); - let url = start_test_server(ct.clone(), trigger).await; - let client = reqwest::Client::new(); - - let resp = client - .get(&url) - .header("Accept", ACCEPT_SSE) - .header("Mcp-Session-Id", "nonexistent-session-id") - .send() - .await - .expect("GET with invalid session"); - - assert_eq!( - resp.status().as_u16(), - 404, - "GET with unknown session ID should return 404 Not Found per MCP spec" - ); - - ct.cancel(); -} - -/// GET without session ID header should return 400 Bad Request per MCP spec. -#[tokio::test] -async fn get_without_session_id_header_returns_400() { - let ct = CancellationToken::new(); - let trigger = Arc::new(Notify::new()); - let url = start_test_server(ct.clone(), trigger).await; - let client = reqwest::Client::new(); - - let resp = client - .get(&url) - .header("Accept", ACCEPT_SSE) - .send() - .await - .expect("GET without session ID"); - - assert_eq!( - resp.status().as_u16(), - 400, - "GET without session ID should return 400 Bad Request per MCP spec" - ); - - ct.cancel(); -} - -/// Shadow streams should be idle — they should NOT receive notifications. -/// Only the primary receives them. -#[tokio::test] -async fn shadow_stream_does_not_receive_notifications() { - let ct = CancellationToken::new(); - let trigger = Arc::new(Notify::new()); - let url = start_test_server(ct.clone(), trigger.clone()).await; - let client = reqwest::Client::new(); - - let session_id = initialize_session(&client, &url).await; - send_initialized_notification(&client, &url, &session_id).await; - tokio::time::sleep(Duration::from_millis(200)).await; - - // First GET — primary - let _get1 = open_standalone_get(&client, &url, &session_id).await; - assert_eq!(_get1.status(), 200); - tokio::time::sleep(Duration::from_millis(100)).await; - - // Second GET — shadow - let get2 = open_standalone_get(&client, &url, &session_id).await; - assert_eq!(get2.status(), 200); - tokio::time::sleep(Duration::from_millis(100)).await; - - // Trigger notification - trigger.notify_one(); - - // Shadow stream should NOT receive the notification (timeout expected) - let shadow_received = - wait_for_sse_event(get2, "tools/list_changed", Duration::from_millis(500)).await; - assert!( - !shadow_received, - "Shadow stream should NOT receive notifications" - ); - - ct.cancel(); -} - -/// Dropping all shadow streams should not affect the primary channel. -/// Primary should still receive notifications after all shadows are dropped. -#[tokio::test] -async fn dropping_shadows_does_not_affect_primary() { - let ct = CancellationToken::new(); - let trigger = Arc::new(Notify::new()); - let url = start_test_server(ct.clone(), trigger.clone()).await; - let client = reqwest::Client::new(); - - let session_id = initialize_session(&client, &url).await; - send_initialized_notification(&client, &url, &session_id).await; - tokio::time::sleep(Duration::from_millis(200)).await; - - // Primary GET - let get1 = open_standalone_get(&client, &url, &session_id).await; - assert_eq!(get1.status(), 200); - tokio::time::sleep(Duration::from_millis(100)).await; - - // Create and drop several shadows - for _ in 0..3 { - let shadow = open_standalone_get(&client, &url, &session_id).await; - assert_eq!(shadow.status(), 200); - drop(shadow); - tokio::time::sleep(Duration::from_millis(50)).await; - } - - // Trigger notification — primary should still receive it - trigger.notify_one(); - - assert!( - wait_for_sse_event(get1, "tools/list_changed", Duration::from_secs(3)).await, - "Primary should still work after all shadows are dropped" - ); - - ct.cancel(); -} - -// ─── Tests: Cache replay on dead primary replacement ───────────────────────── - -/// When a notification is sent while the primary is alive, then the primary -/// dies and a new GET resumes with Last-Event-ID "0", the replacement primary -/// should receive the cached notification via sync() replay. -#[tokio::test] -async fn dead_primary_replacement_replays_cached_events() { - let ct = CancellationToken::new(); - let trigger = Arc::new(Notify::new()); - let url = start_test_server(ct.clone(), trigger.clone()).await; - let client = reqwest::Client::new(); - - let session_id = initialize_session(&client, &url).await; - send_initialized_notification(&client, &url, &session_id).await; - tokio::time::sleep(Duration::from_millis(200)).await; - - // First GET — becomes primary - let get1 = open_standalone_get(&client, &url, &session_id).await; - assert_eq!(get1.status(), 200); - tokio::time::sleep(Duration::from_millis(100)).await; - - // Trigger notification while primary is alive (gets cached) - trigger.notify_one(); - tokio::time::sleep(Duration::from_millis(200)).await; - - // Drop primary — notification was sent and cached - drop(get1); - tokio::time::sleep(Duration::from_millis(100)).await; - - // Resume with Last-Event-ID "0" — primary is dead, should replace it - // and replay cached events from index 0 - let get_resume = open_resume_get(&client, &url, &session_id, "0").await; - assert_eq!(get_resume.status(), 200); - - // The cached notification should be replayed on the new primary - assert!( - wait_for_sse_event(get_resume, "tools/list_changed", Duration::from_secs(3)).await, - "Replacement primary should receive cached notification via sync() replay" - ); - - ct.cancel(); -} - -// ─── Tests: Shadow stream limits ───────────────────────────────────────────── - -/// Opening more than 32 shadow streams should not crash or reject — the server -/// drops the oldest shadow to stay within the limit. Primary still works. -#[tokio::test] -async fn shadow_stream_limit_drops_oldest() { - let ct = CancellationToken::new(); - let trigger = Arc::new(Notify::new()); - let url = start_test_server(ct.clone(), trigger.clone()).await; - let client = reqwest::Client::new(); - - let session_id = initialize_session(&client, &url).await; - send_initialized_notification(&client, &url, &session_id).await; - tokio::time::sleep(Duration::from_millis(200)).await; - - // First GET — primary - let get1 = open_standalone_get(&client, &url, &session_id).await; - assert_eq!(get1.status(), 200); - tokio::time::sleep(Duration::from_millis(100)).await; - - // Open 35 shadow streams (exceeds MAX_SHADOW_STREAMS=32) - let mut shadows = Vec::new(); - for i in 0..35 { - let shadow = open_standalone_get(&client, &url, &session_id).await; - assert_eq!(shadow.status(), 200, "Shadow #{i} should succeed"); - shadows.push(shadow); - } - - // Primary should still receive notifications despite shadow churn - trigger.notify_one(); - - assert!( - wait_for_sse_event(get1, "tools/list_changed", Duration::from_secs(3)).await, - "Primary should still work after exceeding shadow limit" - ); - - ct.cancel(); -} diff --git a/vendor/rmcp/tests/test_stateless_protocol_version.rs b/vendor/rmcp/tests/test_stateless_protocol_version.rs deleted file mode 100644 index 02222ec2c..000000000 --- a/vendor/rmcp/tests/test_stateless_protocol_version.rs +++ /dev/null @@ -1,226 +0,0 @@ -//! Tests for protocol version negotiation in stateless HTTP mode. -//! -//! Supported versions are echoed back; unknown versions, and versions outside -//! the server's `supported_protocol_versions`, fall back to the handler's own -//! version. -#![cfg(not(feature = "local"))] - -use std::borrow::Cow; - -use rmcp::{ - ErrorData, RoleServer, ServerHandler, - model::{ - InitializeRequestParams, InitializeResult, ProtocolVersion, ServerCapabilities, ServerInfo, - }, - service::RequestContext, - transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, - }, -}; -use tokio_util::sync::CancellationToken; - -#[derive(Clone, Default)] -struct OverridingInitialize; - -impl ServerHandler for OverridingInitialize { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::default()) - } - - async fn initialize( - &self, - _request: InitializeRequestParams, - _context: RequestContext, - ) -> Result { - Ok(self.get_info()) - } -} - -/// Every known version except `2026-07-28`, standing in for a server that has -/// not implemented that revision. -const NARROWED_VERSIONS: &[ProtocolVersion] = &[ - ProtocolVersion::V_2024_11_05, - ProtocolVersion::V_2025_03_26, - ProtocolVersion::V_2025_06_18, - ProtocolVersion::V_2025_11_25, -]; - -/// Overrides `initialize`, so the handler-side default negotiation never runs, -/// *and* narrows the supported versions. -#[derive(Clone, Default)] -struct NarrowedOverridingInitialize; - -impl ServerHandler for NarrowedOverridingInitialize { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::default()) - } - - fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { - Cow::Borrowed(NARROWED_VERSIONS) - } - - async fn initialize( - &self, - _request: InitializeRequestParams, - _context: RequestContext, - ) -> Result { - Ok(self.get_info()) - } -} - -fn stateless_sse_config() -> StreamableHttpServerConfig { - StreamableHttpServerConfig::default() - .with_legacy_session_mode(false) - .with_sse_keep_alive(None) - .with_cancellation_token(CancellationToken::new()) -} - -fn stateless_json_config() -> StreamableHttpServerConfig { - stateless_sse_config().with_json_response(true) -} - -async fn spawn_server( - config: StreamableHttpServerConfig, -) -> (reqwest::Client, String, CancellationToken) { - spawn_server_of::(config).await -} - -async fn spawn_server_of( - config: StreamableHttpServerConfig, -) -> (reqwest::Client, String, CancellationToken) { - let ct = config.cancellation_token.clone(); - let service: StreamableHttpService = - StreamableHttpService::new(|| Ok(H::default()), Default::default(), config); - - let router = axum::Router::new().nest_service("/mcp", service); - let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = tcp_listener.local_addr().unwrap(); - - tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(tcp_listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - - (reqwest::Client::new(), format!("http://{addr}/mcp"), ct) -} - -async fn post_init(client: &reqwest::Client, url: &str, body_version: &str) -> serde_json::Value { - let body = serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": body_version, - "capabilities": {}, - "clientInfo": {"name": "test", "version": "0.0.1"} - } - }); - let resp = client - .post(url) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .body(body.to_string()) - .send() - .await - .expect("send request"); - assert!(resp.status().is_success(), "HTTP {}", resp.status()); - let is_json = resp - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.starts_with("application/json")); - if is_json { - resp.json().await.expect("parse JSON") - } else { - let body = resp.text().await.expect("read SSE body"); - let data = body - .lines() - .find_map(|line| line.strip_prefix("data:")) - .map(str::trim) - .filter(|data| !data.is_empty()) - .expect("SSE response contains data"); - serde_json::from_str(data).expect("parse SSE data") - } -} - -#[tokio::test] -async fn stateless_json_init_echoes_known_versions_when_handler_overrides_initialize() { - let (client, url, ct) = spawn_server(stateless_json_config()).await; - - for version in ProtocolVersion::KNOWN_VERSIONS { - let resp = post_init(&client, &url, version.as_str()).await; - assert_eq!( - resp["result"]["protocolVersion"], - version.as_str(), - "known version {version} should be echoed back" - ); - } - - ct.cancel(); -} - -#[tokio::test] -async fn stateless_sse_init_echoes_known_versions_when_handler_overrides_initialize() { - let (client, url, ct) = spawn_server(stateless_sse_config()).await; - - for version in ProtocolVersion::KNOWN_VERSIONS { - let resp = post_init(&client, &url, version.as_str()).await; - assert_eq!( - resp["result"]["protocolVersion"], - version.as_str(), - "known version {version} should be echoed back" - ); - } - - ct.cancel(); -} - -#[tokio::test] -async fn stateless_json_init_preserves_handler_fallback_for_unknown_version() { - let (client, url, ct) = spawn_server(stateless_json_config()).await; - - let resp = post_init(&client, &url, "1999-01-01").await; - assert_eq!( - resp["result"]["protocolVersion"], - ProtocolVersion::LATEST.as_str(), - "unknown version should preserve the handler's fallback" - ); - - ct.cancel(); -} - -#[tokio::test] -async fn stateless_json_init_echoes_versions_the_server_narrowed_to() { - let (client, url, ct) = - spawn_server_of::(stateless_json_config()).await; - - for version in NARROWED_VERSIONS { - let resp = post_init(&client, &url, version.as_str()).await; - assert_eq!( - resp["result"]["protocolVersion"], - version.as_str(), - "supported version {version} should be echoed back" - ); - } - - ct.cancel(); -} - -#[tokio::test] -async fn stateless_json_init_does_not_agree_to_version_outside_supported_list() { - let (client, url, ct) = - spawn_server_of::(stateless_json_config()).await; - - let resp = post_init(&client, &url, ProtocolVersion::V_2026_07_28.as_str()).await; - assert_eq!( - resp["result"]["protocolVersion"], - ProtocolVersion::V_2025_11_25.as_str(), - "a version outside supported_protocol_versions should not be echoed back" - ); - - ct.cancel(); -} diff --git a/vendor/rmcp/tests/test_stateless_server_requests.rs b/vendor/rmcp/tests/test_stateless_server_requests.rs deleted file mode 100644 index 4404e08cf..000000000 --- a/vendor/rmcp/tests/test_stateless_server_requests.rs +++ /dev/null @@ -1,196 +0,0 @@ -#![cfg(all(feature = "server", not(feature = "local")))] - -use std::sync::{Arc, Mutex}; - -use rmcp::{ - ServerHandler, ServiceExt, - model::{ - ClientCapabilities, ClientJsonRpcMessage, ClientRequest, DiscoverRequest, - DiscoverRequestParams, ErrorCode, ErrorData, Implementation, ListToolsRequest, - ListToolsResult, PaginatedRequestParams, ProtocolVersion, RequestId, RequestMetaObject, - ServerJsonRpcMessage, - }, - service::{MaybeSendFuture, RequestContext, RoleServer, ServerInitializeError}, - transport::{IntoTransport, Transport}, -}; - -#[derive(Clone, Default)] -struct StatelessServer; - -impl ServerHandler for StatelessServer {} - -fn complete_meta() -> RequestMetaObject { - complete_meta_for("stateless-client") -} - -fn complete_meta_for(client_name: &str) -> RequestMetaObject { - let mut meta = RequestMetaObject::new(); - meta.set_protocol_version(ProtocolVersion::V_2026_07_28); - meta.set_client_info(Implementation::new(client_name, "1.0.0")); - meta.set_client_capabilities(ClientCapabilities::default()); - meta -} - -fn list_tools_request(meta: RequestMetaObject) -> ClientJsonRpcMessage { - let mut request = ListToolsRequest { - method: Default::default(), - params: None, - extensions: Default::default(), - }; - request.extensions.insert(meta); - ClientJsonRpcMessage::request( - ClientRequest::ListToolsRequest(request), - RequestId::Number(1), - ) -} - -#[tokio::test] -async fn stateless_server_rejects_missing_metadata_on_every_request() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let server_task = tokio::spawn(async move { - StatelessServer - .serve(server_transport) - .await - .expect("server should start") - }); - let mut client = IntoTransport::::into_transport(client_transport); - - let mut discover = DiscoverRequest::new(DiscoverRequestParams {}); - discover.extensions.insert(complete_meta()); - client - .send(ClientJsonRpcMessage::request( - ClientRequest::DiscoverRequest(discover), - RequestId::Number(1), - )) - .await - .expect("send discover"); - assert!(matches!( - client.receive().await, - Some(ServerJsonRpcMessage::Response(_)) - )); - - client - .send(ClientJsonRpcMessage::request( - ClientRequest::ListToolsRequest(ListToolsRequest { - method: Default::default(), - params: None, - extensions: Default::default(), - }), - RequestId::Number(2), - )) - .await - .expect("send list tools"); - let Some(ServerJsonRpcMessage::Error(error)) = client.receive().await else { - panic!("expected invalid params"); - }; - assert_eq!(error.error.code, ErrorCode::INVALID_PARAMS); - - server_task - .await - .expect("server task") - .cancel() - .await - .expect("cancel server"); -} - -#[derive(Clone)] -struct ContextServer { - seen_clients: Arc>>, -} - -impl ServerHandler for ContextServer { - fn list_tools( - &self, - _request: Option, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - let seen_clients = self.seen_clients.clone(); - async move { - seen_clients - .lock() - .expect("seen clients lock") - .push(context.client_info().expect("current client info").name); - Ok(ListToolsResult::default()) - } - } -} - -#[tokio::test] -async fn stateless_server_uses_each_requests_client_context() { - let seen_clients = Arc::new(Mutex::new(Vec::new())); - let handler = ContextServer { - seen_clients: seen_clients.clone(), - }; - let (server_transport, client_transport) = tokio::io::duplex(4096); - let server_task = tokio::spawn(async move { - handler - .serve(server_transport) - .await - .expect("server should start") - }); - let mut client = IntoTransport::::into_transport(client_transport); - - client - .send(list_tools_request(complete_meta_for("first-client"))) - .await - .expect("send first request"); - assert!(matches!( - client.receive().await, - Some(ServerJsonRpcMessage::Response(_)) - )); - - let mut second = list_tools_request(complete_meta_for("second-client")); - if let ClientJsonRpcMessage::Request(request) = &mut second { - request.id = RequestId::Number(2); - } - client.send(second).await.expect("send second request"); - assert!(matches!( - client.receive().await, - Some(ServerJsonRpcMessage::Response(_)) - )); - - assert_eq!( - *seen_clients.lock().expect("seen clients lock"), - ["first-client", "second-client"] - ); - server_task - .await - .expect("server task") - .cancel() - .await - .expect("cancel server"); -} - -#[tokio::test] -async fn stateless_server_rejects_malformed_metadata_opener() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let server_task = tokio::spawn(async move { StatelessServer.serve(server_transport).await }); - let mut client = IntoTransport::::into_transport(client_transport); - - let mut request = ListToolsRequest { - method: Default::default(), - params: None, - extensions: Default::default(), - }; - let malformed: RequestMetaObject = serde_json::from_value(serde_json::json!({ - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientInfo": "wrong", - "io.modelcontextprotocol/clientCapabilities": null - })) - .unwrap(); - request.extensions.insert(malformed); - client - .send(ClientJsonRpcMessage::request( - ClientRequest::ListToolsRequest(request), - RequestId::Number(1), - )) - .await - .expect("send list tools"); - let Err(error) = server_task.await.expect("server task") else { - panic!("malformed opener should not start a session"); - }; - assert!(matches!( - error, - ServerInitializeError::ExpectedInitializeRequest(Some(_)) - )); -} diff --git a/vendor/rmcp/tests/test_stdio_response_concurrency.rs b/vendor/rmcp/tests/test_stdio_response_concurrency.rs deleted file mode 100644 index 2e1e40a22..000000000 --- a/vendor/rmcp/tests/test_stdio_response_concurrency.rs +++ /dev/null @@ -1,199 +0,0 @@ -#![cfg(not(feature = "local"))] - -use std::{collections::BTreeSet, process::Stdio, time::Duration}; - -use rmcp::{ - ErrorData as McpError, ServerHandler, ServiceExt, - model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities, - ServerInfo, - }, -}; -use serde_json::{Value, json}; -use tokio::{ - io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}, - process::{Child, Command}, -}; - -const HELPER_ENV: &str = "RMCP_STDIO_RESPONSE_CONCURRENCY_HELPER"; -const REQUESTS: usize = 200; -const RESPONSE_BYTES: usize = 64 * 1024; -const READ_TIMEOUT: Duration = Duration::from_secs(10); - -#[tokio::test(flavor = "multi_thread", worker_threads = 8)] -async fn raw_client_concurrent_large_stdio_tool_responses_are_not_lost() -> anyhow::Result<()> { - // Spawn the same test binary as a child process so the server uses real - // stdio pipes, not an in-process transport. - let mut child = spawn_helper(); - let mut writer = child.stdin.take().expect("helper stdin"); - let stdout = child.stdout.take().expect("helper stdout"); - let mut reader = BufReader::new(stdout); - - // Complete the normal MCP initialization flow before stressing tools/call. - send_json( - &mut writer, - &json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": { "name": "raw-test-client", "version": "0.0.0" } - } - }), - ) - .await?; - read_response_for_id(&mut reader, 1).await?; - - send_json( - &mut writer, - &json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), - ) - .await?; - - // Send the whole batch before reading responses. This creates concurrent - // request handling and concurrent response production inside rmcp. - for id in request_ids() { - send_json( - &mut writer, - &json!({ - "jsonrpc": "2.0", - "id": id, - "method": "tools/call", - "params": { "name": "large-response", "arguments": {} } - }), - ) - .await?; - } - - let missing_ids = read_responses_for_ids(&mut reader, request_ids(), READ_TIMEOUT).await?; - assert!( - missing_ids.is_empty(), - "missing response ids: {missing_ids:?}" - ); - - drop(writer); - wait_for_child(&mut child).await; - Ok(()) -} - -struct LargeResponseServer; - -impl ServerHandler for LargeResponseServer { - #[allow(deprecated)] - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - } - - async fn call_tool( - &self, - request: CallToolRequestParams, - _context: rmcp::service::RequestContext, - ) -> Result { - assert_eq!("large-response", request.name.as_ref()); - Ok(CallToolResult::success(vec![ContentBlock::text("x".repeat(RESPONSE_BYTES))]).into()) - } -} - -#[tokio::test] -async fn stdio_response_concurrency_helper() -> anyhow::Result<()> { - // The parent test starts this same binary with HELPER_ENV=1 so it can act - // as a small MCP server connected over real stdin/stdout pipes. - if std::env::var(HELPER_ENV).as_deref() != Ok("1") { - return Ok(()); - } - let server = LargeResponseServer.serve(rmcp::transport::stdio()).await?; - server.waiting().await?; - Ok(()) -} - -fn spawn_helper() -> Child { - let exe = std::env::current_exe().expect("current test exe"); - Command::new(exe) - .arg("--exact") - .arg("stdio_response_concurrency_helper") - .arg("--quiet") - .arg("--nocapture") - .arg("--test-threads") - .arg("1") - .env(HELPER_ENV, "1") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .kill_on_drop(true) - .spawn() - .expect("spawn helper") -} - -async fn wait_for_child(child: &mut Child) { - let _ = tokio::time::timeout(Duration::from_secs(2), child.wait()).await; - if child.id().is_some() { - let _ = child.kill().await; - } -} - -fn request_ids() -> BTreeSet { - (1000..1000 + REQUESTS as u64).collect() -} - -async fn send_json(writer: &mut W, message: &Value) -> anyhow::Result<()> -where - W: AsyncWrite + Unpin, -{ - let serialized = serde_json::to_string(message)?; - writer.write_all(serialized.as_bytes()).await?; - writer.write_all(b"\n").await?; - writer.flush().await?; - Ok(()) -} - -async fn read_response_for_id(reader: &mut BufReader, expected_id: u64) -> anyhow::Result<()> -where - R: tokio::io::AsyncRead + Unpin, -{ - let missing = - read_responses_for_ids(reader, BTreeSet::from([expected_id]), READ_TIMEOUT).await?; - if missing.is_empty() { - Ok(()) - } else { - anyhow::bail!("missing response id {expected_id}") - } -} - -async fn read_responses_for_ids( - reader: &mut BufReader, - mut pending_ids: BTreeSet, - timeout: Duration, -) -> anyhow::Result> -where - R: tokio::io::AsyncRead + Unpin, -{ - let deadline = tokio::time::Instant::now() + timeout; - while !pending_ids.is_empty() { - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); - if remaining.is_zero() { - break; - } - let mut line = String::new(); - let Ok(read_result) = tokio::time::timeout(remaining, reader.read_line(&mut line)).await - else { - break; - }; - let read = read_result?; - if read == 0 { - break; - } - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let Ok(value) = serde_json::from_str::(trimmed) else { - continue; - }; - if let Some(id) = value.get("id").and_then(Value::as_u64) { - pending_ids.remove(&id); - } - } - Ok(pending_ids) -} diff --git a/vendor/rmcp/tests/test_streamable_http_4xx_error_body.rs b/vendor/rmcp/tests/test_streamable_http_4xx_error_body.rs deleted file mode 100644 index ea49a4172..000000000 --- a/vendor/rmcp/tests/test_streamable_http_4xx_error_body.rs +++ /dev/null @@ -1,121 +0,0 @@ -#![cfg(all( - feature = "transport-streamable-http-client", - feature = "transport-streamable-http-client-reqwest", - not(feature = "local") -))] - -use std::{collections::HashMap, sync::Arc}; - -use rmcp::{ - model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, - transport::streamable_http_client::{ - StreamableHttpClient, StreamableHttpError, StreamableHttpPostResponse, - }, -}; - -/// Spin up a minimal axum server that always responds with the given status, -/// content-type, and body — no MCP logic involved. -async fn spawn_mock_server(status: u16, content_type: &'static str, body: &'static str) -> String { - use axum::{Router, body::Body, http::Response, routing::post}; - - let router = Router::new().route( - "/mcp", - post(move || async move { - Response::builder() - .status(status) - .header("content-type", content_type) - .body(Body::from(body)) - .unwrap() - }), - ); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, router).await.unwrap(); - }); - - format!("http://{addr}/mcp") -} - -fn ping_message() -> ClientJsonRpcMessage { - ClientJsonRpcMessage::request( - ClientRequest::PingRequest(PingRequest::default()), - RequestId::Number(1), - ) -} - -/// HTTP 4xx with Content-Type: application/json and a valid JSON-RPC error body -/// must be surfaced as `StreamableHttpPostResponse::Json`, not swallowed as a -/// transport error. -#[tokio::test] -async fn http_4xx_json_rpc_error_body_is_surfaced_as_json_response() { - let body = r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Invalid Request"}}"#; - let url = spawn_mock_server(400, "application/json", body).await; - - let client = reqwest::Client::new(); - let result = client - .post_message( - Arc::from(url.as_str()), - ping_message(), - None, - None, - HashMap::new(), - ) - .await; - - match result { - Ok(StreamableHttpPostResponse::Json(msg, _)) => { - let json = serde_json::to_value(&msg).unwrap(); - assert_eq!(json["error"]["code"], -32600); - assert_eq!(json["error"]["message"], "Invalid Request"); - } - other => panic!("expected Json response, got: {other:?}"), - } -} - -/// HTTP 4xx with non-JSON content-type must still return `UnexpectedServerResponse` -/// (no regression on the original error path). -#[tokio::test] -async fn http_4xx_non_json_body_returns_unexpected_server_response() { - let url = spawn_mock_server(400, "text/plain", "Bad Request").await; - - let client = reqwest::Client::new(); - let result = client - .post_message( - Arc::from(url.as_str()), - ping_message(), - None, - None, - HashMap::new(), - ) - .await; - - match result { - Err(StreamableHttpError::UnexpectedServerResponse(_)) => {} - other => panic!("expected UnexpectedServerResponse, got: {other:?}"), - } -} - -/// HTTP 4xx with Content-Type: application/json but a body that is NOT a valid -/// JSON-RPC message must fall back to `UnexpectedServerResponse`. -#[tokio::test] -async fn http_4xx_malformed_json_body_falls_back_to_unexpected_server_response() { - let url = spawn_mock_server(400, "application/json", r#"{"error":"not jsonrpc"}"#).await; - - let client = reqwest::Client::new(); - let result = client - .post_message( - Arc::from(url.as_str()), - ping_message(), - None, - None, - HashMap::new(), - ) - .await; - - match result { - Err(StreamableHttpError::UnexpectedServerResponse(_)) => {} - other => panic!("expected UnexpectedServerResponse, got: {other:?}"), - } -} diff --git a/vendor/rmcp/tests/test_streamable_http_connection_reuse.rs b/vendor/rmcp/tests/test_streamable_http_connection_reuse.rs deleted file mode 100644 index 553448eae..000000000 --- a/vendor/rmcp/tests/test_streamable_http_connection_reuse.rs +++ /dev/null @@ -1,122 +0,0 @@ -#![cfg(not(feature = "local"))] - -use std::time::Instant; - -use rmcp::{ - ServerHandler, ServiceExt, - handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{CallToolRequestParams, ClientInfo, ServerCapabilities, ServerInfo}, - schemars, tool, tool_handler, tool_router, - transport::{ - StreamableHttpClientTransport, - streamable_http_client::StreamableHttpClientTransportConfig, - streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, - }, - }, -}; -use tokio_util::sync::CancellationToken; - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -struct SumRequest { - a: i32, - b: i32, -} - -#[derive(Debug, Clone)] -struct SumServer { - tool_router: ToolRouter, -} - -impl SumServer { - fn new() -> Self { - Self { - tool_router: Self::tool_router(), - } - } -} - -#[tool_router] -impl SumServer { - #[tool(description = "Sum two numbers")] - fn sum(&self, Parameters(SumRequest { a, b }): Parameters) -> String { - (a + b).to_string() - } -} - -#[tool_handler(router = self.tool_router)] -impl ServerHandler for SumServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - } -} - -/// Verify that subsequent tool calls do not regress in latency due to -/// HTTP/1.1 connection pool exhaustion. Before the fix, each POST SSE -/// response was dropped without fully consuming the body, preventing -/// connection reuse and forcing a new TCP connection (~40 ms) per call. -#[tokio::test] -async fn test_subsequent_tool_calls_reuse_connections() -> anyhow::Result<()> { - let ct = CancellationToken::new(); - - let service: StreamableHttpService = StreamableHttpService::new( - || Ok(SumServer::new()), - Default::default(), - StreamableHttpServerConfig::default() - .with_sse_keep_alive(None) - .with_cancellation_token(ct.child_token()), - ); - - let router = axum::Router::new().nest_service("/mcp", service); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - - let server_handle = tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - - let transport = StreamableHttpClientTransport::from_config( - StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")), - ); - let client = ClientInfo::default().serve(transport).await?; - - // Warm up: first call may include one-time setup costs. - let args: serde_json::Map = - serde_json::from_value(serde_json::json!({"a": 1, "b": 2}))?; - let _ = client - .call_tool(CallToolRequestParams::new("sum").with_arguments(args)) - .await?; - - // Measure subsequent calls. - let mut durations = Vec::new(); - for i in 0..5i32 { - let args: serde_json::Map = - serde_json::from_value(serde_json::json!({"a": i, "b": i + 1}))?; - let start = Instant::now(); - let result = client - .call_tool(CallToolRequestParams::new("sum").with_arguments(args)) - .await?; - let elapsed = start.elapsed(); - durations.push(elapsed); - - assert!(result.is_error != Some(true)); - } - - let _ = client.cancel().await; - ct.cancel(); - server_handle.await?; - - // With connection reuse, localhost calls should complete well under 20 ms. - // Before the fix, they consistently took ~42 ms due to new TCP connections. - let max_allowed = std::time::Duration::from_millis(20); - for d in &durations { - assert!(*d < max_allowed); - } - - Ok(()) -} diff --git a/vendor/rmcp/tests/test_streamable_http_disconnect_cancel.rs b/vendor/rmcp/tests/test_streamable_http_disconnect_cancel.rs deleted file mode 100644 index 31e74137d..000000000 --- a/vendor/rmcp/tests/test_streamable_http_disconnect_cancel.rs +++ /dev/null @@ -1,196 +0,0 @@ -#![cfg(all( - feature = "server", - feature = "transport-streamable-http-server", - feature = "reqwest", - not(feature = "local") -))] - -//! Regression test for #857: when a stateless streamable-HTTP client disconnects -//! (drops the response) while a tool handler is still awaiting, the per-request -//! `RequestContext::ct` should fire so the handler can cancel cooperatively. -//! -//! Stateless requests are one-shot (no session, no resumption), so a dropped -//! response is terminal and safe to cancel — unlike the stateful/resumable path, -//! where a disconnect may be recovered via `Last-Event-ID`. - -use std::{sync::Arc, time::Duration}; - -use rmcp::{ - ErrorData as McpError, RoleServer, ServerHandler, - model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities, - ServerInfo, - }, - service::RequestContext, - transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, - }, -}; -use tokio::sync::Notify; -use tokio_util::sync::CancellationToken; - -#[derive(Clone)] -struct CancelProbe { - started: Arc, - cancelled: Arc, -} - -impl ServerHandler for CancelProbe { - #[allow(deprecated)] - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - } - - async fn call_tool( - &self, - _request: CallToolRequestParams, - context: RequestContext, - ) -> Result { - self.started.notify_one(); - // Wait until the per-request cancellation token fires, or give up after a - // generous timeout so a buggy build fails via the outer assertion rather - // than hanging the test. - tokio::select! { - _ = context.ct.cancelled() => { - self.cancelled.notify_one(); - Ok(CallToolResult::success(vec![ContentBlock::text("cancelled")]).into()) - } - _ = tokio::time::sleep(Duration::from_secs(30)) => { - Ok(CallToolResult::success(vec![ContentBlock::text("ran_to_completion")]).into()) - } - } - } -} - -const CALL_BODY: &str = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"wait_for_cancel","arguments":{}}}"#; - -struct TestServer { - url: String, - server_ct: CancellationToken, - started: Arc, - cancelled: Arc, -} - -async fn spawn_stateless_server(json_response: bool) -> anyhow::Result { - let started = Arc::new(Notify::new()); - let cancelled = Arc::new(Notify::new()); - let probe = CancelProbe { - started: started.clone(), - cancelled: cancelled.clone(), - }; - - let server_ct = CancellationToken::new(); - let config = StreamableHttpServerConfig::default() - .with_legacy_session_mode(false) - .with_json_response(json_response) - // A short keep-alive lets the SSE server notice a dropped connection - // quickly (hyper only observes the disconnect on its next write). - .with_sse_keep_alive(Some(Duration::from_millis(100))) - .with_cancellation_token(server_ct.child_token()); - - let service: StreamableHttpService = - StreamableHttpService::new( - move || Ok(probe.clone()), - Arc::new(LocalSessionManager::default()), - config, - ); - let router = axum::Router::new().nest_service("/mcp", service); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - tokio::spawn({ - let ct = server_ct.clone(); - async move { - let _ = axum::serve(listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - - Ok(TestServer { - url: format!("http://{addr}/mcp"), - server_ct, - started, - cancelled, - }) -} - -/// SSE mode: the response is a stream; dropping it (client disconnect) must fire -/// the handler's cancellation token. -#[tokio::test] -async fn stateless_sse_client_disconnect_cancels_request() -> anyhow::Result<()> { - let server = spawn_stateless_server(false).await?; - let client = reqwest::Client::builder() - .pool_max_idle_per_host(0) - .build()?; - - // A single self-contained tools/call (no session, no initialize handshake). - let call = client - .post(&server.url) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("MCP-Protocol-Version", "2025-03-26") - .body(CALL_BODY) - .send() - .await?; - assert!( - call.status().is_success(), - "tools/call failed: {:?}", - call.status() - ); - - tokio::time::timeout(Duration::from_secs(5), server.started.notified()) - .await - .expect("tool handler should start"); - - // Client disconnects mid-call: drop the streaming response (and the client). - drop(call); - drop(client); - - tokio::time::timeout(Duration::from_secs(10), server.cancelled.notified()) - .await - .expect("RequestContext::ct should fire after client disconnect (SSE)"); - - server.server_ct.cancel(); - Ok(()) -} - -/// JSON-direct mode: the server holds the connection open awaiting the single -/// response. A client that disconnects while the handler is running must still -/// fire the handler's cancellation token. -#[tokio::test] -async fn stateless_json_client_disconnect_cancels_request() -> anyhow::Result<()> { - let server = spawn_stateless_server(true).await?; - let client = reqwest::Client::builder() - .pool_max_idle_per_host(0) - .build()?; - - // In JSON mode the server does not respond until the handler completes, so - // the request stays pending; drive it from a task we can abort to disconnect. - let url = server.url.clone(); - let req_task = tokio::spawn(async move { - let _ = client - .post(&url) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("MCP-Protocol-Version", "2025-03-26") - .body(CALL_BODY) - .send() - .await; - // Keep the client alive until the request future is dropped by abort(). - drop(client); - }); - - tokio::time::timeout(Duration::from_secs(5), server.started.notified()) - .await - .expect("tool handler should start"); - - // Client disconnects: abort the in-flight request, closing the connection. - req_task.abort(); - - tokio::time::timeout(Duration::from_secs(10), server.cancelled.notified()) - .await - .expect("RequestContext::ct should fire after client disconnect (JSON)"); - - server.server_ct.cancel(); - Ok(()) -} diff --git a/vendor/rmcp/tests/test_streamable_http_empty_2xx_notification.rs b/vendor/rmcp/tests/test_streamable_http_empty_2xx_notification.rs deleted file mode 100644 index c8a153345..000000000 --- a/vendor/rmcp/tests/test_streamable_http_empty_2xx_notification.rs +++ /dev/null @@ -1,48 +0,0 @@ -#![cfg(all( - feature = "transport-streamable-http-client", - feature = "transport-streamable-http-client-reqwest", - not(feature = "local") -))] - -use std::{collections::HashMap, sync::Arc}; - -use rmcp::{ - model::{ClientJsonRpcMessage, ClientNotification, InitializedNotification}, - transport::streamable_http_client::{StreamableHttpClient, StreamableHttpPostResponse}, -}; - -async fn spawn_empty_ok_server() -> String { - use axum::{Router, http::StatusCode, routing::post}; - - let router = Router::new().route("/mcp", post(|| async { StatusCode::OK })); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, router).await.unwrap(); - }); - - format!("http://{addr}/mcp") -} - -#[tokio::test] -async fn empty_success_response_to_notification_is_accepted() { - let url = spawn_empty_ok_server().await; - let client = reqwest::Client::new(); - let result = client - .post_message( - Arc::from(url.as_str()), - ClientJsonRpcMessage::notification(ClientNotification::InitializedNotification( - InitializedNotification::default(), - )), - None, - None, - HashMap::new(), - ) - .await; - - match result { - Ok(StreamableHttpPostResponse::Accepted) => {} - other => panic!("expected Accepted, got: {other:?}"), - } -} diff --git a/vendor/rmcp/tests/test_streamable_http_event_store.rs b/vendor/rmcp/tests/test_streamable_http_event_store.rs deleted file mode 100644 index cc88f1077..000000000 --- a/vendor/rmcp/tests/test_streamable_http_event_store.rs +++ /dev/null @@ -1,372 +0,0 @@ -#![cfg(all( - feature = "client", - feature = "server", - feature = "transport-streamable-http-client-reqwest", - feature = "transport-streamable-http-server", - not(feature = "local") -))] - -use std::{ - collections::HashMap, - sync::{ - Arc, - atomic::{AtomicU64, Ordering}, - }, -}; - -use futures::StreamExt; -use rmcp::{ - ErrorData, ServerHandler, - model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, - ProgressNotificationParam, ServerCapabilities, ServerInfo, - }, - service::RequestContext, - transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, - session::{ - EventStore, EventStoreError, EventStream, ServerSseMessage, SessionState, SessionStore, - SessionStoreError, local::LocalSessionManager, never::NeverSessionManager, - }, - }, -}; -use tokio::sync::RwLock; -use tokio_util::sync::CancellationToken; - -#[derive(Clone, Default)] -struct InMemorySessionStore(Arc>>); - -#[async_trait::async_trait] -impl SessionStore for InMemorySessionStore { - async fn load(&self, session_id: &str) -> Result, SessionStoreError> { - Ok(self.0.read().await.get(session_id).cloned()) - } - - async fn store(&self, session_id: &str, state: &SessionState) -> Result<(), SessionStoreError> { - self.0 - .write() - .await - .insert(session_id.to_owned(), state.clone()); - Ok(()) - } - - async fn delete(&self, session_id: &str) -> Result<(), SessionStoreError> { - self.0.write().await.remove(session_id); - Ok(()) - } -} - -#[derive(Clone)] -struct StoredEvent { - stream_id: String, - event: ServerSseMessage, -} - -#[derive(Clone, Default)] -struct InMemoryEventStore { - events: Arc>>, - next_id: Arc, -} - -#[async_trait::async_trait] -impl EventStore for InMemoryEventStore { - async fn store_event( - &self, - stream_id: &str, - event: &ServerSseMessage, - ) -> Result { - let event_id = format!("event-{}", self.next_id.fetch_add(1, Ordering::Relaxed)); - let mut event = event.clone(); - event.event_id = Some(event_id.clone()); - self.events.write().await.push(StoredEvent { - stream_id: stream_id.to_owned(), - event, - }); - Ok(event_id) - } - - async fn replay_events_after( - &self, - last_event_id: &str, - ) -> Result { - let events = self.events.read().await; - let last_index = events - .iter() - .position(|stored| stored.event.event_id.as_deref() == Some(last_event_id)) - .ok_or_else(|| std::io::Error::other("event not found"))?; - let stream_id = events[last_index].stream_id.clone(); - let replay = events - .iter() - .skip(last_index + 1) - .filter(|stored| stored.stream_id == stream_id) - .map(|stored| stored.event.clone()) - .collect::>(); - Ok(Box::pin(futures::stream::iter(replay))) - } -} - -#[derive(Clone)] -struct ProgressServer; - -impl ServerHandler for ProgressServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - } - - async fn call_tool( - &self, - request: CallToolRequestParams, - context: RequestContext, - ) -> Result { - if request.name == "progress" || request.name == "slow-progress" { - let progress_token = context - .meta - .get_progress_token() - .expect("request includes progressToken"); - context - .peer - .notify_progress( - ProgressNotificationParam::new(progress_token, 50.0) - .with_total(100.0) - .with_message("working"), - ) - .await - .expect("progress notification is delivered"); - } - if request.name == "slow-progress" { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - } - Ok(CallToolResult::success(vec![ContentBlock::text("done")]).into()) - } -} - -async fn spawn_server( - session_store: Arc, - event_store: Arc, - cancellation_token: &CancellationToken, - legacy_session_mode: bool, -) -> anyhow::Result<(String, tokio::task::JoinHandle<()>)> { - let config = { - let mut config = StreamableHttpServerConfig::default(); - config.sse_keep_alive = None; - config.legacy_session_mode = legacy_session_mode; - config.cancellation_token = cancellation_token.child_token(); - config.session_store = Some(session_store); - config - }; - let router = if legacy_session_mode { - let session_manager = - Arc::new(LocalSessionManager::default().with_event_store(event_store)); - let service = StreamableHttpService::new(|| Ok(ProgressServer), session_manager, config); - axum::Router::new().nest_service("/mcp", service) - } else { - let session_manager = - Arc::new(NeverSessionManager::default().with_event_store(event_store)); - let service = StreamableHttpService::new(|| Ok(ProgressServer), session_manager, config); - axum::Router::new().nest_service("/mcp", service) - }; - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; - let address = listener.local_addr()?; - let handle = tokio::spawn({ - let cancellation_token = cancellation_token.clone(); - async move { - let _ = axum::serve(listener, router) - .with_graceful_shutdown(async move { cancellation_token.cancelled_owned().await }) - .await; - } - }); - Ok((format!("http://{address}/mcp"), handle)) -} - -fn event_id_containing<'a>(body: &'a str, needle: &str) -> Option<&'a str> { - body.split("\n\n") - .find(|event| event.contains(needle))? - .lines() - .find_map(|line| line.strip_prefix("id: ")) -} - -#[tokio::test] -async fn restored_instance_replays_events_from_shared_store() -> anyhow::Result<()> { - let session_store: Arc = Arc::new(InMemorySessionStore::default()); - let event_store: Arc = Arc::new(InMemoryEventStore::default()); - let http = reqwest::Client::new(); - - let cancellation_a = CancellationToken::new(); - let (url_a, server_a) = spawn_server( - session_store.clone(), - event_store.clone(), - &cancellation_a, - true, - ) - .await?; - let initialize = http - .post(&url_a) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#) - .send() - .await?; - let session_id = initialize - .headers() - .get("mcp-session-id") - .expect("initialize returns a session ID") - .to_str()? - .to_owned(); - let _ = initialize.text().await?; - - let initialized_status = http - .post(&url_a) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("Mcp-Session-Id", &session_id) - .header("Mcp-Protocol-Version", "2025-06-18") - .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) - .send() - .await? - .status(); - assert_eq!(initialized_status, reqwest::StatusCode::ACCEPTED); - - let original_body = http - .post(&url_a) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("Mcp-Session-Id", &session_id) - .header("Mcp-Protocol-Version", "2025-06-18") - .body(r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"progress","arguments":{},"_meta":{"progressToken":"progress-1"}}}"#) - .send() - .await? - .text() - .await?; - let progress_event_id = event_id_containing(&original_body, "notifications/progress") - .expect("progress event has a persisted event ID") - .to_owned(); - - cancellation_a.cancel(); - server_a.await?; - - let cancellation_b = CancellationToken::new(); - let (url_b, server_b) = spawn_server(session_store, event_store, &cancellation_b, true).await?; - let replay = http - .get(&url_b) - .header("Accept", "text/event-stream") - .header("Mcp-Session-Id", &session_id) - .header("Mcp-Protocol-Version", "2025-06-18") - .header("Last-Event-ID", progress_event_id) - .send() - .await?; - assert_eq!(replay.status(), reqwest::StatusCode::OK); - let replay_body = replay.text().await?; - assert!( - replay_body.contains(r#""id":2"#), - "instance B should replay the final response stored by instance A: {replay_body}" - ); - - cancellation_b.cancel(); - server_b.await?; - Ok(()) -} - -#[tokio::test] -async fn stateless_instance_replays_events_from_shared_store() -> anyhow::Result<()> { - let session_store: Arc = Arc::new(InMemorySessionStore::default()); - let event_store = Arc::new(InMemoryEventStore::default()); - let http = reqwest::Client::new(); - - let cancellation_a = CancellationToken::new(); - let (url_a, server_a) = spawn_server( - session_store.clone(), - event_store.clone(), - &cancellation_a, - false, - ) - .await?; - let original = http - .post(&url_a) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("MCP-Protocol-Version", "2026-07-28") - .header("Mcp-Method", "tools/call") - .header("Mcp-Name", "slow-progress") - .body( - r#"{ - "jsonrpc":"2.0", - "id":2, - "method":"tools/call", - "params":{ - "name":"slow-progress", - "arguments":{}, - "_meta":{ - "progressToken":"progress-1", - "io.modelcontextprotocol/protocolVersion":"2026-07-28", - "io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"}, - "io.modelcontextprotocol/clientCapabilities":{} - } - } - }"#, - ) - .send() - .await?; - assert_eq!(original.status(), reqwest::StatusCode::OK); - assert!( - original.headers().get("mcp-session-id").is_none(), - "stateless response must not create a session" - ); - let mut body = original.bytes_stream(); - let mut received = String::new(); - let progress_event_id = tokio::time::timeout(std::time::Duration::from_secs(2), async { - loop { - let chunk = body - .next() - .await - .expect("response remains open until progress arrives")?; - received.push_str(&String::from_utf8_lossy(&chunk)); - if let Some(event_id) = event_id_containing(&received, "notifications/progress") { - return Ok::<_, reqwest::Error>(event_id.to_owned()); - } - } - }) - .await??; - drop(body); - - tokio::time::timeout(std::time::Duration::from_secs(2), async { - loop { - let stored_response = event_store.events.read().await.iter().any(|stored| { - stored.event.message.as_ref().is_some_and(|message| { - matches!( - message.as_ref(), - rmcp::model::ServerJsonRpcMessage::Response(_) - ) - }) - }); - if stored_response { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(5)).await; - } - }) - .await?; - - cancellation_a.cancel(); - server_a.await?; - - let cancellation_b = CancellationToken::new(); - let (url_b, server_b) = - spawn_server(session_store, event_store, &cancellation_b, false).await?; - let replay = http - .get(&url_b) - .header("Accept", "text/event-stream") - .header("MCP-Protocol-Version", "2026-07-28") - .header("Last-Event-ID", progress_event_id) - .send() - .await?; - assert_eq!(replay.status(), reqwest::StatusCode::OK); - let replay_body = replay.text().await?; - assert!( - replay_body.contains(r#""id":2"#), - "instance B should replay the stateless response stored by instance A: {replay_body}" - ); - - cancellation_b.cancel(); - server_b.await?; - Ok(()) -} diff --git a/vendor/rmcp/tests/test_streamable_http_idle_timeout_log.rs b/vendor/rmcp/tests/test_streamable_http_idle_timeout_log.rs deleted file mode 100644 index e0559b5b8..000000000 --- a/vendor/rmcp/tests/test_streamable_http_idle_timeout_log.rs +++ /dev/null @@ -1,252 +0,0 @@ -#![cfg(all( - feature = "transport-streamable-http-server", - feature = "transport-streamable-http-client-reqwest", - not(feature = "local") -))] - -use std::{ - sync::{Arc, Mutex}, - time::Duration, -}; - -use rmcp::transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, - session::{SessionManager, local::LocalSessionManager}, -}; -use tokio_util::sync::CancellationToken; -use tracing_subscriber::layer::SubscriberExt; - -mod common; -use common::calculator::Calculator; - -struct CapturedEvent { - level: tracing::Level, - target: String, - message: String, -} - -struct CapturingLayer { - events: Arc>>, -} - -impl tracing_subscriber::Layer for CapturingLayer { - fn on_event( - &self, - event: &tracing::Event<'_>, - _ctx: tracing_subscriber::layer::Context<'_, S>, - ) { - let mut visitor = MessageVisitor(String::new()); - event.record(&mut visitor); - self.events.lock().unwrap().push(CapturedEvent { - level: *event.metadata().level(), - target: event.metadata().target().to_string(), - message: visitor.0, - }); - } -} - -struct MessageVisitor(String); - -impl tracing::field::Visit for MessageVisitor { - fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { - if field.name() == "message" { - self.0 = format!("{:?}", value); - } - } -} - -#[tokio::test(flavor = "current_thread")] -async fn test_keep_alive_timeout_does_not_emit_error_log() { - let events = Arc::new(Mutex::new(Vec::::new())); - - let subscriber = tracing_subscriber::registry().with(CapturingLayer { - events: events.clone(), - }); - - let _guard = tracing::subscriber::set_default(subscriber); - - let ct = CancellationToken::new(); - let mut session_manager = LocalSessionManager::default(); - session_manager.session_config.keep_alive = Some(Duration::from_millis(200)); - let session_manager = Arc::new(session_manager); - - let service = StreamableHttpService::new( - || Ok(Calculator::new()), - session_manager.clone(), - StreamableHttpServerConfig::default() - .with_sse_keep_alive(None) - .with_cancellation_token(ct.child_token()), - ); - - let router = axum::Router::new().nest_service("/mcp", service); - let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = tcp_listener.local_addr().unwrap(); - - tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(tcp_listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - - let client = reqwest::Client::new(); - - let response = client - .post(format!("http://{addr}/mcp")) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#) - .send() - .await - .unwrap(); - assert_eq!(response.status(), 200); - let session_id = response.headers()["mcp-session-id"] - .to_str() - .unwrap() - .to_string(); - - client - .post(format!("http://{addr}/mcp")) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("mcp-session-id", &session_id) - .header("Mcp-Protocol-Version", "2025-06-18") - .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) - .send() - .await - .unwrap(); - - tokio::time::sleep(Duration::from_millis(400)).await; - - // Wait until close_session() has completed so all logs are captured. - let session_id_parsed: Arc = Arc::from(session_id.as_str()); - for _ in 0..20 { - if !session_manager - .has_session(&session_id_parsed) - .await - .unwrap() - { - break; - } - tokio::time::sleep(Duration::from_millis(50)).await; - } - assert!( - !session_manager - .has_session(&session_id_parsed) - .await - .unwrap(), - "session should have been removed after idle reap" - ); - - let captured = events.lock().unwrap(); - - let error_events: Vec<_> = captured - .iter() - .filter(|e| e.level == tracing::Level::ERROR && e.target.starts_with("rmcp")) - .collect(); - assert!( - error_events.is_empty(), - "idle reap should not produce any ERROR logs, found {}: {:?}", - error_events.len(), - error_events.iter().map(|e| &e.message).collect::>() - ); - - let debug_events: Vec<_> = captured - .iter() - .filter(|e| { - e.level == tracing::Level::DEBUG - && e.target.starts_with("rmcp") - && e.message.contains("IdleTimeout") - }) - .collect(); - assert!( - !debug_events.is_empty(), - "expected a DEBUG log with IdleTimeout, but found none" - ); - - ct.cancel(); -} - -#[tokio::test(flavor = "current_thread")] -async fn test_explicit_close_on_live_session_succeeds() { - let ct = CancellationToken::new(); - let mut session_manager = LocalSessionManager::default(); - session_manager.session_config.keep_alive = Some(Duration::from_secs(60)); - let session_manager = Arc::new(session_manager); - - let service = StreamableHttpService::new( - || Ok(Calculator::new()), - session_manager.clone(), - StreamableHttpServerConfig::default() - .with_sse_keep_alive(None) - .with_cancellation_token(ct.child_token()), - ); - - let router = axum::Router::new().nest_service("/mcp", service); - let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = tcp_listener.local_addr().unwrap(); - - tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(tcp_listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - - let client = reqwest::Client::new(); - - let response = client - .post(format!("http://{addr}/mcp")) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#) - .send() - .await - .unwrap(); - assert_eq!(response.status(), 200); - let session_id = response.headers()["mcp-session-id"] - .to_str() - .unwrap() - .to_string(); - - client - .post(format!("http://{addr}/mcp")) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("mcp-session-id", &session_id) - .header("Mcp-Protocol-Version", "2025-06-18") - .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) - .send() - .await - .unwrap(); - - let session_id_parsed: Arc = Arc::from(session_id.as_str()); - - assert!( - session_manager - .has_session(&session_id_parsed) - .await - .unwrap(), - "session should exist before explicit close" - ); - - let result = session_manager.close_session(&session_id_parsed).await; - assert!( - result.is_ok(), - "close_session on a live worker should succeed: {result:?}" - ); - - assert!( - !session_manager - .has_session(&session_id_parsed) - .await - .unwrap(), - "session should not exist after explicit close" - ); - - ct.cancel(); -} diff --git a/vendor/rmcp/tests/test_streamable_http_init_timeout.rs b/vendor/rmcp/tests/test_streamable_http_init_timeout.rs deleted file mode 100644 index c9a2ee89b..000000000 --- a/vendor/rmcp/tests/test_streamable_http_init_timeout.rs +++ /dev/null @@ -1,63 +0,0 @@ -#![cfg(all(feature = "transport-streamable-http-server", not(feature = "local")))] - -use std::time::Duration; - -use rmcp::{ - model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, - transport::streamable_http_server::session::{SessionManager, local::LocalSessionManager}, -}; - -#[tokio::test] -async fn test_init_timeout_terminates_pre_init_session() -> anyhow::Result<()> { - let mut manager = LocalSessionManager::default(); - manager.session_config.init_timeout = Some(Duration::from_millis(200)); - - // Bind the transport so its drop-guard doesn't cancel the worker — we - // want termination via init_timeout, not via cancellation. - let (session_id, _transport) = manager.create_session().await?; - - tokio::time::sleep(Duration::from_millis(500)).await; - - let message = ClientJsonRpcMessage::request( - ClientRequest::PingRequest(PingRequest::default()), - RequestId::Number(1), - ); - let result = manager.initialize_session(&session_id, message).await; - - assert!( - result.is_err(), - "expected worker to be dead; got: {result:?}" - ); - - Ok(()) -} - -#[tokio::test] -async fn test_init_timeout_none_keeps_worker_alive() -> anyhow::Result<()> { - let mut manager = LocalSessionManager::default(); - manager.session_config.init_timeout = None; - - let (session_id, _transport) = manager.create_session().await?; - - tokio::time::sleep(Duration::from_millis(500)).await; - - let message = ClientJsonRpcMessage::request( - ClientRequest::PingRequest(PingRequest::default()), - RequestId::Number(1), - ); - // Liveness probe: a live worker accepts the send then stalls waiting for - // a handler response (none is wired up), tripping the outer timeout. A - // dead worker would fail the send and return immediately. - let probe = tokio::time::timeout( - Duration::from_millis(200), - manager.initialize_session(&session_id, message), - ) - .await; - - assert!( - probe.is_err(), - "expected worker to be alive; got: {probe:?}" - ); - - Ok(()) -} diff --git a/vendor/rmcp/tests/test_streamable_http_json_response.rs b/vendor/rmcp/tests/test_streamable_http_json_response.rs deleted file mode 100644 index b1c09f512..000000000 --- a/vendor/rmcp/tests/test_streamable_http_json_response.rs +++ /dev/null @@ -1,405 +0,0 @@ -#![cfg(not(feature = "local"))] -use rmcp::{ - ErrorData, ServerHandler, - model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, - ProgressNotificationParam, ServerCapabilities, ServerInfo, - }, - service::RequestContext, - transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, - }, -}; -use tokio_util::sync::CancellationToken; - -mod common; -use common::calculator::Calculator; - -const INIT_BODY: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#; -const CALL_WITH_PROGRESS_BODY: &str = r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"progress","arguments":{},"_meta":{"progressToken":"progress-test-1"}}}"#; -const NEGOTIATED_CALL_BODY: &str = r#"{ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": { - "name": "terminal", - "arguments": {}, - "_meta": { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientInfo": { - "name": "test", - "version": "1.0" - }, - "io.modelcontextprotocol/clientCapabilities": {} - } - } -}"#; -const NEGOTIATED_CALL_WITH_PROGRESS_BODY: &str = r#"{ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": { - "name": "progress", - "arguments": {}, - "_meta": { - "progressToken": "progress-test-1", - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientInfo": { - "name": "test", - "version": "1.0" - }, - "io.modelcontextprotocol/clientCapabilities": {} - } - } -}"#; - -#[derive(Clone)] -struct ProgressServer; - -impl ServerHandler for ProgressServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - } - - async fn call_tool( - &self, - request: CallToolRequestParams, - context: RequestContext, - ) -> Result { - if request.name == "progress" { - let progress_token = context - .meta - .get_progress_token() - .expect("request includes progressToken"); - context - .peer - .notify_progress( - ProgressNotificationParam::new(progress_token, 50.0) - .with_total(100.0) - .with_message("working"), - ) - .await - .expect("progress notification is delivered"); - } - Ok(CallToolResult::success(vec![ContentBlock::text("done")]).into()) - } -} - -async fn spawn_server( - config: StreamableHttpServerConfig, -) -> (reqwest::Client, String, CancellationToken) { - let ct = config.cancellation_token.clone(); - let service: StreamableHttpService = - StreamableHttpService::new(|| Ok(Calculator::new()), Default::default(), config); - - let router = axum::Router::new().nest_service("/mcp", service); - let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = tcp_listener.local_addr().unwrap(); - - tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(tcp_listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - - let client = reqwest::Client::new(); - let base_url = format!("http://{addr}/mcp"); - (client, base_url, ct) -} - -async fn spawn_progress_server( - config: StreamableHttpServerConfig, -) -> (reqwest::Client, String, CancellationToken) { - let ct = config.cancellation_token.clone(); - let service: StreamableHttpService = - StreamableHttpService::new(|| Ok(ProgressServer), Default::default(), config); - - let router = axum::Router::new().nest_service("/mcp", service); - let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = tcp_listener.local_addr().unwrap(); - - tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(tcp_listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - - let client = reqwest::Client::new(); - let base_url = format!("http://{addr}/mcp"); - (client, base_url, ct) -} - -#[tokio::test] -async fn stateless_json_response_returns_application_json() -> anyhow::Result<()> { - let ct = CancellationToken::new(); - let (client, url, ct) = spawn_server( - StreamableHttpServerConfig::default() - .with_legacy_session_mode(false) - .with_json_response(true) - .with_sse_keep_alive(None) - .with_cancellation_token(ct.child_token()), - ) - .await; - - let response = client - .post(&url) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .body(INIT_BODY) - .send() - .await?; - - assert_eq!(response.status(), 200); - - let content_type = response - .headers() - .get("content-type") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - assert!( - content_type.contains("application/json"), - "Expected application/json, got: {content_type}" - ); - - let body = response.text().await?; - let parsed: serde_json::Value = serde_json::from_str(&body)?; - assert_eq!(parsed["jsonrpc"], "2.0"); - assert_eq!(parsed["id"], 1); - assert!(parsed["result"].is_object(), "Expected result object"); - - ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn stateless_negotiated_terminal_response_returns_application_json() -> anyhow::Result<()> { - let ct = CancellationToken::new(); - let (client, url, ct) = spawn_progress_server( - StreamableHttpServerConfig::default() - .with_legacy_session_mode(false) - .with_json_response(true) - .with_sse_keep_alive(None) - .with_cancellation_token(ct.child_token()), - ) - .await; - - let response = client - .post(&url) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("MCP-Protocol-Version", "2026-07-28") - .header("Mcp-Method", "tools/call") - .header("Mcp-Name", "terminal") - .body(NEGOTIATED_CALL_BODY) - .send() - .await?; - - assert_eq!(response.status(), 200); - - let content_type = response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()) - .unwrap_or(""); - assert!( - content_type.contains("application/json"), - "Expected application/json, got: {content_type}" - ); - - let body: serde_json::Value = response.json().await?; - assert_eq!(body["id"], 2); - assert!(body["result"].is_object(), "Expected result object"); - - ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn stateless_json_response_falls_back_to_sse_for_progress() -> anyhow::Result<()> { - let ct = CancellationToken::new(); - let (client, url, ct) = spawn_progress_server( - StreamableHttpServerConfig::default() - .with_legacy_session_mode(false) - .with_json_response(true) - .with_sse_keep_alive(None) - .with_cancellation_token(ct.child_token()), - ) - .await; - - let response = client - .post(&url) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .body(CALL_WITH_PROGRESS_BODY) - .send() - .await?; - - assert_eq!(response.status(), 200); - - let content_type = response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()) - .unwrap_or(""); - assert!( - content_type.contains("text/event-stream"), - "Expected SSE fallback, got: {content_type}" - ); - - let body = response.text().await?; - let messages: Vec = body - .lines() - .filter_map(|line| line.strip_prefix("data:")) - .map(str::trim) - .filter(|data| !data.is_empty()) - .map(serde_json::from_str) - .collect::>()?; - assert_eq!(messages.len(), 2, "Expected progress and result: {body}"); - assert_eq!(messages[0]["method"], "notifications/progress"); - assert_eq!(messages[1]["id"], 2); - assert!( - messages[1]["result"].is_object(), - "Expected result object: {body}" - ); - - ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn stateless_negotiated_json_response_falls_back_to_sse_for_progress() -> anyhow::Result<()> { - let ct = CancellationToken::new(); - let (client, url, ct) = spawn_progress_server( - StreamableHttpServerConfig::default() - .with_legacy_session_mode(false) - .with_json_response(true) - .with_sse_keep_alive(None) - .with_cancellation_token(ct.child_token()), - ) - .await; - - let response = client - .post(&url) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("MCP-Protocol-Version", "2026-07-28") - .header("Mcp-Method", "tools/call") - .header("Mcp-Name", "progress") - .body(NEGOTIATED_CALL_WITH_PROGRESS_BODY) - .send() - .await?; - - assert_eq!(response.status(), 200); - - let content_type = response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()) - .unwrap_or(""); - assert!( - content_type.contains("text/event-stream"), - "Expected SSE fallback, got: {content_type}" - ); - - let body = response.text().await?; - let messages: Vec = body - .lines() - .filter_map(|line| line.strip_prefix("data:")) - .map(str::trim) - .filter(|data| !data.is_empty()) - .map(serde_json::from_str) - .collect::>()?; - assert_eq!(messages.len(), 2, "Expected progress and result: {body}"); - assert_eq!(messages[0]["method"], "notifications/progress"); - assert_eq!(messages[1]["id"], 2); - assert!( - messages[1]["result"].is_object(), - "Expected result object: {body}" - ); - - ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn stateless_sse_mode_default_unchanged() -> anyhow::Result<()> { - let ct = CancellationToken::new(); - let (client, url, ct) = spawn_server( - StreamableHttpServerConfig::default() - .with_legacy_session_mode(false) - .with_sse_keep_alive(None) - .with_cancellation_token(ct.child_token()), - ) - .await; - - let response = client - .post(&url) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .body(INIT_BODY) - .send() - .await?; - - assert_eq!(response.status(), 200); - - let content_type = response - .headers() - .get("content-type") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - assert!( - content_type.contains("text/event-stream"), - "Expected text/event-stream, got: {content_type}" - ); - - let body = response.text().await?; - assert!( - body.contains("data:"), - "Expected SSE framing (data: prefix), got: {body}" - ); - - ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn json_response_ignored_in_legacy_session_mode() -> anyhow::Result<()> { - let ct = CancellationToken::new(); - // json_response: true has no effect when legacy_session_mode: true — server still uses SSE - let (client, url, ct) = spawn_server( - StreamableHttpServerConfig::default() - .with_json_response(true) - .with_sse_keep_alive(None) - .with_cancellation_token(ct.child_token()), - ) - .await; - - let response = client - .post(&url) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .body(INIT_BODY) - .send() - .await?; - - assert_eq!(response.status(), 200); - - let content_type = response - .headers() - .get("content-type") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - assert!( - content_type.contains("text/event-stream"), - "Stateful mode should always use SSE regardless of json_response, got: {content_type}" - ); - - ct.cancel(); - Ok(()) -} diff --git a/vendor/rmcp/tests/test_streamable_http_priming.rs b/vendor/rmcp/tests/test_streamable_http_priming.rs deleted file mode 100644 index 1e0e5e5f9..000000000 --- a/vendor/rmcp/tests/test_streamable_http_priming.rs +++ /dev/null @@ -1,499 +0,0 @@ -#![cfg(not(feature = "local"))] -use std::time::Duration; - -use rmcp::transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, - session::{SessionId, local::LocalSessionManager}, -}; -use tokio_util::sync::CancellationToken; - -mod common; -use common::calculator::Calculator; - -#[tokio::test] -async fn test_priming_on_stream_start() -> anyhow::Result<()> { - let ct = CancellationToken::new(); - - // legacy_session_mode: true automatically enables priming with DEFAULT_RETRY_INTERVAL (3 seconds) - let service: StreamableHttpService = - StreamableHttpService::new( - || Ok(Calculator::new()), - Default::default(), - StreamableHttpServerConfig::default() - .with_sse_keep_alive(None) - .with_cancellation_token(ct.child_token()), - ); - - let router = axum::Router::new().nest_service("/mcp", service); - let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; - let addr = tcp_listener.local_addr()?; - - let handle = tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(tcp_listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - - // Send initialize request - let client = reqwest::Client::new(); - let response = client - .post(format!("http://{addr}/mcp")) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#) - .send() - .await?; - - assert_eq!(response.status(), 200); - - let body = response.text().await?; - - // Split SSE events by double newline - let events: Vec<&str> = body.split("\n\n").filter(|e| !e.is_empty()).collect(); - assert!(events.len() >= 2); - - // Verify priming event (first event) — initialize uses "0" (no http_request_id) - let priming_event = events[0]; - assert!(priming_event.contains("id: 0")); - assert!(priming_event.contains("retry: 3000")); - assert!(priming_event.contains("data:")); - - // Verify initialize response (second event) - let response_event = events[1]; - assert!(response_event.contains(r#""jsonrpc":"2.0""#)); - assert!(response_event.contains(r#""id":1"#)); - - ct.cancel(); - handle.await?; - - Ok(()) -} - -#[tokio::test] -async fn test_request_wise_priming_includes_http_request_id() -> anyhow::Result<()> { - let ct = CancellationToken::new(); - - let service: StreamableHttpService = - StreamableHttpService::new( - || Ok(Calculator::new()), - Default::default(), - StreamableHttpServerConfig::default() - .with_sse_keep_alive(None) - .with_cancellation_token(ct.child_token()), - ); - - let router = axum::Router::new().nest_service("/mcp", service); - let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; - let addr = tcp_listener.local_addr()?; - - let handle = tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(tcp_listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - - let client = reqwest::Client::new(); - - // Initialize the session - let response = client - .post(format!("http://{addr}/mcp")) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#) - .send() - .await?; - assert_eq!(response.status(), 200); - let session_id: SessionId = response.headers()["mcp-session-id"].to_str()?.into(); - - // Send notifications/initialized - let status = client - .post(format!("http://{addr}/mcp")) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("mcp-session-id", session_id.to_string()) - .header("Mcp-Protocol-Version", "2025-06-18") - .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) - .send() - .await? - .status(); - assert_eq!(status, 202); - - // First tool call — should get http_request_id 0 - let body = client - .post(format!("http://{addr}/mcp")) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("mcp-session-id", session_id.to_string()) - .header("Mcp-Protocol-Version", "2025-06-18") - .body(r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"sum","arguments":{"a":1,"b":2}}}"#) - .send() - .await? - .text() - .await?; - - let events: Vec<&str> = body.split("\n\n").filter(|e| !e.is_empty()).collect(); - assert!( - events.len() >= 2, - "expected priming + response, got: {body}" - ); - - // Priming event should encode the http_request_id (0) - let priming = events[0]; - assert!( - priming.contains("id: 0/0"), - "first request priming should be 0/0, got: {priming}" - ); - assert!(priming.contains("retry: 3000")); - - // Response event should use index 1 (since priming occupies index 0) - let response_event = events[1]; - assert!( - response_event.contains("id: 1/0"), - "first response event id should be 1/0, got: {response_event}" - ); - assert!(response_event.contains(r#""id":2"#)); - - // Second tool call — should get http_request_id 1 - let body = client - .post(format!("http://{addr}/mcp")) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("mcp-session-id", session_id.to_string()) - .header("Mcp-Protocol-Version", "2025-06-18") - .body(r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"sum","arguments":{"a":3,"b":4}}}"#) - .send() - .await? - .text() - .await?; - - let events: Vec<&str> = body.split("\n\n").filter(|e| !e.is_empty()).collect(); - assert!( - events.len() >= 2, - "expected priming + response, got: {body}" - ); - - let priming = events[0]; - assert!( - priming.contains("id: 0/1"), - "second request priming should be 0/1, got: {priming}" - ); - - let response_event = events[1]; - assert!( - response_event.contains("id: 1/1"), - "second response event id should be 1/1, got: {response_event}" - ); - assert!(response_event.contains(r#""id":3"#)); - - ct.cancel(); - handle.await?; - - Ok(()) -} - -#[tokio::test] -async fn test_resume_after_request_wise_channel_completed() -> anyhow::Result<()> { - let ct = CancellationToken::new(); - - let service: StreamableHttpService = - StreamableHttpService::new( - || Ok(Calculator::new()), - Default::default(), - StreamableHttpServerConfig::default() - .with_sse_keep_alive(None) - .with_cancellation_token(ct.child_token()), - ); - - let router = axum::Router::new().nest_service("/mcp", service); - let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; - let addr = tcp_listener.local_addr()?; - - let handle = tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(tcp_listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - - let client = reqwest::Client::new(); - - // Initialize session - let response = client - .post(format!("http://{addr}/mcp")) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#) - .send() - .await?; - assert_eq!(response.status(), 200); - let session_id: SessionId = response.headers()["mcp-session-id"].to_str()?.into(); - - // Complete handshake - let status = client - .post(format!("http://{addr}/mcp")) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("mcp-session-id", session_id.to_string()) - .header("Mcp-Protocol-Version", "2025-06-18") - .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) - .send() - .await? - .status(); - assert_eq!(status, 202); - - // Call a tool and consume the full response (channel completes) - let body = client - .post(format!("http://{addr}/mcp")) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("mcp-session-id", session_id.to_string()) - .header("Mcp-Protocol-Version", "2025-06-18") - .body(r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"sum","arguments":{"a":1,"b":2}}}"#) - .send() - .await? - .text() - .await?; - - let events: Vec<&str> = body.split("\n\n").filter(|e| !e.is_empty()).collect(); - assert!( - events.len() >= 2, - "expected priming + response, got: {body}" - ); - assert!(events[0].contains("id: 0/0")); - assert!(events[1].contains(r#""id":2"#)); - - // Resume with Last-Event-ID after the channel has completed. - // The server returns 200 — either with replayed cached events - // (if the channel is still retained) or an empty stream (if the - // session worker hasn't processed the completion yet). - let resume = client - .get(format!("http://{addr}/mcp")) - .header("Accept", "text/event-stream") - .header("mcp-session-id", session_id.to_string()) - .header("Mcp-Protocol-Version", "2025-06-18") - .header("last-event-id", "0/0") - .send() - .await?; - assert_eq!(resume.status(), 200); - - let resume_body = resume.text().await?; - // The stream should complete (not hang), regardless of whether - // it contains replayed events or is empty. - assert!( - !resume_body.contains("standalone"), - "should not receive events from a different stream" - ); - - ct.cancel(); - handle.await?; - - Ok(()) -} - -#[tokio::test] -async fn test_completed_cache_ttl_eviction() -> anyhow::Result<()> { - use std::sync::Arc; - - let ct = CancellationToken::new(); - let mut session_manager = LocalSessionManager::default(); - session_manager.session_config.completed_cache_ttl = Duration::from_millis(200); - let session_manager = Arc::new(session_manager); - - let service = StreamableHttpService::new( - || Ok(Calculator::new()), - session_manager.clone(), - StreamableHttpServerConfig::default() - .with_sse_keep_alive(None) - .with_cancellation_token(ct.child_token()), - ); - - let router = axum::Router::new().nest_service("/mcp", service); - let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; - let addr = tcp_listener.local_addr()?; - - let handle = tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(tcp_listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - - let client = reqwest::Client::new(); - - // Initialize session - let response = client - .post(format!("http://{addr}/mcp")) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#) - .send() - .await?; - assert_eq!(response.status(), 200); - let session_id: SessionId = response.headers()["mcp-session-id"].to_str()?.into(); - - // Complete handshake - client - .post(format!("http://{addr}/mcp")) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("mcp-session-id", session_id.to_string()) - .header("Mcp-Protocol-Version", "2025-06-18") - .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) - .send() - .await?; - - // Call a tool and consume the response (channel completes) - let body = client - .post(format!("http://{addr}/mcp")) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("mcp-session-id", session_id.to_string()) - .header("Mcp-Protocol-Version", "2025-06-18") - .body(r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"sum","arguments":{"a":1,"b":2}}}"#) - .send() - .await? - .text() - .await?; - assert!(body.contains(r#""id":2"#)); - - // Wait for TTL to expire (200ms) plus margin - tokio::time::sleep(Duration::from_millis(400)).await; - - // Send a notification to trigger an event loop iteration (runs eviction) - client - .post(format!("http://{addr}/mcp")) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("mcp-session-id", session_id.to_string()) - .header("Mcp-Protocol-Version", "2025-06-18") - .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) - .send() - .await?; - - // Small delay to ensure the eviction ran - tokio::time::sleep(Duration::from_millis(50)).await; - - // Resume after TTL — channel should be evicted. The server returns - // 200 with an empty stream (no events from a different stream). - let resume = client - .get(format!("http://{addr}/mcp")) - .header("Accept", "text/event-stream") - .header("mcp-session-id", session_id.to_string()) - .header("Mcp-Protocol-Version", "2025-06-18") - .header("last-event-id", "0/0") - .send() - .await?; - assert_eq!(resume.status(), 200); - - let body = resume.text().await?; - assert!( - !body.contains(r#""id":2"#), - "should NOT contain the old tool response after eviction, got: {body}" - ); - - ct.cancel(); - handle.await?; - - Ok(()) -} - -#[tokio::test] -async fn test_priming_on_stream_close() -> anyhow::Result<()> { - use std::sync::Arc; - - use rmcp::transport::streamable_http_server::session::SessionId; - - let ct = CancellationToken::new(); - let session_manager = Arc::new(LocalSessionManager::default()); - - // legacy_session_mode: true automatically enables priming with DEFAULT_RETRY_INTERVAL (3 seconds) - let service = StreamableHttpService::new( - || Ok(Calculator::new()), - session_manager.clone(), - StreamableHttpServerConfig::default() - .with_sse_keep_alive(None) - .with_cancellation_token(ct.child_token()), - ); - - let router = axum::Router::new().nest_service("/mcp", service); - let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; - let addr = tcp_listener.local_addr()?; - - let handle = tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(tcp_listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - - // Send initialize request to create a session - let client = reqwest::Client::new(); - let response = client - .post(format!("http://{addr}/mcp")) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#) - .send() - .await?; - - let session_id: SessionId = response.headers()["mcp-session-id"].to_str()?.into(); - - // Open a standalone GET stream (send() returns when headers are received) - let response = client - .get(format!("http://{addr}/mcp")) - .header("Accept", "text/event-stream") - .header("mcp-session-id", session_id.to_string()) - .send() - .await?; - - assert_eq!(response.status(), 200); - - // Spawn a task to read the response body (blocks until stream closes) - let read_task = tokio::spawn(async move { response.text().await.unwrap() }); - - // Close the standalone stream with a 5-second retry hint - let sessions = session_manager.sessions.read().await; - let session = sessions.get(&session_id).unwrap(); - session - .close_standalone_sse_stream(Some(Duration::from_secs(5))) - .await?; - drop(sessions); - - // Wait for the read task to complete and verify the response - let body = read_task.await?; - - // Verify the stream received two priming events: - // 1. At stream start (retry: 3000) - // 2. Before close (retry: 5000) - let events: Vec<&str> = body.split("\n\n").filter(|e| !e.is_empty()).collect(); - assert_eq!(events.len(), 2); - - // First event: priming at stream start - let start_priming = events[0]; - assert!(start_priming.contains("id:")); - assert!(start_priming.contains("retry: 3000")); - assert!(start_priming.contains("data:")); - - // Second event: priming before close - let close_priming = events[1]; - assert!(close_priming.contains("id:")); - assert!(close_priming.contains("retry: 5000")); - assert!(close_priming.contains("data:")); - - ct.cancel(); - handle.await?; - - Ok(()) -} diff --git a/vendor/rmcp/tests/test_streamable_http_protocol_version.rs b/vendor/rmcp/tests/test_streamable_http_protocol_version.rs deleted file mode 100644 index fcbeb41a0..000000000 --- a/vendor/rmcp/tests/test_streamable_http_protocol_version.rs +++ /dev/null @@ -1,853 +0,0 @@ -#![cfg(not(feature = "local"))] -//! Streamable HTTP protocol-version and request-metadata validation tests. -use std::sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, -}; - -use rmcp::{ - ServerHandler, - transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, - }, -}; -use serde_json::{Value, json}; -use tokio_util::sync::CancellationToken; - -mod common; -use common::calculator::Calculator; - -fn init_body(body_version: &str) -> String { - format!( - r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"{body_version}","capabilities":{{}},"clientInfo":{{"name":"test","version":"1.0"}}}}}}"# - ) -} - -async fn spawn_server( - config: StreamableHttpServerConfig, -) -> (reqwest::Client, String, CancellationToken) { - spawn_server_with_manager(config, Arc::new(LocalSessionManager::default())).await -} - -async fn spawn_server_with_manager( - config: StreamableHttpServerConfig, - session_manager: Arc, -) -> (reqwest::Client, String, CancellationToken) { - let ct = config.cancellation_token.clone(); - let service: StreamableHttpService = - StreamableHttpService::new(|| Ok(Calculator::new()), session_manager, config); - - let router = axum::Router::new().nest_service("/mcp", service); - let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = tcp_listener.local_addr().unwrap(); - - tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(tcp_listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - - let client = reqwest::Client::new(); - let base_url = format!("http://{addr}/mcp"); - (client, base_url, ct) -} - -fn stateless_json_config() -> StreamableHttpServerConfig { - StreamableHttpServerConfig::default() - .with_legacy_session_mode(false) - .with_json_response(true) - .with_sse_keep_alive(None) - .with_cancellation_token(CancellationToken::new()) -} - -fn stateful_config() -> StreamableHttpServerConfig { - StreamableHttpServerConfig::default() - .with_legacy_session_mode(true) - .with_sse_keep_alive(None) - .with_cancellation_token(CancellationToken::new()) -} - -async fn post_init( - client: &reqwest::Client, - url: &str, - header: Option<&str>, - body_version: &str, -) -> reqwest::Response { - let mut req = client - .post(url) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .body(init_body(body_version)); - if let Some(h) = header { - req = req.header("MCP-Protocol-Version", h); - } - req.send().await.expect("send initialize request") -} - -async fn post_non_initialize(client: &reqwest::Client, url: &str) -> reqwest::Response { - client - .post(url) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#) - .send() - .await - .expect("send non-initialize request") -} - -async fn post_modern_request( - client: &reqwest::Client, - url: &str, - method: &str, - name: Option<&str>, - params: Value, -) -> reqwest::Response { - let mut request = client - .post(url) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("MCP-Protocol-Version", "2026-07-28") - .header("Mcp-Method", method) - .json(&json!({ - "jsonrpc": "2.0", - "id": 1, - "method": method, - "params": params, - })); - if let Some(name) = name { - request = request.header("Mcp-Name", name); - } - request.send().await.expect("send modern request") -} - -#[tokio::test] -async fn stateless_init_rejects_when_header_older_than_body() -> anyhow::Result<()> { - let (client, url, ct) = spawn_server(stateless_json_config()).await; - - let response = post_init(&client, &url, Some("2025-03-26"), "2025-11-25").await; - assert_eq!(response.status(), 400); - - let body: serde_json::Value = response.json().await?; - assert_eq!(body["error"]["code"], -32600); - assert!( - body["error"]["message"] - .as_str() - .unwrap_or_default() - .contains("MCP-Protocol-Version"), - "expected error message to mention the header, got: {body}" - ); - - ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn stateless_init_rejects_when_header_newer_than_body() -> anyhow::Result<()> { - let (client, url, ct) = spawn_server(stateless_json_config()).await; - - let response = post_init(&client, &url, Some("2025-11-25"), "2025-03-26").await; - assert_eq!(response.status(), 400); - - let body: serde_json::Value = response.json().await?; - assert_eq!(body["error"]["code"], -32600); - - ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn stateless_init_accepts_when_header_matches_body() -> anyhow::Result<()> { - let (client, url, ct) = spawn_server(stateless_json_config()).await; - - let response = post_init(&client, &url, Some("2025-11-25"), "2025-11-25").await; - assert_eq!(response.status(), 200); - - let body: serde_json::Value = response.json().await?; - assert!( - body["result"].is_object(), - "expected an InitializeResult, got: {body}" - ); - - ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn stateless_init_accepts_when_header_absent() -> anyhow::Result<()> { - let (client, url, ct) = spawn_server(stateless_json_config()).await; - - let response = post_init(&client, &url, None, "2025-11-25").await; - assert_eq!(response.status(), 200); - - ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn stateful_init_rejects_when_header_mismatches_body() -> anyhow::Result<()> { - let (client, url, ct) = spawn_server(stateful_config()).await; - - let response = post_init(&client, &url, Some("2024-11-05"), "2025-11-25").await; - assert_eq!(response.status(), 400); - - let body: serde_json::Value = response.json().await?; - assert_eq!(body["error"]["code"], -32600); - - ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn stateful_rejected_initial_posts_do_not_create_sessions() -> anyhow::Result<()> { - let session_manager = Arc::new(LocalSessionManager::default()); - let (client, url, ct) = - spawn_server_with_manager(stateful_config(), session_manager.clone()).await; - - let response = post_non_initialize(&client, &url).await; - assert_eq!(response.status(), 422); - assert_eq!(session_manager.sessions.read().await.len(), 0); - - let response = post_init(&client, &url, Some("2024-11-05"), "2025-11-25").await; - assert_eq!(response.status(), 400); - assert_eq!(session_manager.sessions.read().await.len(), 0); - - ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn stateless_missing_protocol_header_returns_header_mismatch() -> anyhow::Result<()> { - let (client, url, ct) = spawn_server(stateless_json_config()).await; - - let body = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}"#; - let response = client - .post(&url) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("Mcp-Method", "tools/list") - .body(body) - .send() - .await - .expect("send non-initialize request"); - - assert_eq!(response.status(), 400); - - let body: serde_json::Value = response.json().await?; - assert_eq!(body["jsonrpc"], "2.0"); - assert_eq!(body["id"], 1); - assert_eq!(body["error"]["code"], -32020); - assert!( - body["error"]["message"] - .as_str() - .is_some_and(|message| message.contains("requires MCP-Protocol-Version header")), - "expected missing protocol header message, got: {body}" - ); - - ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn stateless_tools_list_rejects_missing_request_meta() { - let (client, url, ct) = spawn_server(stateless_json_config()).await; - - let response = post_modern_request(&client, &url, "tools/list", None, json!({})).await; - - assert_eq!(response.status(), 400); - let body: Value = response.json().await.expect("response should be JSON"); - assert_eq!(body["error"]["code"], -32602); - assert!( - body["error"]["message"] - .as_str() - .is_some_and(|message| message.contains("io.modelcontextprotocol/protocolVersion")), - "expected error message to mention protocolVersion, got: {body}" - ); - ct.cancel(); -} - -#[tokio::test] -async fn stateless_tools_call_rejects_missing_request_meta() { - let (client, url, ct) = spawn_server(stateless_json_config()).await; - - let response = post_modern_request( - &client, - &url, - "tools/call", - Some("sum"), - json!({ - "name": "sum", - "arguments": { - "a": 1, - "b": 2 - } - }), - ) - .await; - - assert_eq!(response.status(), 400); - let body: Value = response.json().await.expect("response should be JSON"); - assert_eq!(body["error"]["code"], -32602); - assert!( - body["error"]["message"] - .as_str() - .is_some_and(|message| message.contains("io.modelcontextprotocol/protocolVersion")), - "expected error message to mention protocolVersion, got: {body}" - ); - ct.cancel(); -} - -#[tokio::test] -async fn stateless_request_rejects_missing_meta_protocol_version() { - let (client, url, ct) = spawn_server(stateless_json_config()).await; - - let response = post_modern_request( - &client, - &url, - "tools/list", - None, - json!({ - "_meta": { - "io.modelcontextprotocol/clientInfo": { - "name": "test", - "version": "1.0" - }, - "io.modelcontextprotocol/clientCapabilities": {} - } - }), - ) - .await; - - assert_eq!(response.status(), 400); - let body: Value = response.json().await.expect("response should be JSON"); - assert_eq!(body["error"]["code"], -32602); - assert!( - body["error"]["message"] - .as_str() - .is_some_and(|message| message.contains("io.modelcontextprotocol/protocolVersion")), - "expected error message to mention protocolVersion, got: {body}" - ); - ct.cancel(); -} - -#[tokio::test] -async fn stateless_request_rejects_missing_meta_client_capabilities() { - let (client, url, ct) = spawn_server(stateless_json_config()).await; - - let response = post_modern_request( - &client, - &url, - "tools/list", - None, - json!({ - "_meta": { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientInfo": { - "name": "test", - "version": "1.0" - } - } - }), - ) - .await; - - assert_eq!(response.status(), 400); - let body: Value = response.json().await.expect("response should be JSON"); - assert_eq!(body["error"]["code"], -32602); - assert!( - body["error"]["message"] - .as_str() - .is_some_and(|message| message.contains("io.modelcontextprotocol/clientCapabilities")), - "expected error message to mention clientCapabilities, got: {body}" - ); - ct.cancel(); -} - -#[tokio::test] -async fn stateless_request_accepts_missing_optional_meta_client_info() { - let (client, url, ct) = spawn_server(stateless_json_config()).await; - - let response = post_modern_request( - &client, - &url, - "tools/list", - None, - json!({ - "_meta": { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientCapabilities": {} - } - }), - ) - .await; - - assert_eq!(response.status(), 200); - ct.cancel(); -} - -// --------------------------------------------------------------------------- -// Opt-in seam: `with_stateless_protocol_metadata_required(true)` -// --------------------------------------------------------------------------- -// In stateless mode, every non-initialize Streamable HTTP JSON-RPC request -// POST must carry the `MCP-Protocol-Version` HTTP header (missing → HTTP 400 / -// JSON-RPC `-32020` HeaderMismatch before dispatch). Every non-initialize, -// non-discover request must additionally carry a per-request -// `io.modelcontextprotocol/protocolVersion` in `_meta` (missing → HTTP 400 / -// JSON-RPC `-32602`). The existing rule that requires `server/discover` to -// carry `_meta.protocolVersion` is preserved unchanged. -// -// Non-dispatch is proven with an explicit invocation counter (`AtomicUsize`). - -#[derive(Clone)] -struct CountingServer { - lists: Arc, -} - -impl CountingServer { - fn new() -> (Self, Arc) { - let lists = Arc::new(AtomicUsize::new(0)); - ( - Self { - lists: lists.clone(), - }, - lists, - ) - } -} - -impl ServerHandler for CountingServer { - fn get_info(&self) -> rmcp::model::ServerInfo { - rmcp::model::ServerInfo::new( - rmcp::model::ServerCapabilities::builder() - .enable_tools() - .build(), - ) - } - - fn list_tools( - &self, - _request: Option, - _context: rmcp::service::RequestContext, - ) -> impl std::future::Future> - + Send - + '_ { - self.lists.fetch_add(1, Ordering::SeqCst); - std::future::ready(Ok(rmcp::model::ListToolsResult::default())) - } -} - -async fn spawn_counting( - config: StreamableHttpServerConfig, -) -> (reqwest::Client, String, CancellationToken, Arc) { - let ct = config.cancellation_token.clone(); - let (server, lists) = CountingServer::new(); - let service: StreamableHttpService = - StreamableHttpService::new(move || Ok(server.clone()), Default::default(), config); - - let router = axum::Router::new().nest_service("/mcp", service); - let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = tcp_listener.local_addr().unwrap(); - - tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(tcp_listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - - ( - reqwest::Client::new(), - format!("http://{addr}/mcp"), - ct, - lists, - ) -} - -fn modern_required_config() -> StreamableHttpServerConfig { - StreamableHttpServerConfig::default() - .with_legacy_session_mode(false) - .with_json_response(true) - .with_sse_keep_alive(None) - .with_stateless_protocol_metadata_required(true) - .with_cancellation_token(CancellationToken::new()) -} - -async fn post_seam( - client: &reqwest::Client, - url: &str, - body: &str, - header_version: Option<&str>, - extra_headers: &[(&str, &str)], -) -> reqwest::Response { - let mut req = client - .post(url) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .body(body.to_owned()); - if let Some(h) = header_version { - req = req.header("MCP-Protocol-Version", h); - } - for (k, v) in extra_headers { - req = req.header(*k, *v); - } - req.send().await.expect("send request") -} - -// With the seam disabled, explicit stateless mode still dispatches a request -// without protocol metadata, preserving backwards compatibility. -#[tokio::test] -async fn seam_disabled_preserves_stateless_compatibility() -> anyhow::Result<()> { - let (client, url, ct, lists) = spawn_counting(stateless_json_config()).await; - - let body = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#; - let response = post_seam(&client, &url, body, None, &[]).await; - assert_eq!(response.status(), 200); - assert_eq!( - lists.load(Ordering::SeqCst), - 1, - "seam-disabled stateless config must dispatch exactly once" - ); - - ct.cancel(); - Ok(()) -} - -// With legacy compatibility enabled, the seam applies only to requests that -// are routed statelessly: an established legacy session remains unchanged, -// while a self-identifying modern request is still enforced. -#[tokio::test] -async fn seam_mixed_mode_enforces_only_stateless_routed_requests() -> anyhow::Result<()> { - let config = StreamableHttpServerConfig::default() - .with_legacy_session_mode(true) - .with_json_response(true) - .with_sse_keep_alive(None) - .with_stateless_protocol_metadata_required(true) - .with_cancellation_token(CancellationToken::new()); - let (client, url, ct, lists) = spawn_counting(config).await; - - let legacy_body = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#; - let unknown_response = post_seam( - &client, - &url, - legacy_body, - None, - &[("Mcp-Session-Id", "unknown-session")], - ) - .await; - assert_eq!( - unknown_response.status(), - reqwest::StatusCode::NOT_FOUND, - "the stateless seam must not shadow the legacy unknown-session boundary" - ); - assert_eq!(lists.load(Ordering::SeqCst), 0); - - let initialize = post_init(&client, &url, None, "2025-11-25").await; - assert_eq!(initialize.status(), 200); - let session_id = initialize - .headers() - .get("Mcp-Session-Id") - .and_then(|value| value.to_str().ok()) - .expect("legacy initialize response must include a session id") - .to_owned(); - - let legacy_response = post_seam( - &client, - &url, - legacy_body, - None, - &[("Mcp-Session-Id", &session_id)], - ) - .await; - assert_eq!(legacy_response.status(), 200); - let _ = legacy_response.bytes().await?; - assert_eq!( - lists.load(Ordering::SeqCst), - 1, - "legacy session routing must remain unchanged" - ); - - let delete_response = client - .delete(&url) - .header("Mcp-Session-Id", &session_id) - .send() - .await?; - assert_eq!(delete_response.status(), reqwest::StatusCode::ACCEPTED); - - let terminated_response = post_seam( - &client, - &url, - legacy_body, - None, - &[("Mcp-Session-Id", &session_id)], - ) - .await; - assert_eq!( - terminated_response.status(), - reqwest::StatusCode::NOT_FOUND, - "the stateless seam must not shadow the legacy terminated-session boundary" - ); - assert_eq!(lists.load(Ordering::SeqCst), 1); - - let modern_body = r#"{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}"#; - let modern_response = post_seam( - &client, - &url, - modern_body, - Some("2026-07-28"), - &[("Mcp-Method", "tools/list")], - ) - .await; - assert_eq!(modern_response.status(), 400); - let payload: serde_json::Value = modern_response.json().await?; - assert_eq!(payload["error"]["code"], -32602); - assert_eq!( - lists.load(Ordering::SeqCst), - 1, - "stateless metadata rejection must happen before another handler dispatch" - ); - - ct.cancel(); - Ok(()) -} - -// Both signals absent → missing header / -32020 before dispatch. -#[tokio::test] -async fn seam_opt_in_rejects_missing_header_before_dispatch() -> anyhow::Result<()> { - let (client, url, ct, lists) = spawn_counting(modern_required_config()).await; - - let without_meta = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#; - let response = post_seam(&client, &url, without_meta, None, &[]).await; - assert_eq!(response.status(), 400, "missing header must yield HTTP 400"); - let payload: serde_json::Value = response.json().await?; - assert_eq!(payload["error"]["code"], -32020); - assert_eq!( - lists.load(Ordering::SeqCst), - 0, - "handler invocation counter must stay at 0 when the header is missing" - ); - - ct.cancel(); - Ok(()) -} - -// Header present but `_meta.protocolVersion` absent → -32602 before -// dispatch. Counter must equal 0. -#[tokio::test] -async fn seam_opt_in_rejects_missing_meta_before_dispatch() -> anyhow::Result<()> { - let (client, url, ct, lists) = spawn_counting(modern_required_config()).await; - - let body = r#"{"jsonrpc":"2.0","id":4,"method":"tools/list","params":{}}"#; - let response = post_seam( - &client, - &url, - body, - Some("2026-07-28"), - &[("Mcp-Method", "tools/list")], - ) - .await; - assert_eq!(response.status(), 400); - let payload: serde_json::Value = response.json().await?; - assert_eq!(payload["error"]["code"], -32602); - assert_eq!( - lists.load(Ordering::SeqCst), - 0, - "handler invocation counter must stay at 0 when _meta.protocolVersion is missing" - ); - - ct.cancel(); - Ok(()) -} - -// A 2026 request with protocolVersion but no clientCapabilities reaches the -// existing inline-metadata validator and is rejected before method dispatch. -#[tokio::test] -async fn seam_opt_in_rejects_missing_client_capabilities_before_dispatch() -> anyhow::Result<()> { - let (client, url, ct, lists) = spawn_counting(modern_required_config()).await; - - let body = r#"{"jsonrpc":"2.0","id":5,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}"#; - let response = post_seam( - &client, - &url, - body, - Some("2026-07-28"), - &[("Mcp-Method", "tools/list")], - ) - .await; - assert_eq!(response.status(), 400); - let payload: serde_json::Value = response.json().await?; - assert_eq!(payload["error"]["code"], -32602); - assert_eq!( - lists.load(Ordering::SeqCst), - 0, - "handler invocation counter must stay at 0 when clientCapabilities is missing" - ); - - ct.cancel(); - Ok(()) -} - -// Both signals present at the current version, plus the routing header -// required by `validate_standard_headers` for `>= STANDARD_HEADERS`. The -// seam must dispatch and the handler must run exactly once. -#[tokio::test] -async fn seam_opt_in_dispatches_when_header_and_meta_present() -> anyhow::Result<()> { - let (client, url, ct, lists) = spawn_counting(modern_required_config()).await; - - let body = r#"{"jsonrpc":"2.0","id":6,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}"#; - let response = post_seam( - &client, - &url, - body, - Some("2026-07-28"), - &[("Mcp-Method", "tools/list")], - ) - .await; - assert_eq!(response.status(), 200); - let payload: serde_json::Value = response.json().await?; - assert!(payload.get("result").is_some()); - assert_eq!( - lists.load(Ordering::SeqCst), - 1, - "handler invocation counter must equal 1 on successful dispatch" - ); - - ct.cancel(); - Ok(()) -} - -// rmcp clients negotiated below 2026-07-28 send the protocol header but do -// not attach per-request protocol metadata. Requiring the metadata therefore -// rejects their real request shape even if the handler supports that version. -#[tokio::test] -async fn seam_opt_in_rejects_older_rmcp_client_request_shape() -> anyhow::Result<()> { - let (client, url, ct, lists) = spawn_counting(modern_required_config()).await; - - let body = r#"{"jsonrpc":"2.0","id":7,"method":"tools/list","params":{}}"#; - let response = post_seam(&client, &url, body, Some("2025-11-25"), &[]).await; - assert_eq!(response.status(), 400); - let payload: serde_json::Value = response.json().await?; - assert_eq!(payload["error"]["code"], -32602); - assert_eq!( - lists.load(Ordering::SeqCst), - 0, - "an older client request without per-request metadata must not dispatch" - ); - - ct.cancel(); - Ok(()) -} - -// When the 2026+ header is present but `Mcp-Method` is missing, the -// existing `validate_standard_headers` rule must fire first with -32020, -// before the seam's -32602 missing-meta check. -#[tokio::test] -async fn seam_opt_in_preserves_standard_header_precedence() -> anyhow::Result<()> { - let (client, url, ct, lists) = spawn_counting(modern_required_config()).await; - - let body = r#"{"jsonrpc":"2.0","id":7,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}"#; - let response = post_seam(&client, &url, body, Some("2026-07-28"), &[]).await; - assert_eq!(response.status(), 400); - let payload: serde_json::Value = response.json().await?; - assert_eq!( - payload["error"]["code"], -32020, - "standard-headers routing-header error must precede the seam's meta check" - ); - assert_eq!( - lists.load(Ordering::SeqCst), - 0, - "handler invocation counter must stay at 0 when a routing header is missing" - ); - - ct.cancel(); - Ok(()) -} - -// `initialize` is exempt from the new required-header check while retaining -// its existing optional-header and header/body consistency rules. -#[tokio::test] -async fn seam_opt_in_preserves_initialize_rules() -> anyhow::Result<()> { - let (client, url, ct) = spawn_server(modern_required_config()).await; - - let response = post_init(&client, &url, None, "2025-11-25").await; - assert_eq!(response.status(), 200); - - let response = post_init(&client, &url, Some("2025-11-25"), "2025-11-25").await; - assert_eq!(response.status(), 200); - - let response = post_init(&client, &url, Some("2025-03-26"), "2025-11-25").await; - assert_eq!(response.status(), 400); - let payload: serde_json::Value = response.json().await?; - assert_eq!(payload["error"]["code"], -32600); - - ct.cancel(); - Ok(()) -} - -// `notifications/initialized` returns HTTP 202 (Accepted) without -// requiring any protocol metadata, even under the seam. -#[tokio::test] -async fn seam_opt_in_notifications_return_202() -> anyhow::Result<()> { - let (client, url, ct) = spawn_server(modern_required_config()).await; - - let body = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#; - let response = post_seam(&client, &url, body, None, &[]).await; - assert_eq!( - response.status(), - reqwest::StatusCode::ACCEPTED, - "notifications must surface HTTP 202 regardless of metadata" - ); - - ct.cancel(); - Ok(()) -} - -// `server/discover` is still subject to the new HTTP-header precheck. -#[tokio::test] -async fn seam_opt_in_discover_rejects_missing_header() -> anyhow::Result<()> { - let (client, url, ct) = spawn_server(modern_required_config()).await; - - let with_meta = r#"{"jsonrpc":"2.0","id":8,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2025-11-25","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}"#; - let without_meta = r#"{"jsonrpc":"2.0","id":9,"method":"server/discover","params":{}}"#; - for body in [with_meta, without_meta] { - let response = post_seam( - &client, - &url, - body, - None, - &[("Mcp-Method", "server/discover")], - ) - .await; - assert_eq!(response.status(), 400); - let payload: serde_json::Value = response.json().await?; - assert_eq!(payload["error"]["code"], -32020); - } - - let response = post_seam( - &client, - &url, - without_meta, - Some("2025-11-25"), - &[("Mcp-Method", "server/discover")], - ) - .await; - assert_eq!(response.status(), 400); - let payload: serde_json::Value = response.json().await?; - assert_eq!(payload["error"]["code"], -32602); - - let response = post_seam( - &client, - &url, - with_meta, - Some("2025-11-25"), - &[("Mcp-Method", "server/discover")], - ) - .await; - assert_eq!(response.status(), 200); - - ct.cancel(); - Ok(()) -} diff --git a/vendor/rmcp/tests/test_streamable_http_session_store.rs b/vendor/rmcp/tests/test_streamable_http_session_store.rs deleted file mode 100644 index 0c8f6a835..000000000 --- a/vendor/rmcp/tests/test_streamable_http_session_store.rs +++ /dev/null @@ -1,398 +0,0 @@ -#![cfg(all( - feature = "client", - feature = "server", - feature = "transport-streamable-http-client-reqwest", - feature = "transport-streamable-http-server", - not(feature = "local") -))] - -use std::{collections::HashMap, sync::Arc}; - -use rmcp::{ - ServiceExt, - transport::{ - StreamableHttpClientTransport, - streamable_http_client::StreamableHttpClientTransportConfig, - streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, - session::{SessionState, SessionStore, SessionStoreError, local::LocalSessionManager}, - }, - }, -}; -use tokio::sync::RwLock; -use tokio_util::sync::CancellationToken; - -mod common; -use common::calculator::Calculator; - -// --------------------------------------------------------------------------- -// Shared in-memory store used across tests -// --------------------------------------------------------------------------- - -#[derive(Default, Clone)] -struct InMemorySessionStore(Arc>>); - -impl InMemorySessionStore { - fn new() -> Self { - Self::default() - } - - async fn len(&self) -> usize { - self.0.read().await.len() - } -} - -#[async_trait::async_trait] -impl SessionStore for InMemorySessionStore { - async fn load(&self, session_id: &str) -> Result, SessionStoreError> { - Ok(self.0.read().await.get(session_id).cloned()) - } - - async fn store(&self, session_id: &str, state: &SessionState) -> Result<(), SessionStoreError> { - self.0 - .write() - .await - .insert(session_id.to_owned(), state.clone()); - Ok(()) - } - - async fn delete(&self, session_id: &str) -> Result<(), SessionStoreError> { - self.0.write().await.remove(session_id); - Ok(()) - } -} - -// --------------------------------------------------------------------------- -// Helper: spin up a StreamableHttpService backed by the given store and -// return the bound address together with the cancellation token. -// --------------------------------------------------------------------------- - -fn make_service( - session_store: Arc, - ct: &CancellationToken, -) -> StreamableHttpService { - StreamableHttpService::new(|| Ok(Calculator::new()), Default::default(), { - let mut cfg = StreamableHttpServerConfig::default(); - cfg.legacy_session_mode = true; - cfg.sse_keep_alive = None; - cfg.cancellation_token = ct.child_token(); - cfg.session_store = Some(session_store); - cfg - }) -} - -// --------------------------------------------------------------------------- -// Test 1 — state is persisted to the store after a successful handshake -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn test_session_state_persisted_to_store() -> anyhow::Result<()> { - let store = Arc::new(InMemorySessionStore::new()); - let ct = CancellationToken::new(); - let service = make_service(store.clone(), &ct); - - let router = axum::Router::new().nest_service("/mcp", service); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - - let handle = tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - - // Connect a full client — this performs the initialize + initialized handshake. - let transport = StreamableHttpClientTransport::from_config( - StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")), - ); - let client = ().serve(transport).await?; - - // Make a real request so the session is fully active. - let _resources = client.list_all_resources().await?; - - // The store should now contain exactly one session entry. - assert_eq!( - store.len().await, - 1, - "session state should be persisted to the store after initialization" - ); - - // Verify the stored state contains the expected client info. - let entries = store.0.read().await; - let state = entries.values().next().expect("store entry should exist"); - assert_eq!( - state.initialize_params.client_info.name, "rmcp", - "stored client_info.name should match the rmcp client" - ); - - let _ = client.cancel().await; - ct.cancel(); - handle.await?; - - Ok(()) -} - -// --------------------------------------------------------------------------- -// Test 2 — store entry is removed when the client sends HTTP DELETE -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn test_session_state_deleted_from_store_on_delete() -> anyhow::Result<()> { - let store = Arc::new(InMemorySessionStore::new()); - let session_manager = Arc::new(LocalSessionManager::default()); - let ct = CancellationToken::new(); - - let service = StreamableHttpService::new(|| Ok(Calculator::new()), session_manager.clone(), { - let mut cfg = StreamableHttpServerConfig::default(); - cfg.legacy_session_mode = true; - cfg.sse_keep_alive = None; - cfg.cancellation_token = ct.child_token(); - cfg.session_store = Some(store.clone()); - cfg - }); - - let router = axum::Router::new().nest_service("/mcp", service); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - - let handle = tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - - let transport = StreamableHttpClientTransport::from_config( - StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")), - ); - let client = ().serve(transport).await?; - let _resources = client.list_all_resources().await?; - - assert_eq!(store.len().await, 1, "store should have one entry"); - - // Get the session ID from the server's in-memory map. - let session_id = { - let sessions = session_manager.sessions.read().await; - sessions - .keys() - .next() - .cloned() - .expect("session should exist") - }; - - // Send an explicit HTTP DELETE — this is the signal to remove from store. - let http_client = reqwest::Client::new(); - let response = http_client - .delete(format!("http://{addr}/mcp")) - .header("mcp-session-id", session_id.as_ref()) - .send() - .await?; - assert_eq!(response.status(), 202); - - assert_eq!( - store.len().await, - 0, - "store entry should be removed after explicit DELETE" - ); - - let _ = client.cancel().await; - ct.cancel(); - handle.await?; - - Ok(()) -} - -// --------------------------------------------------------------------------- -// Helper: spin up a server on an ephemeral port and return its address and -// the join handle. The server shuts down when `ct` is cancelled. -// --------------------------------------------------------------------------- - -fn spawn_server( - session_store: Option>, - session_manager: Arc, - ct: &CancellationToken, -) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) { - let svc = StreamableHttpService::new(|| Ok(Calculator::new()), session_manager, { - let mut cfg = StreamableHttpServerConfig::default(); - cfg.legacy_session_mode = true; - cfg.sse_keep_alive = None; - cfg.cancellation_token = ct.child_token(); - cfg.session_store = session_store; - cfg - }); - // Use std::net::TcpListener so the port is bound synchronously before - // we return — avoids a race between returning the addr and the server - // actually starting to accept connections. - let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - std_listener.set_nonblocking(true).unwrap(); - let addr = std_listener.local_addr().unwrap(); - let listener = tokio::net::TcpListener::from_std(std_listener).unwrap(); - let router = axum::Router::new().nest_service("/mcp", svc); - let handle = tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - (addr, handle) -} - -// --------------------------------------------------------------------------- -// Test 3 — cross-instance session restore -// -// Both halves follow the same structure: -// -// Instance A initializes the session (session state may be saved to store) -// Instance A is fully shut down -// Instance B (fresh, no in-memory state) receives a request for the old ID -// -// Without a store → 404. With a shared store → transparent restore. -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn test_cross_instance_session_restore() -> anyhow::Result<()> { - let http = reqwest::Client::new(); - - // ----------------------------------------------------------------------- - // Negative check: no session store → instance B returns 404. - // ----------------------------------------------------------------------- - { - // --- Instance A (no store): initialize --- - let ct_a = CancellationToken::new(); - let (addr_a, srv_a) = spawn_server(None, Arc::new(LocalSessionManager::default()), &ct_a); - - let init_resp = http - .post(format!("http://{addr_a}/mcp")) - .header("accept", "application/json, text/event-stream") - .header("content-type", "application/json") - .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}"#) - .send() - .await?; - assert_eq!( - init_resp.status(), - 200, - "instance A: initialize should succeed" - ); - let session_id = init_resp - .headers() - .get("mcp-session-id") - .expect("session ID header must be present") - .to_str()? - .to_owned(); - - // Shut down instance A completely. - ct_a.cancel(); - srv_a.await?; - - // --- Instance B (no store, fresh state): send request --- - let ct_b = CancellationToken::new(); - let (addr_b, srv_b) = spawn_server(None, Arc::new(LocalSessionManager::default()), &ct_b); - - let resp = http - .post(format!("http://{addr_b}/mcp")) - .header("accept", "application/json, text/event-stream") - .header("content-type", "application/json") - .header("mcp-session-id", &session_id) - .body(r#"{"jsonrpc":"2.0","id":2,"method":"ping","params":{}}"#) - .send() - .await?; - assert_eq!( - resp.status(), - reqwest::StatusCode::NOT_FOUND, - "without a session store, instance B must return 404 for an unknown session ID" - ); - - ct_b.cancel(); - srv_b.await?; - } - - // ----------------------------------------------------------------------- - // Positive check: shared session store → instance B restores transparently. - // ----------------------------------------------------------------------- - { - let store: Arc = Arc::new(InMemorySessionStore::new()); - - // --- Instance A (with store): initialize --- - let ct_a = CancellationToken::new(); - let sm_a = Arc::new(LocalSessionManager::default()); - let (addr_a, srv_a) = spawn_server(Some(store.clone()), sm_a.clone(), &ct_a); - - let init_resp = http - .post(format!("http://{addr_a}/mcp")) - .header("accept", "application/json, text/event-stream") - .header("content-type", "application/json") - .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}"#) - .send() - .await?; - assert_eq!( - init_resp.status(), - 200, - "instance A: initialize should succeed" - ); - let original_session_id = init_resp - .headers() - .get("mcp-session-id") - .expect("session ID header must be present") - .to_str()? - .to_owned(); - - // Confirm the session was persisted. - let store_ref = store - .load(&original_session_id) - .await - .expect("store load should not error"); - assert!( - store_ref.is_some(), - "store should hold the session after initialization" - ); - - // Shut down instance A completely — session lives only in the store now. - ct_a.cancel(); - srv_a.await?; - - // --- Instance B (same store, fresh in-memory state): send request --- - let ct_b = CancellationToken::new(); - let sm_b = Arc::new(LocalSessionManager::default()); - let (addr_b, srv_b) = spawn_server(Some(store.clone()), sm_b.clone(), &ct_b); - - let resp = http - .post(format!("http://{addr_b}/mcp")) - .header("accept", "application/json, text/event-stream") - .header("content-type", "application/json") - .header("mcp-session-id", &original_session_id) - .body(r#"{"jsonrpc":"2.0","id":2,"method":"ping","params":{}}"#) - .send() - .await?; - assert_eq!( - resp.status(), - 200, - "instance B: request must succeed after transparent restore" - ); - - // The session must be in instance B's memory under the ORIGINAL ID. - { - let sessions = sm_b.sessions.read().await; - let restored_id = sessions - .keys() - .next() - .expect("session should exist in instance B after restore"); - assert_eq!( - restored_id.as_ref(), - original_session_id.as_str(), - "restored session must keep the original session ID" - ); - } - - ct_b.cancel(); - srv_b.await?; - } - - Ok(()) -} diff --git a/vendor/rmcp/tests/test_streamable_http_stale_session.rs b/vendor/rmcp/tests/test_streamable_http_stale_session.rs deleted file mode 100644 index be1ac0269..000000000 --- a/vendor/rmcp/tests/test_streamable_http_stale_session.rs +++ /dev/null @@ -1,464 +0,0 @@ -#![cfg(all( - feature = "transport-streamable-http-client", - feature = "transport-streamable-http-client-reqwest", - feature = "transport-streamable-http-server", - not(feature = "local") -))] - -use std::{ - collections::{HashMap, VecDeque}, - sync::Arc, -}; - -use futures::stream; -use http::{HeaderName, HeaderValue}; -use rmcp::{ - ServiceError, ServiceExt, - model::{ - CallToolRequestParams, ClientInfo, ClientJsonRpcMessage, ClientRequest, ErrorCode, - ErrorData, InitializeResult, PingRequest, ProtocolVersion, RequestId, ServerCapabilities, - ServerJsonRpcMessage, ServerResult, - }, - transport::{ - StreamableHttpClientTransport, - streamable_http_client::{ - StreamableHttpClient, StreamableHttpClientTransportConfig, StreamableHttpError, - StreamableHttpPostResponse, - }, - streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, - }, - }, -}; -use tokio_util::sync::CancellationToken; - -mod common; -use common::calculator::Calculator; - -#[derive(Debug, thiserror::Error)] -#[error("mock streamable http client error")] -struct MockClientError; - -#[derive(Clone)] -struct ReinitDropsAcceptedResponseClient { - state: Arc>, - stale_stream_cancelled: CancellationToken, - initial_request_accepted: Arc, - final_retry_accepted: Arc, -} - -struct MockState { - session_counter: usize, - posts: VecDeque, -} - -enum MockPost { - Initialize, - Initialized, - Accepted, - SessionExpired, -} - -impl ReinitDropsAcceptedResponseClient { - fn new() -> Self { - Self { - state: Arc::new(tokio::sync::Mutex::new(MockState { - session_counter: 0, - posts: VecDeque::from([ - MockPost::Initialize, - MockPost::Initialized, - MockPost::Accepted, - MockPost::SessionExpired, - MockPost::Initialize, - MockPost::Initialized, - MockPost::Accepted, - ]), - })), - stale_stream_cancelled: CancellationToken::new(), - initial_request_accepted: Arc::new(tokio::sync::Semaphore::new(0)), - final_retry_accepted: Arc::new(tokio::sync::Semaphore::new(0)), - } - } -} - -impl StreamableHttpClient for ReinitDropsAcceptedResponseClient { - type Error = MockClientError; - - async fn post_message( - &self, - _uri: Arc, - message: ClientJsonRpcMessage, - _session_id: Option>, - _auth_header: Option, - custom_headers: HashMap, - ) -> Result> { - let mut state = self.state.lock().await; - match state - .posts - .pop_front() - .expect("unexpected mock post_message call") - { - MockPost::Initialize => { - state.session_counter += 1; - let protocol_version = if state.session_counter == 1 { - ProtocolVersion::V_2025_11_25 - } else { - ProtocolVersion::V_2025_06_18 - }; - let id = match message { - ClientJsonRpcMessage::Request(request) => request.id, - other => panic!("expected initialize request, got {other:?}"), - }; - Ok(StreamableHttpPostResponse::Json( - ServerJsonRpcMessage::response( - ServerResult::InitializeResult( - InitializeResult::new( - ServerCapabilities::builder().enable_tools().build(), - ) - .with_protocol_version(protocol_version), - ), - id, - ), - Some(format!("session-{}", state.session_counter)), - )) - } - MockPost::Initialized => { - assert!( - matches!(message, ClientJsonRpcMessage::Notification(_)), - "expected initialized notification, got {message:?}" - ); - Ok(StreamableHttpPostResponse::Accepted) - } - MockPost::Accepted => { - if state.posts.is_empty() { - assert_eq!( - custom_headers - .get(&HeaderName::from_static("mcp-protocol-version")) - .and_then(|value| value.to_str().ok()), - Some(ProtocolVersion::V_2025_06_18.as_str()) - ); - self.final_retry_accepted.add_permits(1); - } else { - self.initial_request_accepted.add_permits(1); - } - Ok(StreamableHttpPostResponse::Accepted) - } - MockPost::SessionExpired => Err(StreamableHttpError::SessionExpired), - } - } - - async fn delete_session( - &self, - _uri: Arc, - _session_id: Arc, - _auth_header: Option, - _custom_headers: HashMap, - ) -> Result<(), StreamableHttpError> { - Ok(()) - } - - async fn get_stream( - &self, - _uri: Arc, - session_id: Option>, - _last_event_id: Option, - _auth_header: Option, - _custom_headers: HashMap, - ) -> Result< - futures::stream::BoxStream<'static, Result>, - StreamableHttpError, - > { - if session_id.as_deref() == Some("session-1") { - let cancel = self.stale_stream_cancelled.clone(); - Ok(Box::pin(stream::once(async move { - cancel.cancelled_owned().await; - Ok(sse_stream::Sse { - event: None, - data: Some( - serde_json::to_string(&ServerJsonRpcMessage::error( - ErrorData::new( - ErrorCode::INTERNAL_ERROR, - "stale stream should not deliver after re-init", - None, - ), - Some(RequestId::Number(2)), - )) - .expect("serialize stale error"), - ), - id: None, - retry: None, - }) - }))) - } else { - Ok(Box::pin(stream::pending())) - } - } -} - -#[tokio::test] -async fn test_reinitialization_completes_accepted_sse_request_instead_of_hanging() --> anyhow::Result<()> { - let mock_client = ReinitDropsAcceptedResponseClient::new(); - let initial_request_accepted = mock_client.initial_request_accepted.clone(); - let final_retry_accepted = mock_client.final_retry_accepted.clone(); - let transport = StreamableHttpClientTransport::with_client( - mock_client, - StreamableHttpClientTransportConfig::with_uri("mock://mcp"), - ); - let mut client = ClientInfo::default().serve(transport).await?; - - let peer = client.peer().clone(); - let pending_call = tokio::spawn(async move { - peer.call_tool(CallToolRequestParams::new("slow_tool")) - .await - }); - - let _initial_permit = tokio::time::timeout( - std::time::Duration::from_secs(1), - initial_request_accepted.acquire(), - ) - .await - .expect("initial accepted request should be observed") - .expect("initial accepted request semaphore should stay open"); - - let reinit_trigger = { - let peer = client.peer().clone(); - tokio::spawn(async move { peer.list_tools(None).await }) - }; - - let _retry_permit = tokio::time::timeout( - std::time::Duration::from_secs(1), - final_retry_accepted.acquire(), - ) - .await - .expect("re-initialization retry should be accepted") - .expect("re-initialization retry semaphore should stay open"); - - let err = tokio::time::timeout(std::time::Duration::from_millis(100), pending_call) - .await - .expect("accepted SSE-backed request should complete instead of hanging")? - .expect_err( - "accepted request should fail after re-initialization drops its response stream", - ); - - match err { - ServiceError::McpError(error) => { - assert_eq!(error.code, ErrorCode::INTERNAL_ERROR); - assert!( - error.message.contains("session"), - "expected session-related error, got: {error}" - ); - } - other => panic!("expected McpError for orphaned request, got: {other:?}"), - } - - reinit_trigger.abort(); - let _ = client.close().await; - - Ok(()) -} - -#[tokio::test] -async fn test_stale_session_id_returns_status_aware_error() -> anyhow::Result<()> { - let ct = CancellationToken::new(); - let service: StreamableHttpService = - StreamableHttpService::new( - || Ok(Calculator::new()), - Default::default(), - StreamableHttpServerConfig::default() - .with_sse_keep_alive(None) - .with_cancellation_token(ct.child_token()), - ); - - let router = axum::Router::new().nest_service("/mcp", service); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - - let handle = tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - - let uri = Arc::::from(format!("http://{addr}/mcp")); - let message = ClientJsonRpcMessage::request( - ClientRequest::PingRequest(PingRequest::default()), - RequestId::Number(1), - ); - - let client = reqwest::Client::new(); - let result = client - .post_message( - uri.clone(), - message, - Some(Arc::from("stale-session-id")), - None, - HashMap::new(), - ) - .await; - - let raw_response = reqwest::Client::new() - .post(uri.as_ref()) - .header("accept", "application/json, text/event-stream") - .header("content-type", "application/json") - .header("mcp-session-id", "stale-session-id") - .body(r#"{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}"#) - .send() - .await?; - - assert_eq!(raw_response.status(), reqwest::StatusCode::NOT_FOUND); - match result { - Err(StreamableHttpError::SessionExpired) => { - // Expected: post_message detects 404 with a session ID and returns SessionExpired - } - other => panic!("expected SessionExpired, got: {other:?}"), - } - - ct.cancel(); - handle.await?; - - Ok(()) -} - -/// Verify that when the server loses a session (returns HTTP 404), the client -/// transparently re-initializes and the original request succeeds. -#[tokio::test] -async fn test_transparent_reinitialization_on_session_expiry() -> anyhow::Result<()> { - let ct = CancellationToken::new(); - let session_manager = Arc::new(LocalSessionManager::default()); - - let service = StreamableHttpService::new( - || Ok(Calculator::new()), - session_manager.clone(), - StreamableHttpServerConfig::default() - .with_sse_keep_alive(None) - .with_cancellation_token(ct.child_token()), - ); - - let router = axum::Router::new().nest_service("/mcp", service); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - - let server_handle = tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - - // Connect a full client transport (this performs initialize + notifications/initialized) - let transport = StreamableHttpClientTransport::from_config( - StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")) - .reinit_on_expired_session(true), - ); - let client = ().serve(transport).await?; - - // Verify the session is established: list_all_resources() succeeds - let _resources = client.list_all_resources().await?; - - // Capture the current session ID from the server - let original_session_id = { - let sessions = session_manager.sessions.read().await; - sessions - .keys() - .next() - .cloned() - .expect("session should exist") - }; - - // Force session expiry by removing all sessions from the server-side manager - { - let mut sessions = session_manager.sessions.write().await; - sessions.clear(); - } - - // This call should trigger transparent re-initialization and still succeed - let _resources_after = client.list_all_resources().await?; - - // Verify the server created a new session with a different ID - { - let sessions = session_manager.sessions.read().await; - let new_session_id = sessions - .keys() - .next() - .expect("new session should exist after re-initialization"); - assert_ne!( - new_session_id, &original_session_id, - "new session ID should differ from the original" - ); - } - - let _ = client.cancel().await; - ct.cancel(); - server_handle.await?; - - Ok(()) -} - -/// Verify that when `reinit_on_expired_session` is false and the server loses the session, -/// the client receives a `SessionExpired` transport error instead of retrying. -#[tokio::test] -async fn test_session_expired_error_when_reinit_disabled() -> anyhow::Result<()> { - let ct = CancellationToken::new(); - let session_manager = Arc::new(LocalSessionManager::default()); - - let service = StreamableHttpService::new( - || Ok(Calculator::new()), - session_manager.clone(), - StreamableHttpServerConfig::default() - .with_sse_keep_alive(None) - .with_cancellation_token(ct.child_token()), - ); - - let router = axum::Router::new().nest_service("/mcp", service); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - - let server_handle = tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - - let transport = StreamableHttpClientTransport::from_config( - StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")) - .reinit_on_expired_session(false), - ); - let client = ().serve(transport).await?; - - // Verify the session is established - let _resources = client.list_all_resources().await?; - - // Force session expiry by removing all sessions from the server-side manager - { - let mut sessions = session_manager.sessions.write().await; - sessions.clear(); - } - - // This call should fail with a SessionExpired transport error - let result = client.list_all_resources().await; - match result { - Err(ServiceError::TransportSend(ref dyn_err)) => { - let err_msg = format!("{dyn_err}"); - assert!( - err_msg.contains("Session expired"), - "expected 'Session expired' in error message, got: {err_msg}" - ); - } - other => panic!("expected TransportSend(SessionExpired), got: {other:?}"), - } - - let _ = client.cancel().await; - ct.cancel(); - server_handle.await?; - - Ok(()) -} diff --git a/vendor/rmcp/tests/test_streamable_http_standard_headers.rs b/vendor/rmcp/tests/test_streamable_http_standard_headers.rs deleted file mode 100644 index 510433e70..000000000 --- a/vendor/rmcp/tests/test_streamable_http_standard_headers.rs +++ /dev/null @@ -1,315 +0,0 @@ -#![cfg(not(feature = "local"))] -//! SEP-2243 server-side validation of `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*` headers. -use std::sync::Arc; - -use rmcp::{ - ServerHandler, - model::{ServerCapabilities, ServerInfo, Tool}, - transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, - }, -}; -use tokio_util::sync::CancellationToken; - -const SEP_VERSION: &str = "2026-07-28"; - -/// Server exposing one tool whose `region` argument is promoted to `Mcp-Param-Region`. -#[derive(Clone, Default)] -struct HeaderValidationServer; - -impl ServerHandler for HeaderValidationServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - } - - fn get_tool(&self, name: &str) -> Option { - if name != "deploy" { - return None; - } - let schema = serde_json::json!({ - "type": "object", - "properties": { "region": { "type": "string", "x-mcp-header": "Region" } } - }); - let schema = schema.as_object().expect("object schema").clone(); - Some(Tool::new("deploy", "deploy a thing", Arc::new(schema))) - } -} - -async fn spawn_server() -> (reqwest::Client, String, CancellationToken) { - let config = StreamableHttpServerConfig::default() - .with_legacy_session_mode(false) - .with_json_response(true) - .with_sse_keep_alive(None) - .with_cancellation_token(CancellationToken::new()); - let ct = config.cancellation_token.clone(); - let service: StreamableHttpService = - StreamableHttpService::new(|| Ok(HeaderValidationServer), Default::default(), config); - - let router = axum::Router::new().nest_service("/mcp", service); - let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = tcp_listener.local_addr().unwrap(); - tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(tcp_listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - (reqwest::Client::new(), format!("http://{addr}/mcp"), ct) -} - -/// POSTs a `tools/call` with the given protocol-version and optional SEP-2243 headers. -async fn post_tool_call( - client: &reqwest::Client, - url: &str, - version: &str, - tool_name: &str, - arguments: serde_json::Value, - mcp_method: Option<&str>, - mcp_name: Option<&str>, - param_region: Option<&str>, -) -> reqwest::Response { - let body = serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": tool_name, - "arguments": arguments, - } - }); - let mut req = client - .post(url) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("MCP-Protocol-Version", version) - .body(body.to_string()); - if let Some(method) = mcp_method { - req = req.header("Mcp-Method", method); - } - if let Some(name) = mcp_name { - req = req.header("Mcp-Name", name); - } - if let Some(region) = param_region { - req = req.header("Mcp-Param-Region", region); - } - req.send().await.expect("send tools/call request") -} - -#[tokio::test] -async fn accepts_matching_standard_headers() -> anyhow::Result<()> { - let (client, url, ct) = spawn_server().await; - - // Matching headers pass validation and reach dispatch. (Stateless mode without a - // prior initialize yields an unrelated -32601, which still proves -32020 was not raised.) - let response = post_tool_call( - &client, - &url, - SEP_VERSION, - "sum", - serde_json::json!({ "a": 1, "b": 2 }), - Some("tools/call"), - Some("sum"), - None, - ) - .await; - let body: serde_json::Value = response.json().await?; - assert_ne!( - body["error"]["code"], -32020, - "matching headers must not be rejected as a header mismatch, got: {body}" - ); - - ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn rejects_method_mismatch_with_32020() -> anyhow::Result<()> { - let (client, url, ct) = spawn_server().await; - - let response = post_tool_call( - &client, - &url, - SEP_VERSION, - "sum", - serde_json::json!({ "a": 1, "b": 2 }), - Some("tools/list"), - Some("sum"), - None, - ) - .await; - assert_eq!(response.status(), 400); - let body: serde_json::Value = response.json().await?; - assert_eq!(body["error"]["code"], -32020); - - ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn rejects_missing_method_header_with_32020() -> anyhow::Result<()> { - let (client, url, ct) = spawn_server().await; - - let response = post_tool_call( - &client, - &url, - SEP_VERSION, - "sum", - serde_json::json!({ "a": 1, "b": 2 }), - None, - Some("sum"), - None, - ) - .await; - assert_eq!(response.status(), 400); - let body: serde_json::Value = response.json().await?; - assert_eq!(body["error"]["code"], -32020); - - ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn rejects_name_mismatch_with_32020() -> anyhow::Result<()> { - let (client, url, ct) = spawn_server().await; - - let response = post_tool_call( - &client, - &url, - SEP_VERSION, - "sum", - serde_json::json!({ "a": 1, "b": 2 }), - Some("tools/call"), - Some("product"), - None, - ) - .await; - assert_eq!(response.status(), 400); - let body: serde_json::Value = response.json().await?; - assert_eq!(body["error"]["code"], -32020); - - ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn skips_validation_for_pre_sep_version() -> anyhow::Result<()> { - let (client, url, ct) = spawn_server().await; - - // Older version: headers are not enforced even when absent. - let response = post_tool_call( - &client, - &url, - "2025-11-25", - "sum", - serde_json::json!({ "a": 1, "b": 2 }), - None, - None, - None, - ) - .await; - let body: serde_json::Value = response.json().await?; - assert_ne!( - body["error"]["code"], -32020, - "pre-SEP versions must skip header validation, got: {body}" - ); - - ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn accepts_matching_param_header() -> anyhow::Result<()> { - let (client, url, ct) = spawn_server().await; - - let response = post_tool_call( - &client, - &url, - SEP_VERSION, - "deploy", - serde_json::json!({ "region": "us-west1" }), - Some("tools/call"), - Some("deploy"), - Some("us-west1"), - ) - .await; - let body: serde_json::Value = response.json().await?; - assert_ne!( - body["error"]["code"], -32020, - "matching Mcp-Param-* must not be rejected, got: {body}" - ); - - ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn rejects_param_mismatch_with_32020() -> anyhow::Result<()> { - let (client, url, ct) = spawn_server().await; - - let response = post_tool_call( - &client, - &url, - SEP_VERSION, - "deploy", - serde_json::json!({ "region": "us-west1" }), - Some("tools/call"), - Some("deploy"), - Some("eu-central1"), - ) - .await; - assert_eq!(response.status(), 400); - let body: serde_json::Value = response.json().await?; - assert_eq!(body["error"]["code"], -32020); - - ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn rejects_decoded_base64_param_mismatch_with_32020() -> anyhow::Result<()> { - let (client, url, ct) = spawn_server().await; - - let response = post_tool_call( - &client, - &url, - SEP_VERSION, - "deploy", - serde_json::json!({ "region": "us-west1" }), - Some("tools/call"), - Some("deploy"), - Some("=?base64?ZXUtY2VudHJhbDE=?="), - ) - .await; - assert_eq!(response.status(), 400); - let body: serde_json::Value = response.json().await?; - assert_eq!(body["error"]["code"], -32020); - - ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn rejects_missing_param_header_with_32020() -> anyhow::Result<()> { - let (client, url, ct) = spawn_server().await; - - // `region` argument is present but the annotated `Mcp-Param-Region` header is absent. - let response = post_tool_call( - &client, - &url, - SEP_VERSION, - "deploy", - serde_json::json!({ "region": "us-west1" }), - Some("tools/call"), - Some("deploy"), - None, - ) - .await; - assert_eq!(response.status(), 400); - let body: serde_json::Value = response.json().await?; - assert_eq!(body["error"]["code"], -32020); - - ct.cancel(); - Ok(()) -} diff --git a/vendor/rmcp/tests/test_structured_output.rs b/vendor/rmcp/tests/test_structured_output.rs deleted file mode 100644 index f3416e519..000000000 --- a/vendor/rmcp/tests/test_structured_output.rs +++ /dev/null @@ -1,422 +0,0 @@ -#![allow(clippy::exhaustive_structs)] -//cargo test --test test_structured_output --features "client server macros" -use rmcp::{ - Json, ServerHandler, - handler::server::{router::tool::ToolRouter, tool::IntoCallToolResult, wrapper::Parameters}, - model::{CallToolResponse, CallToolResult, ContentBlock, ServerResult, Tool}, - tool, tool_handler, tool_router, -}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; - -#[derive(Serialize, Deserialize, JsonSchema)] -pub struct CalculationRequest { - pub a: i32, - pub b: i32, -} - -#[derive(Serialize, Deserialize, JsonSchema)] -pub struct CalculationResult { - pub sum: i32, - pub product: i32, -} - -#[derive(Serialize, Deserialize, JsonSchema)] -pub struct UserInfo { - pub name: String, - pub age: u32, -} - -#[derive(Serialize, Deserialize, JsonSchema)] -pub struct GreetingRequest { - pub name: String, -} - -#[derive(Serialize, Deserialize, JsonSchema)] -pub struct GetUserRequest { - pub user_id: String, -} - -#[tool_handler(router = self.tool_router)] -impl ServerHandler for TestServer {} - -#[derive(Debug, Clone)] -pub struct TestServer { - tool_router: ToolRouter, -} - -impl Default for TestServer { - fn default() -> Self { - Self::new() - } -} - -#[tool_router(router = tool_router)] -impl TestServer { - pub fn new() -> Self { - Self { - tool_router: Self::tool_router(), - } - } - - /// Tool that returns structured output - #[tool(name = "calculate", description = "Perform calculations")] - pub async fn calculate( - &self, - params: Parameters, - ) -> Result, String> { - Ok(Json(CalculationResult { - sum: params.0.a + params.0.b, - product: params.0.a * params.0.b, - })) - } - - /// Tool that returns regular string output - #[tool(name = "get-greeting", description = "Get a greeting")] - pub async fn get_greeting(&self, params: Parameters) -> String { - format!("Hello, {}!", params.0.name) - } - - /// Tool that returns structured user info - #[tool(name = "get-user", description = "Get user info")] - pub async fn get_user( - &self, - params: Parameters, - ) -> Result, String> { - if params.0.user_id == "123" { - Ok(Json(UserInfo { - name: "Alice".to_string(), - age: 30, - })) - } else { - Err("User not found".to_string()) - } - } - - /// Tool that returns a list of calculation results - #[tool( - name = "calculate-list", - description = "Return a list of calculation results" - )] - pub async fn calculate_list( - &self, - params: Parameters, - ) -> Result>, String> { - Ok(Json(vec![CalculationResult { - sum: params.0.a + params.0.b, - product: params.0.a * params.0.b, - }])) - } - - /// Tool that returns a count - #[tool(name = "get-count", description = "Return a count")] - pub async fn get_count(&self) -> Result, String> { - Ok(Json(42)) - } -} - -#[tokio::test] -async fn test_tool_with_output_schema() { - let server = TestServer::new(); - let tools = server.tool_router.list_all(); - - // Find the calculate tool - let calculate_tool = tools.iter().find(|t| t.name == "calculate").unwrap(); - - // Verify it has an output schema - assert!(calculate_tool.output_schema.is_some()); - - let schema = calculate_tool.output_schema.as_ref().unwrap(); - - // Check that the schema contains expected fields - let schema_str = serde_json::to_string(schema).unwrap(); - assert!(schema_str.contains("sum")); - assert!(schema_str.contains("product")); -} - -#[tokio::test] -async fn test_tool_without_output_schema() { - let server = TestServer::new(); - let tools = server.tool_router.list_all(); - - // Find the get-greeting tool - let greeting_tool = tools.iter().find(|t| t.name == "get-greeting").unwrap(); - - // Verify it doesn't have an output schema (returns String) - assert!(greeting_tool.output_schema.is_none()); -} - -#[tokio::test] -async fn test_structured_content_in_call_result() { - // Test creating a CallToolResult with structured content - let structured_data = json!({ - "sum": 7, - "product": 12 - }); - - let result = CallToolResult::structured(structured_data.clone()); - - assert!(!result.content.is_empty()); - assert!(result.structured_content.is_some()); - - let contents = result.content; - - assert_eq!(contents.len(), 1); - - let content_text = contents.first().unwrap().as_text(); - - assert!(content_text.is_some()); - - let content_value: Value = serde_json::from_str(&content_text.unwrap().text).unwrap(); - - assert_eq!(content_value, structured_data); - assert_eq!(result.structured_content.unwrap(), structured_data); - assert_eq!(result.is_error, Some(false)); -} - -#[tokio::test] -async fn test_structured_error_in_call_result() { - // Test creating a CallToolResult with structured error - let error_data = json!({ - "error_code": "NOT_FOUND", - "message": "User not found" - }); - - let result = CallToolResult::structured_error(error_data.clone()); - - assert!(!result.content.is_empty()); - assert!(result.structured_content.is_some()); - - let contents = result.content; - - assert_eq!(contents.len(), 1); - - let content_text = contents.first().unwrap().as_text(); - - assert!(content_text.is_some()); - - let content_value: Value = serde_json::from_str(&content_text.unwrap().text).unwrap(); - - assert_eq!(content_value, error_data); - assert_eq!(result.structured_content.unwrap(), error_data); - assert_eq!(result.is_error, Some(true)); -} - -#[tokio::test] -async fn test_mutual_exclusivity_validation() { - #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] - pub struct Response { - message: String, - } - let response = Response { - message: "Hello".into(), - }; - // Test that content and structured_content can both be passed separately - let content_result = - CallToolResult::success(vec![ContentBlock::json(response.clone()).unwrap()]); - let structured_result = CallToolResult::structured(json!({"message": "Hello"})); - - // Verify the validation - content_result - .into_typed::() - .expect("Failed to extract content"); - structured_result - .into_typed::() - .expect("Failed to extract content"); - - // Try to create a result with both fields - let json_with_both = json!({ - "content": [{"type": "text", "text": "Hello"}], - "structuredContent": {"message": "Hello"} - }); - - // The deserialization itself should not fail - let deserialized: Result = serde_json::from_value(json_with_both); - assert!(deserialized.is_ok()); -} - -#[tokio::test] -async fn test_structured_return_conversion() { - // Test that Json converts to CallToolResult with structured_content - let calc_result = CalculationResult { - sum: 7, - product: 12, - }; - - let structured = Json(calc_result); - let result: Result = - rmcp::handler::server::tool::IntoCallToolResult::into_call_tool_result(structured); - - assert!(result.is_ok()); - let CallToolResponse::Complete(call_result) = result.unwrap() else { - panic!("expected complete CallToolResult"); - }; - - // Tools which return structured content should also return a serialized version as - // Content::text for backwards compatibility. - assert!(!call_result.content.is_empty()); - assert!(call_result.structured_content.is_some()); - - let contents = call_result.content; - - assert_eq!(contents.len(), 1); - - let content_text = contents.first().unwrap().as_text(); - - assert!(content_text.is_some()); - - let content_value: Value = serde_json::from_str(&content_text.unwrap().text).unwrap(); - let structured_value = call_result.structured_content.unwrap(); - - assert_eq!(content_value, structured_value); - - assert_eq!(structured_value["sum"], 7); - assert_eq!(structured_value["product"], 12); -} - -#[tokio::test] -async fn test_tool_serialization_with_output_schema() { - let server = TestServer::new(); - let tools = server.tool_router.list_all(); - - let calculate_tool = tools.iter().find(|t| t.name == "calculate").unwrap(); - - // Serialize the tool - let serialized = serde_json::to_value(calculate_tool).unwrap(); - - // Check that outputSchema is included - assert!(serialized["outputSchema"].is_object()); - - // Deserialize back - let deserialized: Tool = serde_json::from_value(serialized).unwrap(); - assert!(deserialized.output_schema.is_some()); -} - -#[tokio::test] -async fn test_output_schema_requires_structured_content() { - // Test that tools with output_schema must use structured_content - let server = TestServer::new(); - let tools = server.tool_router.list_all(); - - // The calculate tool should have output_schema - let calculate_tool = tools.iter().find(|t| t.name == "calculate").unwrap(); - assert!(calculate_tool.output_schema.is_some()); - - // Directly call the tool and verify its result structure - let params = Parameters(CalculationRequest { a: 5, b: 3 }); - let result = server.calculate(params).await.unwrap(); - - // Convert the Json to CallToolResult - let call_result: Result = - IntoCallToolResult::into_call_tool_result(result); - - assert!(call_result.is_ok()); - let CallToolResponse::Complete(call_result) = call_result.unwrap() else { - panic!("expected complete CallToolResult"); - }; - - // Verify it has structured_content and content - assert!(call_result.structured_content.is_some()); - assert!(!call_result.content.is_empty()); -} - -#[tokio::test] -async fn test_empty_content_array_deserializes() { - let raw = json!({ "content": [] }); - let result: CallToolResult = serde_json::from_value(raw).unwrap(); - assert!(result.content.is_empty()); - assert!(result.structured_content.is_none()); - assert!(result.is_error.is_none()); -} - -#[tokio::test] -async fn test_empty_content_array_with_is_error() { - let raw = json!({ "content": [], "isError": false }); - let result: CallToolResult = serde_json::from_value(raw).unwrap(); - assert!(result.content.is_empty()); - assert_eq!(result.is_error, Some(false)); -} - -#[test] -fn test_missing_content_defaults_to_empty() { - let raw = json!({ "isError": false }); - let result: CallToolResult = serde_json::from_value(raw).unwrap(); - assert!(result.content.is_empty()); - assert_eq!(result.is_error, Some(false)); -} - -#[test] -fn test_missing_content_with_structured_content_deserializes() { - let raw = json!({ "structuredContent": {"key": "value"}, "isError": false }); - let result: CallToolResult = serde_json::from_value(raw).unwrap(); - assert!(result.content.is_empty()); - assert_eq!(result.structured_content.unwrap()["key"], "value"); -} - -#[tokio::test] -async fn test_empty_content_deserializes_as_call_tool_result_variant() { - let raw = json!({ "content": [] }); - let result: ServerResult = serde_json::from_value(raw).unwrap(); - match result { - ServerResult::CallToolResult(call_result) => { - assert!(call_result.content.is_empty()); - assert!(call_result.structured_content.is_none()); - } - other => panic!("Expected CallToolResult, got {:?}", other), - } -} - -#[tokio::test] -async fn test_empty_content_roundtrip() { - let result = CallToolResult::success(vec![]); - let v = serde_json::to_value(&result).unwrap(); - assert_eq!(v["content"], json!([])); - let deserialized: CallToolResult = serde_json::from_value(v).unwrap(); - assert_eq!(deserialized, result); -} - -#[test] -fn test_call_tool_result_deserialize_without_content() { - let json = json!({ - "structuredContent": {"message": "Hello"} - }); - let result: CallToolResult = serde_json::from_value(json).unwrap(); - assert!(result.content.is_empty()); - assert!(result.structured_content.is_some()); -} - -#[tokio::test] -async fn test_tool_with_array_output_schema() { - let server = TestServer::new(); - let tools = server.tool_router.list_all(); - - // Find the calculate-list tool - let calculate_list_tool = tools.iter().find(|t| t.name == "calculate-list").unwrap(); - - // Verify it has an output schema - assert!(calculate_list_tool.output_schema.is_some()); - - let schema = calculate_list_tool.output_schema.as_ref().unwrap(); - - // Check that the schema contains array type - let schema_str = serde_json::to_string(schema).unwrap(); - assert!(schema_str.contains("array")); -} - -#[tokio::test] -async fn test_tool_with_primitive_output_schema() { - let server = TestServer::new(); - let tools = server.tool_router.list_all(); - - // Find the get-count tool - let get_count_tool = tools.iter().find(|t| t.name == "get-count").unwrap(); - - // Verify it has an output schema - assert!(get_count_tool.output_schema.is_some()); - - let schema = get_count_tool.output_schema.as_ref().unwrap(); - - // Check that the schema contains integer type - assert_eq!(schema.get("type"), Some(&serde_json::json!("integer"))); -} diff --git a/vendor/rmcp/tests/test_subscriptions.rs b/vendor/rmcp/tests/test_subscriptions.rs deleted file mode 100644 index 8ceef93cd..000000000 --- a/vendor/rmcp/tests/test_subscriptions.rs +++ /dev/null @@ -1,727 +0,0 @@ -#![cfg(all( - not(feature = "local"), - feature = "client", - feature = "server", - feature = "transport-io" -))] - -use std::{ - num::NonZeroUsize, - sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }, - time::Duration, -}; - -use rmcp::{ - ClientHandler, ClientServiceExt, ServerHandler, ServiceExt, - model::{ - ClientNotification, ClientRequest, DiscoverResult, GetMeta, Implementation, - NotificationMetaObject, PromptListChangedNotification, ProtocolVersion, ServerCapabilities, - ServerInfo, ServerNotification, ServerResult, SubscriptionFilter, - SubscriptionsAcknowledgedNotification, SubscriptionsAcknowledgedNotificationParams, - SubscriptionsListenResult, - }, - service::{ - NotificationContext, RequestContext, RoleClient, RoleServer, SubscriptionContext, - SubscriptionEnd, SubscriptionSendError, SubscriptionSink, - }, -}; -use tokio::sync::{Mutex, Notify}; - -struct ToolsOnlyServer; - -#[derive(Clone)] -struct CountingClient { - tool_changes: Arc, -} - -impl ClientHandler for CountingClient { - async fn on_tool_list_changed(&self, _context: NotificationContext) { - self.tool_changes.fetch_add(1, Ordering::Relaxed); - } -} - -impl ServerHandler for ToolsOnlyServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( - ServerCapabilities::builder() - .enable_tools() - .enable_tool_list_changed() - .build(), - ) - .with_server_info(Implementation::new("tools-only-server", "1.0.0")) - } - - fn accepted_subscription_filter( - &self, - requested: &SubscriptionFilter, - ) -> Option { - Some(requested.supported_by(&self.get_info().capabilities)) - } - - async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { - context - .sink() - .notify_tool_list_changed() - .await - .expect("accepted tool notification"); - assert!(matches!( - context.sink().notify_prompt_list_changed().await, - Err(SubscriptionSendError::NotificationNotAccepted( - "notifications/prompts/list_changed" - )) - )); - Ok(()) - } -} - -struct ToolsAndPromptsServer; - -impl ServerHandler for ToolsAndPromptsServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( - ServerCapabilities::builder() - .enable_tools() - .enable_tool_list_changed() - .enable_prompts() - .enable_prompts_list_changed() - .build(), - ) - } - - fn accepted_subscription_filter( - &self, - requested: &SubscriptionFilter, - ) -> Option { - Some(requested.supported_by(&self.get_info().capabilities)) - } - - async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { - if context.accepted().tools_list_changed == Some(true) { - context - .sink() - .notify_tool_list_changed() - .await - .expect("send tool notification"); - } - if context.accepted().prompts_list_changed == Some(true) { - context - .sink() - .notify_prompt_list_changed() - .await - .expect("send prompt notification"); - } - Ok(()) - } -} - -struct ResourceSubscriptionServer; - -impl ServerHandler for ResourceSubscriptionServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( - ServerCapabilities::builder() - .enable_resources() - .enable_resources_subscribe() - .build(), - ) - } - - fn accepted_subscription_filter( - &self, - requested: &SubscriptionFilter, - ) -> Option { - Some(requested.supported_by(&self.get_info().capabilities)) - } - - async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { - context - .sink() - .notify_resource_updated("file:///accepted") - .await - .expect("accepted URI"); - assert!(matches!( - context - .sink() - .notify_resource_updated("file:///not-requested") - .await, - Err(SubscriptionSendError::NotificationNotAccepted( - "notifications/resources/updated" - )) - )); - Ok(()) - } -} - -struct RemoteCancellationServer; - -impl ServerHandler for RemoteCancellationServer { - fn accepted_subscription_filter( - &self, - requested: &SubscriptionFilter, - ) -> Option { - Some(requested.clone()) - } - - async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { - context - .request_context() - .peer - .notify_cancelled(rmcp::model::CancelledNotificationParam::new( - Some(context.sink().id().clone()), - Some("server shutdown".to_owned()), - )) - .await - .expect("send server cancellation"); - std::future::pending().await - } -} - -struct FloodServer; - -impl ServerHandler for FloodServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( - ServerCapabilities::builder() - .enable_tools() - .enable_tool_list_changed() - .build(), - ) - } - - fn accepted_subscription_filter( - &self, - requested: &SubscriptionFilter, - ) -> Option { - Some(requested.clone()) - } - - async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { - for _ in 0..10 { - if context.sink().notify_tool_list_changed().await.is_err() { - break; - } - } - context.cancelled().await; - Ok(()) - } -} - -#[derive(Clone)] -struct ClosedSinkServer { - sink: Arc>>, -} - -struct LeakyServer; - -impl ServerHandler for LeakyServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( - ServerCapabilities::builder() - .enable_tools() - .enable_tool_list_changed() - .build(), - ) - } - - fn accepted_subscription_filter( - &self, - requested: &SubscriptionFilter, - ) -> Option { - Some(requested.supported_by(&self.get_info().capabilities)) - } - - async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { - let mut notification = - ServerNotification::PromptListChangedNotification(PromptListChangedNotification { - method: Default::default(), - extensions: Default::default(), - }); - notification - .get_meta_mut() - .set_subscription_id(context.sink().id().clone()); - context - .request_context() - .peer - .send_notification(notification) - .await - .expect("send deliberately invalid notification"); - std::future::pending().await - } -} - -struct MalformedAcknowledgmentServer { - cancelled: Arc, -} - -impl rmcp::service::Service for MalformedAcknowledgmentServer { - async fn handle_request( - &self, - request: ClientRequest, - context: RequestContext, - ) -> Result { - match request { - ClientRequest::DiscoverRequest(_) => Ok(ServerResult::DiscoverResult( - DiscoverResult::new( - vec![ProtocolVersion::V_2026_07_28], - ServerCapabilities::builder() - .enable_tools() - .enable_tool_list_changed() - .enable_prompts() - .enable_prompts_list_changed() - .build(), - ) - .with_server_info(Implementation::new("malformed-ack-server", "1.0.0")), - )), - ClientRequest::SubscriptionsListenRequest(_) => { - let mut acknowledgment = SubscriptionsAcknowledgedNotification::new( - SubscriptionsAcknowledgedNotificationParams::new( - SubscriptionFilter::builder().prompts_list_changed().build(), - ), - ); - let mut meta = NotificationMetaObject::new(); - meta.set_subscription_id(context.id.clone()); - acknowledgment.extensions.insert(meta); - context - .peer - .send_notification(ServerNotification::SubscriptionsAcknowledgedNotification( - acknowledgment, - )) - .await - .map_err(|error| rmcp::ErrorData::internal_error(error.to_string(), None))?; - context.ct.cancelled().await; - Ok(ServerResult::SubscriptionsListenResult( - SubscriptionsListenResult::complete(context.id), - )) - } - _ => Err(rmcp::ErrorData::invalid_request( - "unexpected test request", - None, - )), - } - } - - async fn handle_notification( - &self, - notification: ClientNotification, - _context: NotificationContext, - ) -> Result<(), rmcp::ErrorData> { - if matches!(notification, ClientNotification::CancelledNotification(_)) { - self.cancelled.notify_one(); - } - Ok(()) - } - - fn get_info(&self) -> rmcp::model::ServerInfo { - ServerInfo::default() - } -} - -impl ServerHandler for ClosedSinkServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( - ServerCapabilities::builder() - .enable_tools() - .enable_tool_list_changed() - .build(), - ) - } - - fn accepted_subscription_filter( - &self, - requested: &SubscriptionFilter, - ) -> Option { - Some(requested.supported_by(&self.get_info().capabilities)) - } - - async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { - self.sink.lock().await.replace(context.sink().clone()); - Ok(()) - } -} - -#[derive(Clone)] -struct CancellationServer { - cancelled: Arc, -} - -impl ServerHandler for CancellationServer { - fn accepted_subscription_filter( - &self, - requested: &SubscriptionFilter, - ) -> Option { - Some(requested.clone()) - } - - async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { - context.cancelled().await; - self.cancelled.notify_one(); - Ok(()) - } -} - -#[derive(Clone)] -struct AbruptServer { - started: Arc, -} - -impl ServerHandler for AbruptServer { - fn accepted_subscription_filter( - &self, - requested: &SubscriptionFilter, - ) -> Option { - Some(requested.clone()) - } - - async fn listen(&self, _context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { - self.started.notify_one(); - std::future::pending().await - } -} - -async fn modern_client( - server: S, -) -> anyhow::Result> { - let (server_transport, client_transport) = tokio::io::duplex(16 * 1024); - tokio::spawn(async move { - let server = server.serve(server_transport).await?; - server.waiting().await?; - anyhow::Ok(()) - }); - ().serve_with_lifecycle( - client_transport, - rmcp::ClientLifecycleMode::Discover { - preferred_versions: vec![ProtocolVersion::V_2026_07_28], - }, - ) - .await - .map_err(Into::into) -} - -#[tokio::test] -async fn listen_exposes_acknowledged_filter_and_graceful_result() -> anyhow::Result<()> { - let client = modern_client(ToolsOnlyServer).await?; - let mut subscription = client - .listen( - SubscriptionFilter::builder() - .tools_list_changed() - .prompts_list_changed() - .build(), - ) - .await?; - - assert_eq!( - subscription.acknowledged(), - &SubscriptionFilter::builder().tools_list_changed().build() - ); - - let notification = tokio::time::timeout(Duration::from_secs(5), subscription.next()) - .await?? - .expect("tool notification"); - assert!(matches!( - notification, - ServerNotification::ToolListChangedNotification(_) - )); - assert_eq!( - notification.get_meta().subscription_id(), - Some(subscription.id().clone()) - ); - - assert!( - tokio::time::timeout(Duration::from_secs(5), subscription.next()) - .await?? - .is_none() - ); - let Some(SubscriptionEnd::Graceful(result)) = subscription.end() else { - panic!("expected graceful final result"); - }; - assert_eq!( - result.meta.subscription_id().as_ref(), - Some(subscription.id()) - ); - assert_eq!( - result - .meta - .server_info() - .expect("graceful result should contain valid server info"), - Implementation::new("tools-only-server", "1.0.0") - ); - - client.cancel().await?; - Ok(()) -} - -#[tokio::test] -async fn typed_subscription_notifications_do_not_reach_handler_callbacks() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(16 * 1024); - tokio::spawn(async move { - let server = ToolsOnlyServer.serve(server_transport).await?; - server.waiting().await?; - anyhow::Ok(()) - }); - let tool_changes = Arc::new(AtomicUsize::new(0)); - let client = CountingClient { - tool_changes: tool_changes.clone(), - } - .serve_with_lifecycle( - client_transport, - rmcp::ClientLifecycleMode::Discover { - preferred_versions: vec![ProtocolVersion::V_2026_07_28], - }, - ) - .await?; - let mut subscription = client - .listen(SubscriptionFilter::builder().tools_list_changed().build()) - .await?; - - assert!(subscription.next().await?.is_some()); - assert!(subscription.next().await?.is_none()); - tokio::task::yield_now().await; - assert_eq!(tool_changes.load(Ordering::Relaxed), 0); - - client.cancel().await?; - Ok(()) -} - -#[tokio::test] -async fn discover_lifecycle_allows_subscriptions_with_older_application_version() --> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(16 * 1024); - tokio::spawn(async move { - let server = ToolsOnlyServer.serve(server_transport).await?; - server.waiting().await?; - anyhow::Ok(()) - }); - let client = () - .serve_with_lifecycle( - client_transport, - rmcp::ClientLifecycleMode::Discover { - preferred_versions: vec![ProtocolVersion::V_2025_11_25], - }, - ) - .await?; - let mut subscription = client - .listen(SubscriptionFilter::builder().tools_list_changed().build()) - .await?; - - assert!(subscription.next().await?.is_some()); - assert!(subscription.next().await?.is_none()); - - client.cancel().await?; - Ok(()) -} - -#[tokio::test] -async fn concurrent_subscriptions_are_demultiplexed_by_request_id() -> anyhow::Result<()> { - let client = modern_client(ToolsAndPromptsServer).await?; - let (tools, prompts) = tokio::join!( - client.listen(SubscriptionFilter::builder().tools_list_changed().build()), - client.listen(SubscriptionFilter::builder().prompts_list_changed().build()) - ); - let mut tools = tools?; - let mut prompts = prompts?; - - let tool_notification = tools.next().await?.expect("tool notification"); - let prompt_notification = prompts.next().await?.expect("prompt notification"); - assert!(matches!( - tool_notification, - ServerNotification::ToolListChangedNotification(_) - )); - assert!(matches!( - prompt_notification, - ServerNotification::PromptListChangedNotification(_) - )); - assert_ne!(tools.id(), prompts.id()); - - client.cancel().await?; - Ok(()) -} - -#[tokio::test] -async fn stdio_cancellation_sends_cancelled_for_the_listen_request() -> anyhow::Result<()> { - let cancelled = Arc::new(Notify::new()); - let client = modern_client(CancellationServer { - cancelled: cancelled.clone(), - }) - .await?; - let mut subscription = client.listen(SubscriptionFilter::new()).await?; - - subscription.cancel().await?; - assert!(matches!( - subscription.end(), - Some(SubscriptionEnd::Cancelled) - )); - tokio::time::timeout(Duration::from_secs(5), cancelled.notified()).await?; - - client.cancel().await?; - Ok(()) -} - -#[tokio::test] -async fn resource_updates_are_filtered_by_exact_uri_membership() -> anyhow::Result<()> { - let client = modern_client(ResourceSubscriptionServer).await?; - let mut subscription = client - .listen( - SubscriptionFilter::builder() - .resource_subscription("file:///accepted") - .build(), - ) - .await?; - - let notification = subscription.next().await?.expect("resource update"); - let ServerNotification::ResourceUpdatedNotification(update) = notification else { - panic!("expected resource update"); - }; - assert_eq!(update.params.uri, "file:///accepted"); - assert!(subscription.next().await?.is_none()); - - client.cancel().await?; - Ok(()) -} - -#[tokio::test] -async fn stdio_server_cancellation_ends_the_matching_subscription() -> anyhow::Result<()> { - let client = modern_client(RemoteCancellationServer).await?; - let mut subscription = client.listen(SubscriptionFilter::new()).await?; - - assert!(subscription.next().await?.is_none()); - assert!(matches!( - subscription.end(), - Some(SubscriptionEnd::Cancelled) - )); - - client.cancel().await?; - Ok(()) -} - -#[tokio::test] -async fn slow_consumer_reports_subscription_lag() -> anyhow::Result<()> { - let client = modern_client(FloodServer).await?; - let mut subscription = client - .listen_with_capacity( - SubscriptionFilter::builder().tools_list_changed().build(), - NonZeroUsize::MIN, - ) - .await?; - - tokio::time::sleep(Duration::from_millis(50)).await; - while subscription.next().await?.is_some() {} - assert!( - matches!( - subscription.end(), - Some(SubscriptionEnd::Lagged { capacity: 1 }) - ), - "unexpected end: {:?}", - subscription.end() - ); - - client.cancel().await?; - Ok(()) -} - -#[tokio::test] -async fn sink_rejects_notifications_after_graceful_completion() -> anyhow::Result<()> { - let sink = Arc::new(Mutex::new(None)); - let client = modern_client(ClosedSinkServer { sink: sink.clone() }).await?; - let mut subscription = client - .listen(SubscriptionFilter::builder().tools_list_changed().build()) - .await?; - assert!(subscription.next().await?.is_none()); - - let sink = sink.lock().await.clone().expect("captured sink"); - assert!(matches!( - sink.notify_tool_list_changed().await, - Err(SubscriptionSendError::SubscriptionClosed) - )); - - client.cancel().await?; - Ok(()) -} - -#[tokio::test] -async fn client_rejects_notifications_outside_the_acknowledged_filter() -> anyhow::Result<()> { - let client = modern_client(LeakyServer).await?; - let mut subscription = client - .listen(SubscriptionFilter::builder().tools_list_changed().build()) - .await?; - - assert!(matches!( - subscription.next().await, - Err(rmcp::ServiceError::UnexpectedResponse) - )); - assert!(matches!(subscription.end(), Some(SubscriptionEnd::Abrupt))); - - client.cancel().await?; - Ok(()) -} - -#[tokio::test] -async fn malformed_acknowledgment_cancels_pending_listen_request() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(16 * 1024); - let cancelled = Arc::new(Notify::new()); - let server_cancelled = cancelled.clone(); - tokio::spawn(async move { - let server = MalformedAcknowledgmentServer { - cancelled: server_cancelled, - } - .serve(server_transport) - .await?; - server.waiting().await?; - anyhow::Ok(()) - }); - let client = () - .serve_with_lifecycle( - client_transport, - rmcp::ClientLifecycleMode::Discover { - preferred_versions: vec![ProtocolVersion::V_2026_07_28], - }, - ) - .await?; - - assert!(matches!( - client - .listen(SubscriptionFilter::builder().tools_list_changed().build()) - .await, - Err(rmcp::ServiceError::UnexpectedResponse) - )); - tokio::time::timeout(Duration::from_secs(5), cancelled.notified()).await?; - - client.cancel().await?; - Ok(()) -} - -#[tokio::test] -async fn transport_close_without_final_result_is_abrupt() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(16 * 1024); - let started = Arc::new(Notify::new()); - let server_started = started.clone(); - tokio::spawn(async move { - let server = AbruptServer { - started: server_started.clone(), - } - .serve(server_transport) - .await?; - server_started.notified().await; - server.cancel().await?; - anyhow::Ok(()) - }); - let client = () - .serve_with_lifecycle( - client_transport, - rmcp::ClientLifecycleMode::Discover { - preferred_versions: vec![ProtocolVersion::V_2026_07_28], - }, - ) - .await?; - let mut subscription = client.listen(SubscriptionFilter::new()).await?; - - assert!( - tokio::time::timeout(Duration::from_secs(5), subscription.next()) - .await?? - .is_none() - ); - assert!(matches!(subscription.end(), Some(SubscriptionEnd::Abrupt))); - Ok(()) -} diff --git a/vendor/rmcp/tests/test_subscriptions_model.rs b/vendor/rmcp/tests/test_subscriptions_model.rs deleted file mode 100644 index 8d79fb119..000000000 --- a/vendor/rmcp/tests/test_subscriptions_model.rs +++ /dev/null @@ -1,254 +0,0 @@ -use rmcp::model::{ - ClientJsonRpcMessage, ClientRequest, GetMeta, Implementation, JsonRpcNotification, - JsonRpcRequest, NotificationMetaObject, RequestId, RequestMetaObject, ServerJsonRpcMessage, - ServerNotification, SubscriptionFilter, SubscriptionsAcknowledgedNotification, - SubscriptionsAcknowledgedNotificationParams, SubscriptionsListenRequest, - SubscriptionsListenRequestParams, SubscriptionsListenResult, SubscriptionsListenResultMeta, -}; -use serde_json::json; - -#[test] -fn subscription_filter_serializes_only_opted_in_notifications() { - let filter = SubscriptionFilter::builder() - .tools_list_changed() - .resource_subscription("file:///one") - .resource_subscription("file:///two") - .build(); - - assert_eq!( - serde_json::to_value(filter).expect("serialize filter"), - json!({ - "toolsListChanged": true, - "resourceSubscriptions": ["file:///one", "file:///two"], - }) - ); -} - -#[test] -fn subscription_filter_subset_is_order_independent_and_ignores_false_flags() { - let requested = SubscriptionFilter::builder() - .tools_list_changed() - .resource_subscriptions(["file:///one", "file:///two"]) - .build(); - let mut accepted = SubscriptionFilter::builder() - .resource_subscriptions(["file:///two", "file:///one"]) - .build(); - accepted.tools_list_changed = Some(false); - - assert!(accepted.is_subset_of(&requested)); -} - -#[test] -fn subscription_filter_omits_empty_resource_intersection() { - let requested = SubscriptionFilter::builder() - .resource_subscription("file:///requested") - .build(); - let accepted = SubscriptionFilter::builder() - .resource_subscription("file:///different") - .build(); - - assert_eq!( - serde_json::to_value(requested.intersection(&accepted)).expect("serialize intersection"), - json!({}) - ); -} - -#[test] -fn listen_request_round_trips_required_fields_and_arbitrary_metadata() { - let mut request = SubscriptionsListenRequest::new(SubscriptionsListenRequestParams::new( - SubscriptionFilter::builder().prompts_list_changed().build(), - )); - let mut meta = RequestMetaObject::new(); - meta.insert("com.example/request".into(), json!("value")); - request.extensions.insert(meta); - let message = ClientJsonRpcMessage::request( - ClientRequest::SubscriptionsListenRequest(request), - RequestId::String("subscription-1".into()), - ); - - let value = serde_json::to_value(&message).expect("serialize listen request"); - assert_eq!( - value, - json!({ - "jsonrpc": "2.0", - "id": "subscription-1", - "method": "subscriptions/listen", - "params": { - "_meta": { - "com.example/request": "value", - }, - "notifications": { - "promptsListChanged": true, - }, - }, - }) - ); - - let round_trip: ClientJsonRpcMessage = - serde_json::from_value(value).expect("deserialize listen request"); - let ClientJsonRpcMessage::Request(JsonRpcRequest { request, .. }) = round_trip else { - panic!("expected request"); - }; - let ClientRequest::SubscriptionsListenRequest(request) = request else { - panic!("expected subscriptions/listen request"); - }; - assert_eq!( - request - .extensions - .get::() - .and_then(|meta| meta.get("com.example/request")), - Some(&json!("value")) - ); -} - -#[test] -fn acknowledged_notification_round_trips_numeric_subscription_id_and_metadata() { - let mut notification = SubscriptionsAcknowledgedNotification::new( - SubscriptionsAcknowledgedNotificationParams::new( - SubscriptionFilter::builder() - .resources_list_changed() - .build(), - ), - ); - let mut meta = NotificationMetaObject::new(); - meta.set_subscription_id(RequestId::Number(7)); - meta.insert("com.example/notification".into(), json!(true)); - notification.extensions.insert(meta); - let message = ServerJsonRpcMessage::notification( - ServerNotification::SubscriptionsAcknowledgedNotification(notification), - ); - - let value = serde_json::to_value(&message).expect("serialize acknowledgment"); - assert_eq!( - value, - json!({ - "jsonrpc": "2.0", - "method": "notifications/subscriptions/acknowledged", - "params": { - "_meta": { - "io.modelcontextprotocol/subscriptionId": 7, - "com.example/notification": true, - }, - "notifications": { - "resourcesListChanged": true, - }, - }, - }) - ); - - let round_trip: ServerJsonRpcMessage = - serde_json::from_value(value).expect("deserialize acknowledgment"); - let ServerJsonRpcMessage::Notification(JsonRpcNotification { notification, .. }) = round_trip - else { - panic!("expected notification"); - }; - assert_eq!( - notification.get_meta().subscription_id(), - Some(RequestId::Number(7)) - ); - assert_eq!( - notification.get_meta().get("com.example/notification"), - Some(&json!(true)) - ); -} - -#[test] -fn listen_result_requires_matching_string_subscription_id_and_preserves_metadata() { - let mut meta = SubscriptionsListenResultMeta::new(RequestId::String("subscription-2".into())); - meta.set_server_info(Implementation::new("test-server", "1.0.0")); - meta.insert("com.example/result".into(), json!({ "reason": "shutdown" })); - let result = SubscriptionsListenResult::new(meta); - - let value = serde_json::to_value(&result).expect("serialize listen result"); - assert_eq!( - value, - json!({ - "resultType": "complete", - "_meta": { - "io.modelcontextprotocol/subscriptionId": "subscription-2", - "io.modelcontextprotocol/serverInfo": { - "name": "test-server", - "version": "1.0.0", - }, - "com.example/result": { - "reason": "shutdown", - }, - }, - }) - ); - - let round_trip: SubscriptionsListenResult = - serde_json::from_value(value).expect("deserialize listen result"); - assert_eq!( - round_trip.meta.subscription_id(), - Some(RequestId::String("subscription-2".into())) - ); - assert_eq!( - round_trip.meta.server_info(), - Some(Implementation::new("test-server", "1.0.0")) - ); - assert_eq!( - round_trip.meta.get("com.example/result"), - Some(&json!({ "reason": "shutdown" })) - ); -} - -#[test] -fn listen_result_meta_returns_none_after_required_id_is_removed() { - let mut meta = SubscriptionsListenResultMeta::new(RequestId::Number(1)); - meta.remove("io.modelcontextprotocol/subscriptionId"); - - assert_eq!(meta.subscription_id(), None); -} - -#[cfg(feature = "schemars")] -#[test] -fn subscription_schemas_mark_only_draft_required_fields_as_required() { - let request_schema = - serde_json::to_value(schemars::schema_for!(SubscriptionsListenRequestParams)) - .expect("request schema"); - let filter_schema = - serde_json::to_value(schemars::schema_for!(SubscriptionFilter)).expect("filter schema"); - let acknowledgment_schema = serde_json::to_value(schemars::schema_for!( - SubscriptionsAcknowledgedNotificationParams - )) - .expect("acknowledgment schema"); - let result_schema = serde_json::to_value(schemars::schema_for!(SubscriptionsListenResult)) - .expect("result schema"); - - assert_eq!( - request_schema["required"], - json!(["_meta", "notifications"]) - ); - assert_eq!( - request_schema["properties"]["_meta"]["required"], - json!([ - "io.modelcontextprotocol/protocolVersion", - "io.modelcontextprotocol/clientCapabilities" - ]) - ); - assert!(filter_schema.get("required").is_none()); - assert_eq!( - filter_schema["properties"]["toolsListChanged"]["type"], - "boolean" - ); - assert_eq!( - filter_schema["properties"]["resourceSubscriptions"]["type"], - "array" - ); - assert_eq!(acknowledgment_schema["required"], json!(["notifications"])); - assert_eq!( - acknowledgment_schema["properties"]["_meta"]["$ref"], - "#/$defs/NotificationMetaObject" - ); - let result_meta_schema = &result_schema["$defs"]["SubscriptionsListenResultMeta"]; - assert_eq!( - result_meta_schema["properties"]["io.modelcontextprotocol/serverInfo"]["allOf"][0]["$ref"], - "#/$defs/Implementation" - ); - assert_eq!( - result_meta_schema["required"], - json!(["io.modelcontextprotocol/subscriptionId"]) - ); - assert_eq!(result_schema["required"], json!(["resultType", "_meta"])); -} diff --git a/vendor/rmcp/tests/test_subscriptions_streamable_http.rs b/vendor/rmcp/tests/test_subscriptions_streamable_http.rs deleted file mode 100644 index bfb931371..000000000 --- a/vendor/rmcp/tests/test_subscriptions_streamable_http.rs +++ /dev/null @@ -1,317 +0,0 @@ -#![cfg(all( - not(feature = "local"), - feature = "client", - feature = "server", - feature = "transport-streamable-http-client-reqwest", - feature = "transport-streamable-http-server" -))] - -use std::{ - borrow::Cow, - sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }, - time::Duration, -}; - -use rmcp::{ - ClientLifecycleMode, ClientServiceExt, ServerHandler, - model::{ - ClientInfo, ClientRequest, Implementation, ListToolsRequest, ProtocolVersion, - RequestMetaObject, ServerCapabilities, ServerInfo, ServerNotification, SubscriptionFilter, - }, - service::{PeerRequestOptions, SubscriptionContext, SubscriptionEnd}, - transport::{ - StreamableHttpClientTransport, - streamable_http_client::StreamableHttpClientTransportConfig, - streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, - }, - }, -}; -use tokio::sync::Notify; -use tokio_util::sync::CancellationToken; - -#[derive(Clone)] -struct HttpSubscriptionServer { - cancelled: Arc, - started: Arc, - ending: ServerEnding, -} - -#[derive(Clone, Copy)] -enum ServerEnding { - ClientCancellation, - Graceful, - Abrupt, -} - -impl ServerHandler for HttpSubscriptionServer { - fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { - Cow::Borrowed(&[ProtocolVersion::V_2026_07_28, ProtocolVersion::V_2025_11_25]) - } - - fn get_info(&self) -> ServerInfo { - ServerInfo::new( - ServerCapabilities::builder() - .enable_tools() - .enable_tool_list_changed() - .build(), - ) - .with_server_info(Implementation::new("http-subscription-server", "1.0.0")) - } - - fn accepted_subscription_filter( - &self, - requested: &SubscriptionFilter, - ) -> Option { - Some(requested.supported_by(&self.get_info().capabilities)) - } - - async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { - context - .sink() - .notify_tool_list_changed() - .await - .expect("send tool notification"); - self.started.notify_one(); - match self.ending { - ServerEnding::Graceful => Ok(()), - ServerEnding::ClientCancellation => { - context.cancelled().await; - self.cancelled.notify_one(); - Ok(()) - } - ServerEnding::Abrupt => std::future::pending().await, - } - } -} - -async fn spawn_server( - ending: ServerEnding, -) -> ( - String, - CancellationToken, - Arc, - Arc, - Arc, -) { - let cancellation_token = CancellationToken::new(); - let subscription_cancelled = Arc::new(Notify::new()); - let subscription_started = Arc::new(Notify::new()); - let server = HttpSubscriptionServer { - cancelled: subscription_cancelled.clone(), - started: subscription_started.clone(), - ending, - }; - let service: StreamableHttpService = - StreamableHttpService::new( - move || Ok(server.clone()), - Default::default(), - StreamableHttpServerConfig::default() - .with_legacy_session_mode(true) - .with_json_response(true) - .with_sse_keep_alive(Some(Duration::from_millis(50))) - .with_cancellation_token(cancellation_token.child_token()), - ); - let get_requests = Arc::new(AtomicUsize::new(0)); - let observed_get_requests = get_requests.clone(); - let router = - axum::Router::new() - .nest_service("/mcp", service) - .layer(axum::middleware::from_fn( - move |request: axum::extract::Request, next: axum::middleware::Next| { - let observed_get_requests = observed_get_requests.clone(); - async move { - if request.method() == axum::http::Method::GET { - observed_get_requests.fetch_add(1, Ordering::Relaxed); - } - next.run(request).await - } - }, - )); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("listener"); - let address = listener.local_addr().expect("listener address"); - tokio::spawn({ - let cancellation_token = cancellation_token.clone(); - async move { - let _ = axum::serve(listener, router) - .with_graceful_shutdown(async move { cancellation_token.cancelled_owned().await }) - .await; - } - }); - ( - format!("http://{address}/mcp"), - cancellation_token, - subscription_cancelled, - subscription_started, - get_requests, - ) -} - -#[tokio::test] -async fn modern_http_listen_uses_post_stream_and_cancels_by_closing_it() -> anyhow::Result<()> { - let (url, server_ct, subscription_cancelled, _, get_requests) = - spawn_server(ServerEnding::ClientCancellation).await; - let transport = StreamableHttpClientTransport::from_config( - StreamableHttpClientTransportConfig::with_uri(url.clone()), - ); - let client = ClientInfo::default() - .serve_with_lifecycle( - transport, - ClientLifecycleMode::Discover { - preferred_versions: vec![ProtocolVersion::V_2026_07_28], - }, - ) - .await?; - assert_eq!(get_requests.load(Ordering::Relaxed), 0); - let mut subscription = client - .listen(SubscriptionFilter::builder().tools_list_changed().build()) - .await?; - - assert!(matches!( - subscription.next().await?.expect("tool notification"), - ServerNotification::ToolListChangedNotification(_) - )); - let mut older_version = RequestMetaObject::new(); - older_version.set_protocol_version(ProtocolVersion::V_2025_11_25); - client - .send_request_with_option( - ClientRequest::ListToolsRequest(ListToolsRequest { - method: Default::default(), - params: None, - extensions: Default::default(), - }), - PeerRequestOptions::no_options().with_meta(older_version), - ) - .await? - .await_response() - .await?; - subscription.cancel().await?; - tokio::time::timeout(Duration::from_secs(5), subscription_cancelled.notified()).await?; - - client.cancel().await?; - server_ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn modern_http_graceful_close_returns_final_listen_result() -> anyhow::Result<()> { - let (url, server_ct, _, _, _) = spawn_server(ServerEnding::Graceful).await; - let transport = StreamableHttpClientTransport::from_config( - StreamableHttpClientTransportConfig::with_uri(url), - ); - let client = ClientInfo::default() - .serve_with_lifecycle( - transport, - ClientLifecycleMode::Discover { - preferred_versions: vec![ProtocolVersion::V_2026_07_28], - }, - ) - .await?; - let mut subscription = client - .listen(SubscriptionFilter::builder().tools_list_changed().build()) - .await?; - - assert!(subscription.next().await?.is_some()); - assert!(subscription.next().await?.is_none()); - let Some(SubscriptionEnd::Graceful(result)) = subscription.end() else { - panic!("expected graceful final result"); - }; - assert_eq!( - result - .meta - .server_info() - .expect("graceful result should contain valid server info"), - Implementation::new("http-subscription-server", "1.0.0") - ); - - client.cancel().await?; - server_ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn modern_http_stream_close_without_result_is_abrupt() -> anyhow::Result<()> { - let (url, server_ct, _, subscription_started, _) = spawn_server(ServerEnding::Abrupt).await; - let transport = StreamableHttpClientTransport::from_config( - StreamableHttpClientTransportConfig::with_uri(url), - ); - let client = ClientInfo::default() - .serve_with_lifecycle( - transport, - ClientLifecycleMode::Discover { - preferred_versions: vec![ProtocolVersion::V_2026_07_28], - }, - ) - .await?; - let mut subscription = client - .listen(SubscriptionFilter::builder().tools_list_changed().build()) - .await?; - assert!(subscription.next().await?.is_some()); - subscription_started.notified().await; - - server_ct.cancel(); - assert!( - tokio::time::timeout(Duration::from_secs(5), subscription.next()) - .await?? - .is_none() - ); - assert!(matches!(subscription.end(), Some(SubscriptionEnd::Abrupt))); - - client.cancel().await?; - Ok(()) -} - -#[tokio::test] -async fn modern_http_lifecycle_stays_sessionless_for_older_application_version() --> anyhow::Result<()> { - let (url, server_ct, _, _, get_requests) = spawn_server(ServerEnding::ClientCancellation).await; - let transport = StreamableHttpClientTransport::from_config( - StreamableHttpClientTransportConfig::with_uri(url), - ); - let client = ClientInfo::default() - .serve_with_lifecycle( - transport, - ClientLifecycleMode::Discover { - preferred_versions: vec![ProtocolVersion::V_2025_11_25], - }, - ) - .await?; - - client.list_tools(None).await?; - assert_eq!(get_requests.load(Ordering::Relaxed), 0); - - client.cancel().await?; - server_ct.cancel(); - Ok(()) -} - -#[tokio::test] -async fn modern_http_get_and_delete_are_method_not_allowed_in_legacy_session_mode() { - let (url, server_ct, _, _, _) = spawn_server(ServerEnding::ClientCancellation).await; - let client = reqwest::Client::new(); - - for method in [reqwest::Method::GET, reqwest::Method::DELETE] { - let response = client - .request(method, &url) - .header("Accept", "text/event-stream") - .header("MCP-Protocol-Version", "2026-07-28") - .send() - .await - .expect("request"); - assert_eq!(response.status(), reqwest::StatusCode::METHOD_NOT_ALLOWED); - assert_eq!( - response - .headers() - .get(reqwest::header::ALLOW) - .and_then(|value| value.to_str().ok()), - Some("POST") - ); - } - - server_ct.cancel(); -} diff --git a/vendor/rmcp/tests/test_task.rs b/vendor/rmcp/tests/test_task.rs deleted file mode 100644 index ea1a2595e..000000000 --- a/vendor/rmcp/tests/test_task.rs +++ /dev/null @@ -1,442 +0,0 @@ -//! End-to-end tests for the MCP Tasks extension (SEP-2663, -//! `io.modelcontextprotocol/tasks`). -#![cfg(all(feature = "server", feature = "client", not(feature = "local")))] - -use rmcp::{ - ErrorData as McpError, ServerHandler, ServiceExt, - handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::*, - service::{RequestContext, RoleServer}, - task_manager::{TaskExit, TaskManager, TaskOptions}, - tool, tool_router, -}; -use serde_json::json; - -#[derive(Debug, serde::Deserialize, rmcp::schemars::JsonSchema)] -pub struct SumArgs { - pub a: i32, - pub b: i32, -} - -#[derive(Clone)] -struct TaskServer { - tool_router: ToolRouter, - tasks: TaskManager, -} - -#[tool_router] -impl TaskServer { - fn new() -> Self { - Self { - tool_router: Self::tool_router(), - tasks: TaskManager::new(), - } - } - - #[tool(description = "Sum two numbers")] - async fn sum( - &self, - Parameters(SumArgs { a, b }): Parameters, - ) -> Result { - Ok(CallToolResult::success(vec![ContentBlock::text( - (a + b).to_string(), - )])) - } -} - -impl ServerHandler for TaskServer { - async fn call_tool( - &self, - request: CallToolRequestParams, - context: RequestContext, - ) -> Result { - let client_supports_tasks = context - .client_capabilities() - .is_some_and(|caps| caps.supports_tasks()); - - if request.name == "sum" && client_supports_tasks { - let args: SumArgs = serde_json::from_value(serde_json::Value::Object( - request.arguments.clone().unwrap_or_default(), - )) - .map_err(|e| McpError::invalid_params(e.to_string(), None))?; - let task = self - .tasks - .spawn(TaskOptions::new().with_poll_interval_ms(10), move |ctx| { - Box::pin(async move { - tokio::select! { - _ = ctx.cancelled() => { - Err(TaskExit::Cancelled) - } - _ = tokio::time::sleep(std::time::Duration::from_millis(50)) => { - Ok(CallToolResult::success(vec![ContentBlock::text( - (args.a + args.b).to_string(), - )])) - } - } - }) - }); - return Ok(CallToolResponse::Task(CreateTaskResult::new(task))); - } - - let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); - self.tool_router.call(tcc).await - } - - async fn get_task( - &self, - request: GetTaskParams, - _context: RequestContext, - ) -> Result { - Ok(GetTaskResult::new(self.tasks.get_task(&request.task_id)?)) - } - - async fn update_task( - &self, - request: UpdateTaskParams, - _context: RequestContext, - ) -> Result<(), McpError> { - self.tasks - .update_task(&request.task_id, request.input_responses) - } - - async fn cancel_task( - &self, - request: CancelTaskParams, - _context: RequestContext, - ) -> Result<(), McpError> { - self.tasks.cancel_task(&request.task_id) - } - - fn get_info(&self) -> ServerInfo { - ServerInfo::new( - ServerCapabilities::builder() - .enable_tools() - .enable_tasks() - .build(), - ) - } -} - -fn tasks_client_info() -> ClientInfo { - ClientInfo::new( - ClientCapabilities::builder().enable_tasks().build(), - Implementation::from_build_env(), - ) -} - -#[tokio::test] -async fn task_lifecycle_create_poll_complete() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let server = tokio::spawn(async move { - let service = TaskServer::new().serve(server_transport).await?; - service.waiting().await?; - anyhow::Ok(()) - }); - - let client = tasks_client_info().serve(client_transport).await.unwrap(); - - // Server materializes a task because we declared the extension. - let response = client - .call_tool_once( - CallToolRequestParams::new("sum") - .with_arguments(serde_json::from_value(json!({"a": 40, "b": 2})).unwrap()), - ) - .await - .unwrap(); - let create = match response { - CallToolResponse::Task(create) => create, - other => panic!("expected CreateTaskResult, got {other:?}"), - }; - assert_eq!(create.result_type, ResultType::TASK); - let task_id = create.task.task_id.clone(); - - // Poll until terminal. - let final_task = loop { - tokio::time::sleep(std::time::Duration::from_millis( - create.task.poll_interval_ms.unwrap_or(10), - )) - .await; - let info = client - .peer() - .get_task(GetTaskParams::new(task_id.clone())) - .await - .unwrap(); - if info.task.status().is_terminal() { - break info.task; - } - }; - - match final_task.payload { - TaskPayload::Completed { result } => { - let result: CallToolResult = - serde_json::from_value(serde_json::Value::Object(result)).unwrap(); - let text = result.content[0].as_text().unwrap(); - assert_eq!(text.text, "42"); - } - other => panic!("expected completed task, got {other:?}"), - } - - client.cancel().await.unwrap(); - server.abort(); -} - -#[tokio::test] -async fn task_cancel_acknowledged() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let server = tokio::spawn(async move { - let service = TaskServer::new().serve(server_transport).await?; - service.waiting().await?; - anyhow::Ok(()) - }); - - let client = tasks_client_info().serve(client_transport).await.unwrap(); - - let response = client - .call_tool_once( - CallToolRequestParams::new("sum") - .with_arguments(serde_json::from_value(json!({"a": 1, "b": 1})).unwrap()), - ) - .await - .unwrap(); - let create = match response { - CallToolResponse::Task(create) => create, - other => panic!("expected CreateTaskResult, got {other:?}"), - }; - - client - .peer() - .cancel_task(CancelTaskParams::new(create.task.task_id.clone())) - .await - .unwrap(); - - // Cancellation is cooperative (SEP-2663): the ack is immediate, and the - // operation settles the terminal status; poll until it does. - let mut final_status = None; - for _ in 0..100 { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - let info = client - .peer() - .get_task(GetTaskParams::new(create.task.task_id.clone())) - .await - .unwrap(); - if info.task.status().is_terminal() { - final_status = Some(info.task.status()); - break; - } - } - assert_eq!(final_status, Some(TaskStatus::Cancelled)); - - client.cancel().await.unwrap(); - server.abort(); -} - -#[tokio::test] -async fn no_task_without_extension_capability() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let server = tokio::spawn(async move { - let service = TaskServer::new().serve(server_transport).await?; - service.waiting().await?; - anyhow::Ok(()) - }); - - // Plain client: no tasks extension declared. - let client = ().serve(client_transport).await.unwrap(); - let result = client - .call_tool( - CallToolRequestParams::new("sum") - .with_arguments(serde_json::from_value(json!({"a": 2, "b": 3})).unwrap()), - ) - .await - .unwrap(); - let text = result.content[0].as_text().unwrap(); - assert_eq!(text.text, "5"); - - client.cancel().await.unwrap(); - server.abort(); -} - -/// A misbehaving handler that materializes a task without checking the -/// client's capabilities. The SDK dispatch must catch this and reject with -/// -32021 rather than sending a task handle the client cannot parse. -#[derive(Clone)] -struct AlwaysTaskServer { - tasks: TaskManager, -} - -impl ServerHandler for AlwaysTaskServer { - async fn call_tool( - &self, - _request: CallToolRequestParams, - _context: RequestContext, - ) -> Result { - let task = self.tasks.spawn(TaskOptions::default(), |_ctx| { - Box::pin(async { Ok(CallToolResult::success(vec![ContentBlock::text("late")])) }) - }); - Ok(CallToolResponse::Task(CreateTaskResult::new(task))) - } - - fn get_info(&self) -> ServerInfo { - ServerInfo::new( - ServerCapabilities::builder() - .enable_tools() - .enable_tasks() - .build(), - ) - } -} - -#[tokio::test] -async fn dispatch_rejects_task_result_for_non_declaring_client() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let server = tokio::spawn(async move { - let service = AlwaysTaskServer { - tasks: TaskManager::new(), - } - .serve(server_transport) - .await?; - service.waiting().await?; - anyhow::Ok(()) - }); - - // Plain client: no tasks extension declared. The handler tries to return - // a CreateTaskResult anyway; dispatch must reject with -32021. - let client = ().serve(client_transport).await.unwrap(); - let err = client - .call_tool(CallToolRequestParams::new("anything")) - .await - .unwrap_err(); - match err { - rmcp::ServiceError::McpError(e) => { - assert_eq!(e.code, ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY); - } - other => panic!("expected McpError, got {other:?}"), - } - - client.cancel().await.unwrap(); - server.abort(); -} - -#[tokio::test] -async fn tasks_methods_without_capability_return_missing_capability_error() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let server = tokio::spawn(async move { - let service = TaskServer::new().serve(server_transport).await?; - service.waiting().await?; - anyhow::Ok(()) - }); - - // Plain client: no tasks extension declared. tasks/* must be rejected - // with -32021 Missing Required Client Capability (SEP-2663), not -32601. - let client = ().serve(client_transport).await.unwrap(); - let err = client - .peer() - .get_task(GetTaskParams::new("whatever")) - .await - .unwrap_err(); - match err { - rmcp::ServiceError::McpError(e) => { - assert_eq!(e.code, ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY); - let data = e.data.expect("error data should be present"); - assert!( - data["requiredCapabilities"]["extensions"] - .as_object() - .is_some_and(|ext| ext.contains_key("io.modelcontextprotocol/tasks")), - "error data should name the tasks extension: {data}" - ); - } - other => panic!("expected McpError, got {other:?}"), - } - - let err = client - .peer() - .cancel_task(CancelTaskParams::new("whatever")) - .await - .unwrap_err(); - match err { - rmcp::ServiceError::McpError(e) => { - assert_eq!(e.code, ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY); - } - other => panic!("expected McpError, got {other:?}"), - } - - client.cancel().await.unwrap(); - server.abort(); -} - -#[tokio::test] -async fn unknown_task_id_returns_invalid_params() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let server = tokio::spawn(async move { - let service = TaskServer::new().serve(server_transport).await?; - service.waiting().await?; - anyhow::Ok(()) - }); - - let client = tasks_client_info().serve(client_transport).await.unwrap(); - let err = client - .peer() - .get_task(GetTaskParams::new("no-such-task")) - .await - .unwrap_err(); - match err { - rmcp::ServiceError::McpError(e) => { - // SEP-2663: unknown taskId is -32602 Invalid params. - assert_eq!(e.code, ErrorCode::INVALID_PARAMS); - } - other => panic!("expected McpError, got {other:?}"), - } - - client.cancel().await.unwrap(); - server.abort(); -} - -#[tokio::test] -async fn legacy_task_param_is_ignored() { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let server = tokio::spawn(async move { - let service = TaskServer::new().serve(server_transport).await?; - service.waiting().await?; - anyhow::Ok(()) - }); - - // Plain client sending a legacy 2025-11-25 `task: {...}` param: it must - // be silently ignored and the call answered synchronously (SEP-2663). - let client = ().serve(client_transport).await.unwrap(); - let params: CallToolRequestParams = serde_json::from_value(json!({ - "name": "sum", - "arguments": {"a": 2, "b": 3}, - "task": {"ttl": 60000} - })) - .expect("legacy task param must not break deserialization"); - let result = client.call_tool(params).await.unwrap(); - let text = result.content[0].as_text().unwrap(); - assert_eq!(text.text, "5"); - - client.cancel().await.unwrap(); - server.abort(); -} - -#[test] -fn task_status_notification_params_preserve_meta() { - let raw = json!({ - "_meta": { - "traceId": "trace-1" - }, - "taskId": "task-1", - "status": "working", - "createdAt": "2026-06-24T00:00:00Z", - "lastUpdatedAt": "2026-06-24T00:00:01Z", - "ttlMs": null - }); - - let params: TaskStatusNotificationParams = serde_json::from_value(raw).unwrap(); - - assert_eq!(params.task.task.task_id, "task-1"); - assert_eq!(params.status(), TaskStatus::Working); - assert_eq!(params.meta.as_ref().unwrap().0["traceId"], json!("trace-1")); - - let serialized = serde_json::to_value(¶ms).unwrap(); - assert_eq!(serialized["_meta"]["traceId"], json!("trace-1")); - assert_eq!(serialized["taskId"], json!("task-1")); - assert_eq!(serialized["ttlMs"], serde_json::Value::Null); -} diff --git a/vendor/rmcp/tests/test_tool_builder_methods.rs b/vendor/rmcp/tests/test_tool_builder_methods.rs deleted file mode 100644 index cdb1473f5..000000000 --- a/vendor/rmcp/tests/test_tool_builder_methods.rs +++ /dev/null @@ -1,105 +0,0 @@ -#![allow(clippy::exhaustive_structs)] -//cargo test --test test_tool_builder_methods --features "client server macros" -use rmcp::model::{JsonObject, Tool}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize, JsonSchema)] -pub struct InputData { - pub name: String, - pub age: u32, -} - -#[derive(Serialize, Deserialize, JsonSchema)] -pub struct OutputData { - pub greeting: String, - pub is_adult: bool, -} - -#[test] -fn test_with_output_schema() { - let tool = Tool::new("test", "Test tool", JsonObject::new()).with_output_schema::(); - - assert!(tool.output_schema.is_some()); - - let schema = tool.output_schema.as_ref().unwrap(); - assert_eq!(schema.get("type"), Some(&serde_json::json!("object"))); -} - -#[test] -fn test_with_input_schema() { - let tool = Tool::new("test", "Test tool", JsonObject::new()).with_input_schema::(); - - // Verify the schema contains expected fields - let schema_str = serde_json::to_string(&tool.input_schema).unwrap(); - assert!(schema_str.contains("name")); - assert!(schema_str.contains("age")); -} - -#[test] -fn test_chained_builder_methods() { - let tool = Tool::new("test", "Test tool", JsonObject::new()) - .with_input_schema::() - .with_output_schema::() - .annotate(rmcp::model::ToolAnnotations::new().read_only(true)); - - assert!(tool.output_schema.is_some()); - assert!(tool.annotations.is_some()); - assert_eq!( - tool.annotations.as_ref().unwrap().read_only_hint, - Some(true) - ); - - // Verify both schemas are set correctly - let input_schema_str = serde_json::to_string(&tool.input_schema).unwrap(); - assert!(input_schema_str.contains("name")); - assert!(input_schema_str.contains("age")); - - let output_schema = tool.output_schema.as_ref().unwrap(); - assert_eq!( - output_schema.get("type"), - Some(&serde_json::json!("object")) - ); -} - -#[test] -fn test_with_output_schema_primitive() { - let tool = Tool::new("test", "Test tool", JsonObject::new()).with_output_schema::(); - - assert!(tool.output_schema.is_some()); - - let schema = tool.output_schema.as_ref().unwrap(); - assert_eq!(schema.get("type"), Some(&serde_json::json!("integer"))); - // title should be stripped from output schema - assert!(schema.get("title").is_none()); -} - -#[test] -fn test_with_output_schema_array() { - let tool = - Tool::new("test", "Test tool", JsonObject::new()).with_output_schema::>(); - - assert!(tool.output_schema.is_some()); - - let schema_str = serde_json::to_string(tool.output_schema.as_ref().unwrap()).unwrap(); - assert!(schema_str.contains("\"type\":\"array\"")); - assert!(schema_str.contains("items")); - // title should be stripped from output schema - assert!(!schema_str.contains("title")); -} - -#[test] -fn test_with_output_schema_option() { - let tool = - Tool::new("test", "Test tool", JsonObject::new()).with_output_schema::>(); - - assert!(tool.output_schema.is_some()); - - let schema_str = serde_json::to_string(tool.output_schema.as_ref().unwrap()).unwrap(); - // Option generates a composition schema (anyOf/oneOf/type array with null) - assert!( - schema_str.contains("anyOf") || schema_str.contains("oneOf") || schema_str.contains("null"), - "Expected composition schema for Option, got: {schema_str}" - ); - assert!(!schema_str.contains("title")); -} diff --git a/vendor/rmcp/tests/test_tool_disable_notification.rs b/vendor/rmcp/tests/test_tool_disable_notification.rs deleted file mode 100644 index cd8780591..000000000 --- a/vendor/rmcp/tests/test_tool_disable_notification.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! Integration tests for tool list change notifications. -#![cfg(all(feature = "client", not(feature = "local")))] - -use std::sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, -}; - -use rmcp::{ - ClientHandler, RoleClient, RoleServer, ServerHandler, ServiceExt, - handler::server::{router::tool::ToolRoute, tool::ToolCallContext}, - model::{CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool}, - service::{MaybeSendFuture, NotificationContext}, -}; -use tokio::sync::{Notify, RwLock}; - -#[derive(Clone)] -struct TestToolServer { - router: Arc>>, - trigger_disable: Arc, - trigger_enable: Arc, -} - -impl TestToolServer { - fn new() -> Self { - let mut tool_router = rmcp::handler::server::router::tool::ToolRouter::::new(); - tool_router.add_route(ToolRoute::new_dyn( - Tool::new("tool_a", "Tool A", Arc::new(Default::default())), - |_ctx| Box::pin(async { Ok(CallToolResult::default().into()) }), - )); - tool_router.add_route(ToolRoute::new_dyn( - Tool::new("tool_b", "Tool B", Arc::new(Default::default())), - |_ctx| Box::pin(async { Ok(CallToolResult::default().into()) }), - )); - Self { - router: Arc::new(RwLock::new(tool_router)), - trigger_disable: Arc::new(Notify::new()), - trigger_enable: Arc::new(Notify::new()), - } - } -} - -impl ServerHandler for TestToolServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - } - - async fn call_tool( - &self, - request: rmcp::model::CallToolRequestParams, - context: rmcp::service::RequestContext, - ) -> Result { - let router = self.router.read().await; - let tcc = ToolCallContext::new(self, request, context); - router.call(tcc).await - } - - async fn list_tools( - &self, - _request: Option, - _context: rmcp::service::RequestContext, - ) -> Result { - let router = self.router.read().await; - Ok(rmcp::model::ListToolsResult { - tools: router.list_all(), - ..Default::default() - }) - } - - fn on_initialized( - &self, - context: NotificationContext, - ) -> impl std::future::Future + MaybeSendFuture + '_ { - let router = self.router.clone(); - let trigger_disable = self.trigger_disable.clone(); - let trigger_enable = self.trigger_enable.clone(); - let peer = context.peer.clone(); - - async move { - router.write().await.bind_peer_notifier(&peer); - - let router = router.clone(); - tokio::spawn(async move { - trigger_disable.notified().await; - { - let mut r = router.write().await; - r.disable_route("tool_a"); - } - - trigger_enable.notified().await; - { - let mut r = router.write().await; - r.enable_route("tool_a"); - } - }); - } - } -} - -#[derive(Clone)] -struct TestToolClient { - notification_count: Arc, - notify: Arc, -} - -impl TestToolClient { - fn new() -> Self { - Self { - notification_count: Arc::new(AtomicUsize::new(0)), - notify: Arc::new(Notify::new()), - } - } -} - -impl ClientHandler for TestToolClient { - fn on_tool_list_changed( - &self, - _context: NotificationContext, - ) -> impl std::future::Future + MaybeSendFuture + '_ { - self.notification_count.fetch_add(1, Ordering::SeqCst); - self.notify.notify_one(); - std::future::ready(()) - } -} - -#[tokio::test] -async fn test_disable_enable_sends_tool_list_changed() { - let server = TestToolServer::new(); - let trigger_disable = server.trigger_disable.clone(); - let trigger_enable = server.trigger_enable.clone(); - - let client = TestToolClient::new(); - let notification_count = client.notification_count.clone(); - let client_notify = client.notify.clone(); - - let (server_transport, client_transport) = tokio::io::duplex(4096); - - let server_handle = tokio::spawn(async move { server.serve(server_transport).await }); - let client_service = client.serve(client_transport).await.unwrap(); - - let tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(tools.tools.len(), 2); - - trigger_disable.notify_one(); - tokio::time::timeout(std::time::Duration::from_secs(5), client_notify.notified()) - .await - .expect("timed out waiting for tool_list_changed"); - assert_eq!(notification_count.load(Ordering::SeqCst), 1); - - let tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(tools.tools.len(), 1); - assert_eq!(tools.tools[0].name, "tool_b"); - - trigger_enable.notify_one(); - tokio::time::timeout(std::time::Duration::from_secs(5), client_notify.notified()) - .await - .expect("timed out waiting for tool_list_changed"); - assert_eq!(notification_count.load(Ordering::SeqCst), 2); - - let tools = client_service.peer().list_tools(None).await.unwrap(); - assert_eq!(tools.tools.len(), 2); - - client_service.cancel().await.unwrap(); - server_handle.abort(); -} diff --git a/vendor/rmcp/tests/test_tool_handler.rs b/vendor/rmcp/tests/test_tool_handler.rs deleted file mode 100644 index 8b1378917..000000000 --- a/vendor/rmcp/tests/test_tool_handler.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vendor/rmcp/tests/test_tool_macro_annotations.rs b/vendor/rmcp/tests/test_tool_macro_annotations.rs deleted file mode 100644 index 34b17625f..000000000 --- a/vendor/rmcp/tests/test_tool_macro_annotations.rs +++ /dev/null @@ -1,46 +0,0 @@ -#[cfg(test)] -mod tests { - use rmcp::{ServerHandler, handler::server::router::tool::ToolRouter, tool, tool_handler}; - - #[derive(Debug, Clone, Default)] - pub struct AnnotatedServer { - tool_router: ToolRouter, - } - - impl AnnotatedServer { - // Tool with inline comments for documentation - /// Direct annotation test tool - /// This is used to test tool annotations - #[tool( - name = "direct-annotated-tool", - annotations(title = "Annotated Tool", read_only_hint = true) - )] - pub async fn direct_annotated_tool(&self, input: String) -> String { - format!("Direct: {}", input) - } - } - #[tool_handler(router = self.tool_router)] - impl ServerHandler for AnnotatedServer {} - - #[test] - fn test_direct_tool_attributes() { - // Get the tool definition - let tool = AnnotatedServer::direct_annotated_tool_tool_attr(); - - // Verify basic properties - assert_eq!(tool.name, "direct-annotated-tool"); - - // Verify description is extracted from doc comments - assert!(tool.description.is_some()); - assert!( - tool.description - .as_ref() - .unwrap() - .contains("Direct annotation test tool") - ); - - let annotations = tool.annotations.unwrap(); - assert_eq!(annotations.title.as_ref().unwrap(), "Annotated Tool"); - assert_eq!(annotations.read_only_hint, Some(true)); - } -} diff --git a/vendor/rmcp/tests/test_tool_macros.rs b/vendor/rmcp/tests/test_tool_macros.rs deleted file mode 100644 index 9b9530aa8..000000000 --- a/vendor/rmcp/tests/test_tool_macros.rs +++ /dev/null @@ -1,575 +0,0 @@ -#![cfg(not(feature = "local"))] -//! Test tool macros, including documentation for generated fns. - -//cargo test --test test_tool_macros --features "client server" -// Enforce that all generated code has sufficient docs to pass missing_docs lint -#![deny(missing_docs)] -#![allow(dead_code)] -use std::sync::Arc; - -use rmcp::{ - ClientHandler, ServerHandler, ServiceExt, - handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{CallToolRequestParams, ClientInfo, ServerCapabilities, ServerInfo}, - tool, tool_handler, tool_router, -}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -/// Parameters for weather tool. -#[derive(Serialize, Deserialize, JsonSchema)] -struct GetWeatherRequest { - /// City of interest. - city: String, - /// Date of interest. - date: String, -} - -#[tool_handler(router = self.tool_router)] -impl ServerHandler for Server {} - -/// Trivial stateless server. -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct Server { - tool_router: ToolRouter, -} - -impl Server { - /// Create weather server. - pub fn new() -> Self { - Self { - tool_router: Self::tool_router(), - } - } -} - -impl Default for Server { - fn default() -> Self { - Self::new() - } -} - -#[tool_router(router = tool_router)] -impl Server { - /// This tool is used to get the weather of a city. - #[tool(name = "get-weather", description = "Get the weather of a city.")] - pub async fn get_weather(&self, city: Parameters) -> String { - drop(city); - "rain".to_string() - } - - #[tool] - async fn empty_param(&self) {} -} - -/// Generic service trait. -pub trait DataService: Send + Sync + 'static { - /// Get data from service. - fn get_data(&self) -> String; -} - -// mock service for test -#[derive(Clone)] -struct MockDataService; -impl DataService for MockDataService { - fn get_data(&self) -> String { - "mock data".to_string() - } -} - -/// Generic server. -#[derive(Debug, Clone)] -pub struct GenericServer { - data_service: Arc, - tool_router: ToolRouter, -} - -#[tool_router] -impl GenericServer { - /// Create data server instance. - pub fn new(data_service: DS) -> Self { - Self { - data_service: Arc::new(data_service), - tool_router: Self::tool_router(), - } - } - - #[tool(description = "Get data from the service")] - async fn get_data(&self) -> String { - self.data_service.get_data() - } -} - -#[tool_handler] -impl ServerHandler for GenericServer {} - -#[tokio::test] -async fn test_tool_macros() { - let server = Server::new(); - let _attr = Server::get_weather_tool_attr(); - let _get_weather_tool_attr_fn = Server::get_weather_tool_attr; - let _get_weather_fn = Server::get_weather; - server - .get_weather(Parameters(GetWeatherRequest { - city: "Harbin".into(), - date: "Yesterday".into(), - })) - .await; -} - -#[tokio::test] -async fn test_tool_macros_with_empty_param() { - let _attr = Server::empty_param_tool_attr(); - println!("{_attr:?}"); - assert_eq!( - _attr.input_schema.get("type"), - Some(&serde_json::Value::String("object".to_string())) - ); - assert_eq!( - _attr.input_schema.get("properties"), - Some(&serde_json::Value::Object(serde_json::Map::new())) - ); -} - -#[tokio::test] -async fn test_tool_macros_with_generics() { - let mock_service = MockDataService; - let server = GenericServer::new(mock_service); - let _attr = GenericServer::::get_data_tool_attr(); - let _get_data_call_fn = GenericServer::::get_data; - let _get_data_fn = GenericServer::::get_data; - assert_eq!(server.get_data().await, "mock data"); -} - -#[tokio::test] -async fn test_tool_macros_with_optional_param() { - let _attr = Server::get_weather_tool_attr(); - // println!("{_attr:?}"); - let attr_type = _attr - .input_schema - .get("properties") - .unwrap() - .get("city") - .unwrap() - .get("type") - .unwrap(); - println!("_attr.input_schema: {:?}", attr_type); - assert_eq!(attr_type.as_str().unwrap(), "string"); -} - -impl GetWeatherRequest {} - -/// Struct defined for testing optional field schema generation. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -struct OptionalFieldTestSchema { - /// Field description. - #[schemars(description = "An optional description field")] - description: Option, -} - -/// Struct defined for testing optional i64 field schema generation and null handling. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -struct OptionalI64TestSchema { - /// Optional count field. - #[schemars(description = "An optional i64 field")] - count: Option, - - /// Added to ensure non-empty object schema. - mandatory_field: String, -} - -/// Dummy struct to host the test tool method. -#[derive(Debug, Clone)] -pub struct OptionalSchemaTester { - tool_router: ToolRouter, -} - -impl Default for OptionalSchemaTester { - fn default() -> Self { - Self::new() - } -} - -impl OptionalSchemaTester { - /// Create instance of optional schema tester service. - pub fn new() -> Self { - Self { - tool_router: Self::tool_router(), - } - } -} - -#[tool_router] -impl OptionalSchemaTester { - // Dummy tool function using the test schema as an aggregated parameter - #[tool(description = "A tool to test optional schema generation")] - async fn test_optional(&self, _req: Parameters) { - // Implementation doesn't matter for schema testing - // Return type changed to () to satisfy IntoCallToolResult - } - - // Tool function to test optional i64 handling - #[tool(description = "A tool to test optional i64 schema generation")] - async fn test_optional_i64( - &self, - Parameters(req): Parameters, - ) -> String { - match req.count { - Some(c) => format!("Received count: {}", c), - None => "Received null count".to_string(), - } - } -} -#[tool_handler] -// Implement ServerHandler to route tool calls for OptionalSchemaTester -impl ServerHandler for OptionalSchemaTester {} - -#[test] -fn test_optional_field_schema_generation_via_macro() { - // tests https://github.com/modelcontextprotocol/rust-sdk/issues/135 - - // Get the attributes generated by the #[tool] macro helper - let tool_attr = OptionalSchemaTester::test_optional_tool_attr(); - - // Print the actual generated schema for debugging - println!( - "Actual input schema generated by macro: {:#?}", - tool_attr.input_schema - ); - - // Verify the schema generated for the aggregated OptionalFieldTestSchema - // by the macro infrastructure using JSON Schema 2020-12 settings. - let input_schema_map = &*tool_attr.input_schema; // Dereference Arc - - // Check the schema for the 'description' property within the input schema - let properties = input_schema_map - .get("properties") - .expect("Schema should have properties") - .as_object() - .unwrap(); - let description_schema = properties - .get("description") - .expect("Properties should include description") - .as_object() - .unwrap(); - - // Assert nullable Option is represented in JSON Schema 2020-12 form. - let type_value = description_schema - .get("type") - .expect("Schema for Option should include a type field"); - let type_array = type_value - .as_array() - .expect("Schema for Option should use a type array [T, null]"); - assert_eq!( - type_array, - &vec![serde_json::json!("string"), serde_json::json!("null")], - "Schema for Option should be type: [\"string\", \"null\"]" - ); - assert!( - description_schema.get("nullable").is_none(), - "Schema for Option should not use OpenAPI nullable in JSON Schema 2020-12" - ); - // We still check the description is correct - assert_eq!( - description_schema - .get("description") - .map(|v| v.as_str().unwrap()), - Some("An optional description field") - ); - - // Ensure no OpenAPI-only nullable extension was emitted. - assert!(description_schema.get("nullable").is_none()); -} - -// Define a dummy client handler -#[derive(Debug, Clone, Default)] -struct DummyClientHandler {} - -impl ClientHandler for DummyClientHandler { - fn get_info(&self) -> ClientInfo { - ClientInfo::default() - } -} - -#[tokio::test] -async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - // Server setup - let server = OptionalSchemaTester::new(); - let server_handle = tokio::spawn(async move { - server.serve(server_transport).await?.waiting().await?; - anyhow::Ok(()) - }); - - // Create a simple client handler that just forwards tool calls - let client_handler = DummyClientHandler::default(); - let client = client_handler.serve(client_transport).await?; - - // Test null case - let result = client - .call_tool( - CallToolRequestParams::new("test_optional_i64").with_arguments( - serde_json::json!({ - "count": null, - "mandatory_field": "test_null" - }) - .as_object() - .unwrap() - .clone(), - ), - ) - .await?; - - let result_text = result - .content - .first() - .and_then(|content| content.as_text()) - .map(|text| text.text.as_str()) - .expect("Expected text content"); - - assert_eq!( - result_text, "Received null count", - "Null case should return expected message" - ); - - // Test Some case - let some_result = client - .call_tool( - CallToolRequestParams::new("test_optional_i64").with_arguments( - serde_json::json!({ - "count": 42, - "mandatory_field": "test_some" - }) - .as_object() - .unwrap() - .clone(), - ), - ) - .await?; - - let some_result_text = some_result - .content - .first() - .and_then(|content| content.as_text()) - .map(|text| text.text.as_str()) - .expect("Expected text content"); - - assert_eq!( - some_result_text, "Received count: 42", - "Some case should return expected message" - ); - - client.cancel().await?; - server_handle.await??; - Ok(()) -} - -// --- Tests for field-free minimal server pattern (issue #711) --- - -/// Minimal server: no tool_router field, no new(), no get_info(). -#[derive(Debug, Clone)] -struct MinimalServer; - -#[tool_router] -impl MinimalServer { - #[tool(description = "Say hello")] - fn hello(&self) -> String { - "hello".to_string() - } -} - -#[tool_handler] -impl ServerHandler for MinimalServer {} - -#[test] -fn test_minimal_server_get_info_auto_generated() { - let server = MinimalServer; - let info = server.get_info(); - - assert!( - info.capabilities.tools.is_some(), - "tools capability should be enabled" - ); - assert!( - info.capabilities.prompts.is_none(), - "prompts should not be auto-enabled" - ); - assert!( - !info.capabilities.supports_tasks(), - "tasks extension should not be auto-enabled" - ); - assert!( - !info.server_info.name.is_empty(), - "server name should not be empty" - ); - assert!( - !info.server_info.version.is_empty(), - "server version should not be empty" - ); - assert!( - info.instructions.is_none(), - "instructions should be None by default" - ); -} - -#[tokio::test] -async fn test_minimal_server_tool_call() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - let server_handle = tokio::spawn(async move { - MinimalServer - .serve(server_transport) - .await? - .waiting() - .await?; - anyhow::Ok(()) - }); - - let client = DummyClientHandler::default() - .serve(client_transport) - .await?; - - let result = client - .call_tool(CallToolRequestParams::new("hello")) - .await?; - - let text = result - .content - .first() - .and_then(|c| c.as_text()) - .map(|t| t.text.as_str()) - .expect("Expected text content"); - - assert_eq!(text, "hello"); - - client.cancel().await?; - server_handle.await??; - Ok(()) -} - -/// Same minimal pattern as [`MinimalServer`], but `#[tool_handler]` is omitted using -/// `#[tool_router(server_handler)]` (emits `#[tool_handler]` for a second macro pass). -#[derive(Debug, Clone)] -struct ElidedToolHandlerServer; - -#[tool_router(server_handler)] -impl ElidedToolHandlerServer { - #[tool(description = "Say hi")] - fn hi(&self) -> String { - "hi".to_string() - } -} - -#[test] -fn test_tool_router_server_handler_flag_matches_minimal_server_get_info() { - let server = ElidedToolHandlerServer; - let info = server.get_info(); - - assert!(info.capabilities.tools.is_some()); - assert!( - info.capabilities.prompts.is_none(), - "prompts should not be auto-enabled" - ); -} - -#[tokio::test] -async fn test_tool_router_server_handler_flag_end_to_end_tool_call() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - let server_handle = tokio::spawn(async move { - ElidedToolHandlerServer - .serve(server_transport) - .await? - .waiting() - .await?; - anyhow::Ok(()) - }); - - let client = DummyClientHandler::default() - .serve(client_transport) - .await?; - - let result = client.call_tool(CallToolRequestParams::new("hi")).await?; - - let text = result - .content - .first() - .and_then(|c| c.as_text()) - .map(|t| t.text.as_str()) - .expect("Expected text content"); - - assert_eq!(text, "hi"); - - client.cancel().await?; - server_handle.await??; - Ok(()) -} - -/// Server with custom name/version/instructions via tool_handler attributes. -#[derive(Debug, Clone)] -struct CustomInfoServer; - -#[tool_router] -impl CustomInfoServer { - #[tool(description = "Ping")] - fn ping(&self) -> String { - "pong".to_string() - } -} - -#[tool_handler( - name = "my-custom-server", - version = "2.0.0", - instructions = "A custom server" -)] -impl ServerHandler for CustomInfoServer {} - -#[test] -fn test_custom_info_server() { - let server = CustomInfoServer; - let info = server.get_info(); - - assert_eq!(info.server_info.name, "my-custom-server"); - assert_eq!(info.server_info.version, "2.0.0"); - assert_eq!(info.instructions.as_deref(), Some("A custom server")); - assert!(info.capabilities.tools.is_some()); -} - -/// Server that provides its own get_info() — macro should not override it. -#[derive(Debug, Clone)] -struct ManualInfoServer; - -#[tool_router] -impl ManualInfoServer { - #[tool(description = "Noop")] - fn noop(&self) {} -} - -#[tool_handler] -impl ServerHandler for ManualInfoServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( - ServerCapabilities::builder() - .enable_tools() - .enable_resources() - .build(), - ) - .with_server_info(rmcp::model::Implementation::new("manual", "9.9.9")) - } -} - -#[test] -fn test_manual_get_info_not_overridden() { - let server = ManualInfoServer; - let info = server.get_info(); - - assert_eq!(info.server_info.name, "manual"); - assert_eq!(info.server_info.version, "9.9.9"); - assert!(info.capabilities.tools.is_some()); - assert!( - info.capabilities.resources.is_some(), - "manual resources should be preserved" - ); -} diff --git a/vendor/rmcp/tests/test_tool_result_meta.rs b/vendor/rmcp/tests/test_tool_result_meta.rs deleted file mode 100644 index 37585ac76..000000000 --- a/vendor/rmcp/tests/test_tool_result_meta.rs +++ /dev/null @@ -1,41 +0,0 @@ -use rmcp::model::{CallToolResult, ContentBlock, MetaObject}; -use serde_json::{Value, json}; - -#[test] -fn serialize_tool_result_with_meta() { - let content = vec![ContentBlock::text("ok")]; - let mut meta = MetaObject::new(); - meta.insert("foo".to_string(), json!("bar")); - let result = CallToolResult::success(content).with_meta(Some(meta)); - let v = serde_json::to_value(&result).unwrap(); - let expected = json!({ - "resultType": "complete", - "content": [{"type":"text","text":"ok"}], - "isError": false, - "_meta": {"foo":"bar"} - }); - assert_eq!(v, expected); -} - -#[test] -fn deserialize_tool_result_with_meta() { - let raw: Value = json!({ - "content": [{"type":"text","text":"hello"}], - "isError": true, - "_meta": {"a": 1, "b": "two"} - }); - let result: CallToolResult = serde_json::from_value(raw).unwrap(); - assert_eq!(result.is_error, Some(true)); - assert_eq!(result.content.len(), 1); - let meta = result.meta.expect("meta should exist"); - assert_eq!(meta.get("a").unwrap(), &json!(1)); - assert_eq!(meta.get("b").unwrap(), &json!("two")); -} - -#[test] -fn serialize_tool_result_without_meta_omits_field() { - let result = CallToolResult::success(vec![ContentBlock::text("no meta")]); - let v = serde_json::to_value(&result).unwrap(); - // Ensure _meta is omitted - assert!(v.get("_meta").is_none()); -} diff --git a/vendor/rmcp/tests/test_tool_routers.rs b/vendor/rmcp/tests/test_tool_routers.rs deleted file mode 100644 index d2bbe8687..000000000 --- a/vendor/rmcp/tests/test_tool_routers.rs +++ /dev/null @@ -1,372 +0,0 @@ -#![cfg(not(feature = "local"))] -use std::{ - collections::HashMap, - sync::atomic::{AtomicUsize, Ordering}, -}; - -use futures::future::BoxFuture; -use rmcp::{ - ServerHandler, - handler::server::{router::tool::ToolRouter, tool::CallToolHandler, wrapper::Parameters}, -}; - -#[derive(Debug, Default)] -struct TestHandler { - _marker: std::marker::PhantomData, -} - -impl ServerHandler for TestHandler {} -#[derive(Debug, schemars::JsonSchema, serde::Deserialize, serde::Serialize)] -struct Request { - fields: HashMap, -} - -#[derive(Debug, schemars::JsonSchema, serde::Deserialize, serde::Serialize)] -struct Sum { - a: i32, - b: i32, -} - -#[rmcp::tool_router(router = test_router_1)] -impl TestHandler { - #[rmcp::tool] - async fn async_method(&self, Parameters(Request { fields }): Parameters) { - drop(fields) - } -} - -#[rmcp::tool_router(router = test_router_2)] -impl TestHandler { - #[rmcp::tool] - fn sync_method(&self, Parameters(Request { fields }): Parameters) { - drop(fields) - } -} - -#[rmcp::tool] -async fn async_function(Parameters(Request { fields }): Parameters) { - drop(fields) -} - -#[rmcp::tool] -fn async_function2(_callee: &TestHandler) -> BoxFuture<'_, ()> { - Box::pin(async move {}) -} - -#[test] -fn test_tool_router() { - let test_tool_router: ToolRouter> = ToolRouter::>::new() - .with_route((async_function_tool_attr(), async_function)) - .with_route((async_function2_tool_attr(), async_function2)) - + TestHandler::<()>::test_router_1() - + TestHandler::<()>::test_router_2(); - let tools = test_tool_router.list_all(); - assert_eq!(tools.len(), 4); - assert_handler(TestHandler::<()>::async_method); -} - -fn assert_handler(_handler: H) -where - H: CallToolHandler, -{ -} - -#[test] -fn test_tool_router_list_all_is_sorted() { - let router: ToolRouter> = ToolRouter::>::new() - .with_route((async_function_tool_attr(), async_function)) - .with_route((async_function2_tool_attr(), async_function2)) - + TestHandler::<()>::test_router_1() - + TestHandler::<()>::test_router_2(); - let tools = router.list_all(); - let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); - let mut sorted = names.clone(); - sorted.sort(); - assert_eq!( - names, sorted, - "list_all() should return tools sorted alphabetically by name" - ); -} - -fn build_router() -> ToolRouter> { - ToolRouter::>::new() - .with_route((async_function_tool_attr(), async_function)) - .with_route((async_function2_tool_attr(), async_function2)) - + TestHandler::<()>::test_router_1() - + TestHandler::<()>::test_router_2() -} - -#[test] -fn test_disable_route() { - let mut router = build_router(); - assert_eq!(router.list_all().len(), 4); - assert!(router.has_route("async_function")); - assert!(router.get("async_function").is_some()); - - assert!(router.disable_route("async_function")); - - assert_eq!(router.list_all().len(), 3); - assert!(!router.has_route("async_function")); - assert!(router.get("async_function").is_none()); - assert!(router.is_disabled("async_function")); - - // other tools unaffected - assert!(router.has_route("async_function2")); - assert!(router.get("async_function2").is_some()); - assert!(!router.is_disabled("async_function2")); -} - -#[test] -fn test_enable_route() { - let mut router = build_router(); - assert!(router.disable_route("async_function")); - assert!(!router.has_route("async_function")); - - assert!(router.enable_route("async_function")); - assert!(router.has_route("async_function")); - assert!(router.get("async_function").is_some()); - assert!(!router.is_disabled("async_function")); - assert_eq!(router.list_all().len(), 4); -} - -#[test] -fn test_with_disabled_builder() { - let router = build_router() - .with_disabled("async_function") - .with_disabled("sync_method"); - - assert_eq!(router.list_all().len(), 2); - assert!(!router.has_route("async_function")); - assert!(!router.has_route("sync_method")); - assert!(router.has_route("async_function2")); - assert!(router.has_route("async_method")); -} - -#[test] -fn test_disabled_tools_survive_merge() { - let mut router_a = ToolRouter::>::new() - .with_route((async_function_tool_attr(), async_function)); - assert!(router_a.disable_route("async_function")); - - let router_b = ToolRouter::>::new() - .with_route((async_function2_tool_attr(), async_function2)); - - router_a.merge(router_b); - - assert_eq!(router_a.list_all().len(), 1); - assert!(router_a.is_disabled("async_function")); - assert!(router_a.has_route("async_function2")); -} - -#[test] -fn test_disable_nonexistent_tool() { - let mut router = build_router(); - // should not panic; returns true because the name is newly added to disabled set - assert!(router.disable_route("does_not_exist")); - assert_eq!(router.list_all().len(), 4); - // is_disabled returns false for tools not in the map - assert!(!router.is_disabled("does_not_exist")); -} - -#[test] -fn test_remove_route_preserves_disabled_state() { - let mut router = build_router(); - assert!(router.disable_route("async_function")); - assert!(router.is_disabled("async_function")); - - router.remove_route("async_function"); - assert!(!router.has_route("async_function")); - // Disabled marker is preserved — is_disabled returns false (no route in map) - // but re-adding will inherit the disabled state (tested separately) - assert!(!router.is_disabled("async_function")); -} - -#[test] -fn test_remove_route_then_readd_stays_disabled() { - let mut router = build_router(); - assert!(router.disable_route("async_function")); - - router.remove_route("async_function"); - assert!(!router.has_route("async_function")); - - // Re-add the route — it should inherit the disabled state - let other = ToolRouter::>::new() - .with_route((async_function_tool_attr(), async_function)); - router.merge(other); - - assert!(!router.has_route("async_function")); - assert!(router.is_disabled("async_function")); - assert!(router.get("async_function").is_none()); -} - -#[test] -fn test_into_iter_skips_disabled() { - let router = build_router().with_disabled("async_function"); - let names: Vec<_> = router - .into_iter() - .map(|r| r.attr.name.to_string()) - .collect(); - assert_eq!(names.len(), 3); - assert!(!names.contains(&"async_function".to_string())); -} - -#[test] -fn test_pre_disable_before_add_route() { - // Disabling a name before adding a route with that name should - // result in the route being disabled once added. - let router = ToolRouter::>::new() - .with_disabled("async_function") - .with_route((async_function_tool_attr(), async_function)); - - assert_eq!(router.list_all().len(), 0); - assert!(router.is_disabled("async_function")); - assert!(!router.has_route("async_function")); -} - -#[test] -fn test_disabled_tool_invisible_across_all_queries() { - let router = build_router().with_disabled("async_function"); - - // Not listed - let names: Vec<_> = router.list_all().iter().map(|t| t.name.clone()).collect(); - assert!(!names.contains(&"async_function".into())); - // Not retrievable - assert!(router.get("async_function").is_none()); - // Not routable - assert!(!router.has_route("async_function")); - // But known as disabled - assert!(router.is_disabled("async_function")); -} - -#[test] -fn test_disable_route_then_add_route_blocks_tool() { - // Full pre-disable lifecycle via runtime mutation (not builder) - let mut router = ToolRouter::>::new(); - router.disable_route("async_function"); - - // Add route after disabling — tool should be blocked - let other = ToolRouter::>::new() - .with_route((async_function_tool_attr(), async_function)); - router.merge(other); - - assert!(router.is_disabled("async_function")); - assert!(!router.has_route("async_function")); - assert!(router.get("async_function").is_none()); - assert_eq!(router.list_all().len(), 0); -} - -#[test] -fn test_disable_enable_return_false_cases() { - let mut router = build_router(); - - // Repeated disable returns false - assert!(router.disable_route("async_function")); - assert!(!router.disable_route("async_function")); - - // Enable returns true, then false on repeat - assert!(router.enable_route("async_function")); - assert!(!router.enable_route("async_function")); - - // Enable on name never disabled returns false - assert!(!router.enable_route("async_function2")); - - // Enable on unknown name returns false - assert!(!router.enable_route("unknown")); -} - -// ── Notifier tests ────────────────────────────────────────────────────── - -fn counter_notifier() -> ( - impl Fn() + Send + Sync + 'static, - std::sync::Arc, -) { - let counter = std::sync::Arc::new(AtomicUsize::new(0)); - let c = counter.clone(); - let notifier = move || { - c.fetch_add(1, Ordering::SeqCst); - }; - (notifier, counter) -} - -#[test] -fn test_notifier_fires_on_disable_and_enable() { - let (notifier, counter) = counter_notifier(); - let mut router = build_router(); - router.set_notifier(notifier); - - assert!(router.disable_route("async_function")); - assert_eq!(counter.load(Ordering::SeqCst), 1); - - assert!(!router.disable_route("async_function")); - assert_eq!(counter.load(Ordering::SeqCst), 1); - - assert!(router.enable_route("async_function")); - assert_eq!(counter.load(Ordering::SeqCst), 2); - - assert!(!router.enable_route("async_function")); - assert_eq!(counter.load(Ordering::SeqCst), 2); -} - -#[test] -fn test_notifier_skips_nonexistent_tools() { - let (notifier, counter) = counter_notifier(); - let mut router = build_router(); - router.set_notifier(notifier); - - assert!(router.disable_route("does_not_exist")); - assert_eq!(counter.load(Ordering::SeqCst), 0); - - assert!(router.enable_route("does_not_exist")); - assert_eq!(counter.load(Ordering::SeqCst), 0); - - assert!(router.disable_route("future_tool")); - assert_eq!(counter.load(Ordering::SeqCst), 0); - assert!(router.enable_route("future_tool")); - assert_eq!(counter.load(Ordering::SeqCst), 0); -} - -#[test] -fn test_no_notifier_no_panic() { - let mut router = build_router(); - assert!(router.disable_route("async_function")); - assert!(router.enable_route("async_function")); - assert!(router.disable_route("async_function")); - assert!(!router.disable_route("async_function")); -} - -#[test] -fn test_clone_shares_notifier() { - let (notifier, counter) = counter_notifier(); - let mut router = build_router(); - router.set_notifier(notifier); - let mut cloned = router.clone(); - - assert!(cloned.disable_route("async_function")); - assert_eq!(counter.load(Ordering::SeqCst), 1); - - assert!(router.disable_route("async_function")); - assert_eq!(counter.load(Ordering::SeqCst), 2); - - cloned.clear_notifier(); - assert!(cloned.enable_route("async_function")); - assert_eq!(counter.load(Ordering::SeqCst), 2); - - assert!(router.enable_route("async_function")); - assert_eq!(counter.load(Ordering::SeqCst), 3); -} - -#[test] -fn test_pre_init_disable_silent_but_correct() { - let mut router = build_router(); - - assert!(router.disable_route("async_function")); - assert_eq!(router.list_all().len(), 3); - assert!(!router.has_route("async_function")); - - let (notifier, counter) = counter_notifier(); - router.set_notifier(notifier); - assert_eq!(counter.load(Ordering::SeqCst), 0); - - assert!(router.enable_route("async_function")); - assert_eq!(counter.load(Ordering::SeqCst), 1); -} diff --git a/vendor/rmcp/tests/test_trace_context.rs b/vendor/rmcp/tests/test_trace_context.rs deleted file mode 100644 index 15354a497..000000000 --- a/vendor/rmcp/tests/test_trace_context.rs +++ /dev/null @@ -1,85 +0,0 @@ -#![cfg(not(feature = "local"))] -//! SEP-414: the reserved trace-context `_meta` keys survive a client→server round trip unchanged. -use std::sync::Arc; - -use rmcp::{ - RoleServer, ServerHandler, ServiceExt, - model::{ClientRequest, CustomRequest, CustomResult, RequestMetaObject}, - service::{PeerRequestOptions, RequestContext}, -}; -use serde_json::json; -use tokio::sync::{Mutex, Notify}; - -const TRACEPARENT: &str = "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"; -const TRACESTATE: &str = "vendor1=value1,vendor2=value2"; -const BAGGAGE: &str = "userId=alice,region=us-east-1"; - -/// Records the `_meta` it receives on the incoming request so the test can assert passthrough. -struct TraceCapturingServer { - receive_signal: Arc, - seen: Arc>>, -} - -impl ServerHandler for TraceCapturingServer { - async fn on_custom_request( - &self, - _request: CustomRequest, - context: RequestContext, - ) -> Result { - *self.seen.lock().await = Some(context.meta); - self.receive_signal.notify_one(); - Ok(CustomResult::new(json!({ "status": "ok" }))) - } -} - -#[tokio::test] -async fn trace_context_meta_survives_round_trip() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - let receive_signal = Arc::new(Notify::new()); - let seen = Arc::new(Mutex::new(None)); - - { - let receive_signal = receive_signal.clone(); - let seen = seen.clone(); - tokio::spawn(async move { - let server = TraceCapturingServer { - receive_signal, - seen, - } - .serve(server_transport) - .await?; - server.waiting().await?; - anyhow::Ok(()) - }); - } - - let client = ().serve(client_transport).await?; - - // Client attaches trace context to the outgoing request's `_meta`. - let mut meta = RequestMetaObject::new(); - meta.set_traceparent(TRACEPARENT); - meta.set_tracestate(TRACESTATE); - meta.set_baggage(BAGGAGE); - - let mut options = PeerRequestOptions::no_options(); - options.meta = Some(meta); - client - .send_cancellable_request( - ClientRequest::CustomRequest(CustomRequest::new("requests/trace-test", None)), - options, - ) - .await? - .await_response() - .await?; - - tokio::time::timeout(std::time::Duration::from_secs(5), receive_signal.notified()).await?; - - // Server saw the reserved keys unchanged (alongside the injected progressToken). - let seen = seen.lock().await.take().expect("server observed meta"); - assert_eq!(seen.get_traceparent(), Some(TRACEPARENT)); - assert_eq!(seen.get_tracestate(), Some(TRACESTATE)); - assert_eq!(seen.get_baggage(), Some(BAGGAGE)); - - client.cancel().await?; - Ok(()) -} diff --git a/vendor/rmcp/tests/test_unix_socket_transport.rs b/vendor/rmcp/tests/test_unix_socket_transport.rs deleted file mode 100644 index 4c4ad52f1..000000000 --- a/vendor/rmcp/tests/test_unix_socket_transport.rs +++ /dev/null @@ -1,298 +0,0 @@ -#![cfg(all( - unix, - feature = "transport-streamable-http-client-unix-socket", - not(feature = "local") -))] - -use std::{collections::HashMap, sync::Arc}; - -use axum::{ - Router, body::Bytes, extract::State, http::StatusCode, response::IntoResponse, routing::post, -}; -use http::{HeaderName, HeaderValue}; -use hyper_util::rt::TokioIo; -use rmcp::{ - ServiceExt, - transport::{ - StreamableHttpClientTransport, UnixSocketHttpClient, - streamable_http_client::StreamableHttpClientTransportConfig, - }, -}; -use serde_json::json; -use tokio::sync::Mutex; - -#[derive(Clone)] -struct ServerState { - received_headers: Arc>>, - initialize_called: Arc, -} - -async fn mcp_handler( - State(state): State, - headers: http::HeaderMap, - body: Bytes, -) -> impl IntoResponse { - let mut headers_map = HashMap::new(); - for (name, value) in headers.iter() { - let name_str = name.as_str(); - if name_str.starts_with("x-") || name_str == "host" { - if let Ok(v) = value.to_str() { - headers_map.insert(name_str.to_string(), v.to_string()); - } - } - } - - let mut stored = state.received_headers.lock().await; - stored.extend(headers_map); - drop(stored); - - if let Ok(json_body) = serde_json::from_slice::(&body) { - if let Some(method) = json_body.get("method").and_then(|m| m.as_str()) { - if method == "initialize" { - state.initialize_called.notify_one(); - let response = json!({ - "jsonrpc": "2.0", - "id": json_body.get("id"), - "result": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "serverInfo": { - "name": "test-unix-server", - "version": "1.0.0" - } - } - }); - return ( - StatusCode::OK, - [ - (http::header::CONTENT_TYPE, "application/json"), - ( - http::HeaderName::from_static("mcp-session-id"), - "unix-test-session", - ), - ], - response.to_string(), - ); - } else if method == "notifications/initialized" { - return ( - StatusCode::ACCEPTED, - [ - (http::header::CONTENT_TYPE, "application/json"), - ( - http::HeaderName::from_static("mcp-session-id"), - "unix-test-session", - ), - ], - String::new(), - ); - } - } - } - - let request_id = serde_json::from_slice::(&body) - .ok() - .and_then(|j| j.get("id").cloned()) - .unwrap_or(serde_json::Value::Null); - let response = json!({ - "jsonrpc": "2.0", - "id": request_id, - "result": {} - }); - ( - StatusCode::OK, - [ - (http::header::CONTENT_TYPE, "application/json"), - ( - http::HeaderName::from_static("mcp-session-id"), - "unix-test-session", - ), - ], - response.to_string(), - ) -} - -/// Spawns an HTTP/1.1 server on a Unix socket using hyper directly. -/// Avoids `axum::serve(UnixListener, ...)` which uses `spawn_local` on Linux. -fn spawn_unix_server( - listener: tokio::net::UnixListener, - app: Router, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - while let Ok((stream, _)) = listener.accept().await { - let tower_service = app.clone(); - tokio::spawn(async move { - let io = TokioIo::new(stream); - let hyper_service = hyper::service::service_fn( - move |req: hyper::Request| { - let mut tower_service = tower_service.clone(); - async move { - use tower_service::Service; - tower_service.call(req).await - } - }, - ); - hyper::server::conn::http1::Builder::new() - .serve_connection(io, hyper_service) - .await - .ok(); - }); - } - }) -} - -/// Integration test: MCP client connects and completes handshake over a Unix domain socket. -#[tokio::test] -async fn test_unix_socket_mcp_handshake() -> anyhow::Result<()> { - let dir = std::env::temp_dir().join(format!("rmcp-test-{}", std::process::id())); - std::fs::create_dir_all(&dir)?; - let socket_path = dir.join("mcp.sock"); - - let _ = std::fs::remove_file(&socket_path); - - let state = ServerState { - received_headers: Arc::new(Mutex::new(HashMap::new())), - initialize_called: Arc::new(tokio::sync::Notify::new()), - }; - - let app = Router::new() - .route("/mcp", post(mcp_handler)) - .with_state(state.clone()); - - let listener = tokio::net::UnixListener::bind(&socket_path)?; - let server_handle = spawn_unix_server(listener, app); - - let socket_str = socket_path.to_str().unwrap(); - let uri = "http://mcp-server.internal/mcp"; - let client = UnixSocketHttpClient::new(socket_str, uri); - let config = StreamableHttpClientTransportConfig::with_uri(uri); - let transport = StreamableHttpClientTransport::with_client(client, config); - - let mcp_client = ().serve(transport).await.expect("MCP handshake should succeed"); - - tokio::time::timeout( - std::time::Duration::from_secs(5), - state.initialize_called.notified(), - ) - .await - .expect("Initialize request should be received"); - - let headers = state.received_headers.lock().await; - assert_eq!( - headers.get("host"), - Some(&"mcp-server.internal".to_string()), - "Host header should be derived from URI" - ); - - drop(mcp_client); - server_handle.abort(); - let _ = std::fs::remove_file(&socket_path); - let _ = std::fs::remove_dir(&dir); - - Ok(()) -} - -/// Integration test: Custom headers are sent through the Unix socket transport. -#[tokio::test] -async fn test_unix_socket_custom_headers() -> anyhow::Result<()> { - let dir = std::env::temp_dir().join(format!("rmcp-test-headers-{}", std::process::id())); - std::fs::create_dir_all(&dir)?; - let socket_path = dir.join("mcp.sock"); - let _ = std::fs::remove_file(&socket_path); - - let state = ServerState { - received_headers: Arc::new(Mutex::new(HashMap::new())), - initialize_called: Arc::new(tokio::sync::Notify::new()), - }; - - let app = Router::new() - .route("/mcp", post(mcp_handler)) - .with_state(state.clone()); - - let listener = tokio::net::UnixListener::bind(&socket_path)?; - let server_handle = spawn_unix_server(listener, app); - - let mut custom_headers = HashMap::new(); - custom_headers.insert( - HeaderName::from_static("x-test-header"), - HeaderValue::from_static("test-value-123"), - ); - custom_headers.insert( - HeaderName::from_static("x-client-id"), - HeaderValue::from_static("unix-test-client"), - ); - - let socket_str = socket_path.to_str().unwrap(); - let uri = "http://mcp-server.internal/mcp"; - let client = UnixSocketHttpClient::new(socket_str, uri); - let config = StreamableHttpClientTransportConfig::with_uri(uri).custom_headers(custom_headers); - let transport = StreamableHttpClientTransport::with_client(client, config); - - let mcp_client = ().serve(transport).await.expect("MCP handshake should succeed"); - - tokio::time::timeout( - std::time::Duration::from_secs(5), - state.initialize_called.notified(), - ) - .await - .expect("Initialize request should be received"); - - let headers = state.received_headers.lock().await; - assert_eq!( - headers.get("x-test-header"), - Some(&"test-value-123".to_string()), - "Custom header x-test-header should be received" - ); - assert_eq!( - headers.get("x-client-id"), - Some(&"unix-test-client".to_string()), - "Custom header x-client-id should be received" - ); - - drop(mcp_client); - server_handle.abort(); - let _ = std::fs::remove_file(&socket_path); - let _ = std::fs::remove_dir(&dir); - - Ok(()) -} - -/// Integration test: Convenience constructor `from_unix_socket` works end-to-end. -#[tokio::test] -async fn test_unix_socket_convenience_constructor() -> anyhow::Result<()> { - let dir = std::env::temp_dir().join(format!("rmcp-test-conv-{}", std::process::id())); - std::fs::create_dir_all(&dir)?; - let socket_path = dir.join("mcp.sock"); - let _ = std::fs::remove_file(&socket_path); - - let state = ServerState { - received_headers: Arc::new(Mutex::new(HashMap::new())), - initialize_called: Arc::new(tokio::sync::Notify::new()), - }; - - let app = Router::new() - .route("/mcp", post(mcp_handler)) - .with_state(state.clone()); - - let listener = tokio::net::UnixListener::bind(&socket_path)?; - let server_handle = spawn_unix_server(listener, app); - - let socket_str = socket_path.to_str().unwrap(); - let transport = - StreamableHttpClientTransport::from_unix_socket(socket_str, "http://localhost/mcp"); - - let mcp_client = ().serve(transport).await.expect("MCP handshake should succeed"); - - tokio::time::timeout( - std::time::Duration::from_secs(5), - state.initialize_called.notified(), - ) - .await - .expect("Initialize request should be received"); - - drop(mcp_client); - server_handle.abort(); - let _ = std::fs::remove_file(&socket_path); - let _ = std::fs::remove_dir(&dir); - - Ok(()) -} diff --git a/vendor/rmcp/tests/test_with_js.rs b/vendor/rmcp/tests/test_with_js.rs deleted file mode 100644 index 8992f8161..000000000 --- a/vendor/rmcp/tests/test_with_js.rs +++ /dev/null @@ -1,136 +0,0 @@ -#![cfg(not(feature = "local"))] -use rmcp::{ - ServiceExt, - service::QuitReason, - transport::{ - ConfigureCommandExt, StreamableHttpClientTransport, StreamableHttpServerConfig, - TokioChildProcess, - streamable_http_server::{ - session::local::LocalSessionManager, tower::StreamableHttpService, - }, - }, -}; -use tokio_util::sync::CancellationToken; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -mod common; -use common::calculator::Calculator; - -const STREAMABLE_HTTP_BIND_ADDRESS: &str = "127.0.0.1:8001"; -const STREAMABLE_HTTP_JS_BIND_ADDRESS: &str = "127.0.0.1:8002"; -// These tests run concurrently, while npm mutates their shared node_modules directory. -static JS_DEPENDENCIES_INSTALLED: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new(); - -async fn install_js_dependencies() -> anyhow::Result<()> { - JS_DEPENDENCIES_INSTALLED - .get_or_try_init(|| async { - let status = tokio::process::Command::new("npm") - .arg("install") - .current_dir("tests/test_with_js") - .status() - .await?; - anyhow::ensure!(status.success(), "npm install failed with {status}"); - Ok(()) - }) - .await?; - Ok(()) -} - -#[tokio::test] -async fn test_with_js_stdio_server() -> anyhow::Result<()> { - let _ = tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "debug".to_string().into()), - ) - .with(tracing_subscriber::fmt::layer()) - .try_init(); - install_js_dependencies().await?; - let transport = - TokioChildProcess::new(tokio::process::Command::new("node").configure(|cmd| { - cmd.arg("tests/test_with_js/server.js"); - }))?; - - let client = ().serve(transport).await?; - let resources = client.list_all_resources().await?; - tracing::info!("{:#?}", resources); - let tools = client.list_all_tools().await?; - tracing::info!("{:#?}", tools); - - client.cancel().await?; - Ok(()) -} - -#[tokio::test] -async fn test_with_js_streamable_http_client() -> anyhow::Result<()> { - let _ = tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "debug".to_string().into()), - ) - .with(tracing_subscriber::fmt::layer()) - .try_init(); - install_js_dependencies().await?; - - let ct = CancellationToken::new(); - let service: StreamableHttpService = - StreamableHttpService::new( - || Ok(Calculator::new()), - Default::default(), - StreamableHttpServerConfig::default() - .with_sse_keep_alive(None) - .with_cancellation_token(ct.child_token()), - ); - let router = axum::Router::new().nest_service("/mcp", service); - let tcp_listener = tokio::net::TcpListener::bind(STREAMABLE_HTTP_BIND_ADDRESS).await?; - - let handle = tokio::spawn({ - let ct = ct.clone(); - async move { - let _ = axum::serve(tcp_listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - } - }); - let exit_status = tokio::process::Command::new("node") - .arg("tests/test_with_js/streamable_client.js") - .spawn()? - .wait() - .await?; - assert!(exit_status.success()); - ct.cancel(); - handle.await?; - Ok(()) -} - -#[tokio::test] -async fn test_with_js_streamable_http_server() -> anyhow::Result<()> { - let _ = tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "debug".to_string().into()), - ) - .with(tracing_subscriber::fmt::layer()) - .try_init(); - install_js_dependencies().await?; - - let transport = StreamableHttpClientTransport::from_uri(format!( - "http://{STREAMABLE_HTTP_JS_BIND_ADDRESS}/mcp" - )); - - let mut server = tokio::process::Command::new("node") - .arg("tests/test_with_js/streamable_server.js") - .spawn()?; - - // waiting for server up - tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; - - let client = ().serve(transport).await?; - let resources = client.list_all_resources().await?; - tracing::info!("{:#?}", resources); - let tools = client.list_all_tools().await?; - tracing::info!("{:#?}", tools); - let quit_reason = client.cancel().await?; - server.kill().await?; - assert!(matches!(quit_reason, QuitReason::Cancelled)); - Ok(()) -} diff --git a/vendor/rmcp/tests/test_with_js/.gitignore b/vendor/rmcp/tests/test_with_js/.gitignore deleted file mode 100644 index 572406bfd..000000000 --- a/vendor/rmcp/tests/test_with_js/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/node_modules -package-lock.json \ No newline at end of file diff --git a/vendor/rmcp/tests/test_with_js/client.js b/vendor/rmcp/tests/test_with_js/client.js deleted file mode 100644 index 17b189a74..000000000 --- a/vendor/rmcp/tests/test_with_js/client.js +++ /dev/null @@ -1,29 +0,0 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; - -const transport = new SSEClientTransport(new URL(`http://127.0.0.1:8000/sse`)); - -const client = new Client( - { - name: "example-client", - version: "1.0.0" - }, - { - capabilities: { - prompts: {}, - resources: {}, - tools: {} - } - } -); -await client.connect(transport); -const tools = await client.listTools(); -console.log(tools); -const resources = await client.listResources(); -console.log(resources); -const templates = await client.listResourceTemplates(); -console.log(templates); -const prompts = await client.listPrompts(); -console.log(prompts); -await client.close(); -await transport.close(); \ No newline at end of file diff --git a/vendor/rmcp/tests/test_with_js/package.json b/vendor/rmcp/tests/test_with_js/package.json deleted file mode 100644 index d8ecfbfb9..000000000 --- a/vendor/rmcp/tests/test_with_js/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "dependencies": { - "@modelcontextprotocol/sdk": "^1.10", - "eventsource-parser": "^3.0.1", - "express": "^5.1.0" - }, - "type": "module", - "name": "test_with_ts", - "version": "1.0.0", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "author": "", - "license": "ISC", - "description": "" -} diff --git a/vendor/rmcp/tests/test_with_js/server.js b/vendor/rmcp/tests/test_with_js/server.js deleted file mode 100644 index d959224db..000000000 --- a/vendor/rmcp/tests/test_with_js/server.js +++ /dev/null @@ -1,35 +0,0 @@ -import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { z } from "zod"; - -const server = new McpServer({ - name: "Demo", - version: "1.0.0" -}); - -server.resource( - "greeting", - new ResourceTemplate("greeting://{name}", { list: undefined }), - async (uri, { name }) => ({ - contents: [{ - uri: uri.href, - text: `Hello, ${name}` - }] - }) -); - -server.tool( - "add", - { a: z.number(), b: z.number() }, - async ({ a, b }) => ({ - "content": [ - { - "type": "text", - "text": `${a + b}` - } - ] - }) -); - -const transport = new StdioServerTransport(); -await server.connect(transport); \ No newline at end of file diff --git a/vendor/rmcp/tests/test_with_js/streamable_client.js b/vendor/rmcp/tests/test_with_js/streamable_client.js deleted file mode 100644 index 998261312..000000000 --- a/vendor/rmcp/tests/test_with_js/streamable_client.js +++ /dev/null @@ -1,28 +0,0 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; - -const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:8001/mcp/`)); - -const client = new Client( - { - name: "example-client", - version: "1.0.0" - }, - { - capabilities: { - prompts: {}, - resources: {}, - tools: {} - } - } -); -await client.connect(transport); -const tools = await client.listTools(); -console.log(tools); -const resources = await client.listResources(); -console.log(resources); -const templates = await client.listResourceTemplates(); -console.log(templates); -const prompts = await client.listPrompts(); -console.log(prompts); -await client.close(); diff --git a/vendor/rmcp/tests/test_with_js/streamable_server.js b/vendor/rmcp/tests/test_with_js/streamable_server.js deleted file mode 100644 index 3f87ccf01..000000000 --- a/vendor/rmcp/tests/test_with_js/streamable_server.js +++ /dev/null @@ -1,105 +0,0 @@ -import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js" -import { randomUUID } from "node:crypto" -import { z } from "zod"; -import express from "express" - -const app = express(); -app.use(express.json()); - -// Map to store transports by session ID -const transports = {}; - -// Handle POST requests for client-to-server communication -app.post('/mcp', async (req, res) => { - // Check for existing session ID - const sessionId = req.headers['mcp-session-id']; - let transport; - - if (sessionId && transports[sessionId]) { - // Reuse existing transport - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - // New initialization request - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID().toString(), - onsessioninitialized: (sessionId) => { - // Store the transport by session ID - transports[sessionId] = transport; - } - }); - - // Clean up transport when closed - transport.onclose = () => { - if (transport.sessionId) { - delete transports[transport.sessionId]; - } - }; - const server = new McpServer({ - name: "example-server", - version: "1.0.0" - }); - - server.resource( - "greeting", - new ResourceTemplate("greeting://{name}", { list: undefined }), - async (uri, { name }) => ({ - contents: [{ - uri: uri.href, - text: `Hello, ${name}` - }] - }) - ); - - server.tool( - "add", - { a: z.number(), b: z.number() }, - async ({ a, b }) => ({ - "content": [ - { - "type": "text", - "text": `${a + b}` - } - ] - }) - ); - - // Connect to the MCP server - await server.connect(transport); - } else { - // Invalid request - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: No valid session ID provided', - }, - id: null, - }); - return; - } - - // Handle the request - await transport.handleRequest(req, res, req.body); -}); - -// Reusable handler for GET and DELETE requests -const handleSessionRequest = async (req, res) => { - const sessionId = req.headers['mcp-session-id']; - if (!sessionId || !transports[sessionId]) { - res.status(400).send('Invalid or missing session ID'); - return; - } - - const transport = transports[sessionId]; - await transport.handleRequest(req, res); -}; - -// Handle GET requests for server-to-client notifications via SSE -app.get('/mcp', handleSessionRequest); - -// Handle DELETE requests for session termination -app.delete('/mcp', handleSessionRequest); -console.log("Listening on port 8002"); -app.listen(8002); \ No newline at end of file diff --git a/vendor/rmcp/tests/test_with_python.rs b/vendor/rmcp/tests/test_with_python.rs deleted file mode 100644 index c905e1b5b..000000000 --- a/vendor/rmcp/tests/test_with_python.rs +++ /dev/null @@ -1,78 +0,0 @@ -#![cfg(not(feature = "local"))] -use std::process::Stdio; - -use rmcp::{ - ServiceExt, - transport::{ConfigureCommandExt, TokioChildProcess}, -}; -use tokio::io::AsyncReadExt; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -mod common; - -async fn init() -> anyhow::Result<()> { - let _ = tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "debug".to_string().into()), - ) - .with(tracing_subscriber::fmt::layer()) - .try_init(); - tokio::process::Command::new("uv") - .args(["sync"]) - .current_dir("tests/test_with_python") - .spawn()? - .wait() - .await?; - Ok(()) -} - -#[tokio::test] -async fn test_with_python_server() -> anyhow::Result<()> { - init().await?; - - let transport = TokioChildProcess::new(tokio::process::Command::new("uv").configure(|cmd| { - cmd.arg("run") - .arg("server.py") - .current_dir("tests/test_with_python"); - }))?; - - let client = ().serve(transport).await?; - let resources = client.list_all_resources().await?; - tracing::info!("{:#?}", resources); - let tools = client.list_all_tools().await?; - tracing::info!("{:#?}", tools); - client.cancel().await?; - Ok(()) -} - -#[tokio::test] -async fn test_with_python_server_stderr() -> anyhow::Result<()> { - init().await?; - - let (transport, stderr) = - TokioChildProcess::builder(tokio::process::Command::new("uv").configure(|cmd| { - cmd.arg("run") - .arg("server.py") - .current_dir("tests/test_with_python"); - })) - .stderr(Stdio::piped()) - .spawn()?; - - let mut stderr = stderr.expect("stderr must be piped"); - - let stderr_task = tokio::spawn(async move { - let mut buffer = String::new(); - stderr.read_to_string(&mut buffer).await?; - Ok::<_, std::io::Error>(buffer) - }); - - let client = ().serve(transport).await?; - let _ = client.list_all_resources().await?; - let _ = client.list_all_tools().await?; - client.cancel().await?; - - let stderr_output = stderr_task.await??; - assert!(stderr_output.contains("server starting up...")); - - Ok(()) -} diff --git a/vendor/rmcp/tests/test_with_python/.gitignore b/vendor/rmcp/tests/test_with_python/.gitignore deleted file mode 100644 index 2eb000890..000000000 --- a/vendor/rmcp/tests/test_with_python/.gitignore +++ /dev/null @@ -1,22 +0,0 @@ -# Lock files -*.lock - -# Python build artifacts -*.egg-info/ -build/ -dist/ -__pycache__/ -*.py[cod] -*$py.class - -# Virtual environments -venv/ -env/ -.venv/ -.env/ - -# IDE -.vscode/ -.idea/ -*.swp -*.swo diff --git a/vendor/rmcp/tests/test_with_python/client.py b/vendor/rmcp/tests/test_with_python/client.py deleted file mode 100644 index 83d2c35b0..000000000 --- a/vendor/rmcp/tests/test_with_python/client.py +++ /dev/null @@ -1,28 +0,0 @@ -from mcp import ClientSession, StdioServerParameters, types -from mcp.client.sse import sse_client -import sys - -async def run(): - url = sys.argv[1] - async with sse_client(url) as (read, write): - async with ClientSession( - read, write - ) as session: - # Initialize the connection - await session.initialize() - - # List available prompts - prompts = await session.list_prompts() - print(prompts) - # List available resources - resources = await session.list_resources() - print(resources) - - # List available tools - tools = await session.list_tools() - print(tools) - -if __name__ == "__main__": - import asyncio - - asyncio.run(run()) diff --git a/vendor/rmcp/tests/test_with_python/pyproject.toml b/vendor/rmcp/tests/test_with_python/pyproject.toml deleted file mode 100644 index 9c8ce6ae9..000000000 --- a/vendor/rmcp/tests/test_with_python/pyproject.toml +++ /dev/null @@ -1,14 +0,0 @@ -[build-system] -requires = ["setuptools>=42", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "test_with_python" -version = "0.1.0" -description = "Test Python client for RMCP" -dependencies = [ - "fastmcp", -] - -[tool.setuptools] -py-modules = ["client", "server"] diff --git a/vendor/rmcp/tests/test_with_python/server.py b/vendor/rmcp/tests/test_with_python/server.py deleted file mode 100644 index 1c8ee6986..000000000 --- a/vendor/rmcp/tests/test_with_python/server.py +++ /dev/null @@ -1,25 +0,0 @@ -from fastmcp import FastMCP - -import sys - -mcp = FastMCP("Demo") - -print("server starting up...", file=sys.stderr) - - -@mcp.tool() -def add(a: int, b: int) -> int: - """Add two numbers""" - return a + b - - -# Add a dynamic greeting resource -@mcp.resource("greeting://{name}") -def get_greeting(name: str) -> str: - """Get a personalized greeting""" - return f"Hello, {name}!" - - - -if __name__ == "__main__": - mcp.run() \ No newline at end of file