From d8c7b0dd7b77d71b792b70a9d33786045abf319e Mon Sep 17 00:00:00 2001 From: Andrew McGivery Date: Tue, 17 Feb 2026 13:45:00 -0800 Subject: [PATCH] When calculating app target, attempt to autodetect from advertised capabilities if not specified in url --- Cargo.lock | 205 +++++++++++++++++- crates/apollo-mcp-server/Cargo.toml | 2 +- crates/apollo-mcp-server/src/apps/app.rs | 70 +++++- crates/apollo-mcp-server/src/apps/manifest.rs | 1 + crates/apollo-mcp-server/src/apps/resource.rs | 2 +- .../src/operations/operation.rs | 20 ++ .../src/server/states/running.rs | 105 +++++++-- 7 files changed, 369 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1f51aa03f..1bcb2b802 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -646,6 +646,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + [[package]] name = "chrono" version = "0.4.43" @@ -830,6 +841,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -918,7 +938,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -1572,6 +1592,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.0", + "wasip2", + "wasip3", +] + [[package]] name = "glob" version = "0.3.3" @@ -2051,6 +2085,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -2345,6 +2385,12 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "levenshtein" version = "1.0.5" @@ -3391,6 +3437,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.1", + "rand_core 0.10.0", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -3429,6 +3486,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "rand_distr" version = "0.4.3" @@ -3679,12 +3742,11 @@ dependencies = [ [[package]] name = "rmcp" -version = "0.14.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a621b37a548ff6ab6292d57841eb25785a7f146d89391a19c9f199414bd13da" +checksum = "cc4c9c94680f75470ee8083a0667988b5d7b5beb70b9f998a8e51de7c682ce60" dependencies = [ "async-trait", - "axum", "base64", "bytes", "chrono", @@ -3694,7 +3756,7 @@ dependencies = [ "http-body-util", "pastey", "pin-project-lite", - "rand 0.9.2", + "rand 0.10.0", "rmcp-macros", "schemars", "serde", @@ -3711,9 +3773,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.14.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b79ed92303f9262db79575aa8c3652581668e9d136be6fd0b9ededa78954c95" +checksum = "90c23c8f26cae4da838fbc3eadfaecf2d549d97c04b558e7bd90526a9c28b42a" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -4166,7 +4228,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4177,7 +4239,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5285,6 +5347,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -5344,6 +5415,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.85" @@ -5740,6 +5845,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" diff --git a/crates/apollo-mcp-server/Cargo.toml b/crates/apollo-mcp-server/Cargo.toml index 1a8f5c93b..5e4485c2d 100644 --- a/crates/apollo-mcp-server/Cargo.toml +++ b/crates/apollo-mcp-server/Cargo.toml @@ -47,7 +47,7 @@ regex = "1.11.1" reqwest-middleware = "0.4.2" reqwest-tracing = { version = "0.5.8", features = ["opentelemetry_0_30"] } reqwest.workspace = true -rmcp = { version = "0.14", features = [ +rmcp = { version = "0.16", features = [ "server", "transport-io", "transport-streamable-http-server", diff --git a/crates/apollo-mcp-server/src/apps/app.rs b/crates/apollo-mcp-server/src/apps/app.rs index 20192be99..397712ce6 100644 --- a/crates/apollo-mcp-server/src/apps/app.rs +++ b/crates/apollo-mcp-server/src/apps/app.rs @@ -1,6 +1,7 @@ use std::sync::Arc; -use rmcp::model::{ErrorCode, Extensions, RawResource, Resource, Tool}; +use rmcp::model::{ClientCapabilities, ErrorCode, Extensions, RawResource, Resource, Tool}; +use serde_json::Value; use url::Url; use crate::apps::manifest::{AppLabels, CSPSettings, WidgetSettings}; @@ -88,10 +89,12 @@ pub(crate) enum AppTarget { MCPApps, } -impl TryFrom for AppTarget { +impl TryFrom<(Extensions, Option<&ClientCapabilities>)> for AppTarget { type Error = McpError; - fn try_from(extensions: Extensions) -> Result { + fn try_from( + (extensions, client_capabilities): (Extensions, Option<&ClientCapabilities>), + ) -> Result { let app_target_param = extensions .get::() .and_then(|parts| parts.uri.query()) @@ -111,12 +114,30 @@ impl TryFrom for AppTarget { ), None, )), - // TODO: In the future, once host capabilities are advertised, we should try auto detection before defaulting to apps sdk - None => Ok(AppTarget::AppsSDK), + None => { + // If target hasn't been specified in the URL, try to detect it via client capabilities. If we still don't know, we'll default to AppsSDK. + if let Some(client_capabilities) = client_capabilities + && has_mcp_app_support(client_capabilities) + { + Ok(AppTarget::MCPApps) + } else { + Ok(AppTarget::AppsSDK) + } + } } } } +pub(crate) fn has_mcp_app_support(client_capabilities: &ClientCapabilities) -> bool { + client_capabilities + .extensions + .as_ref() + .and_then(|extensions| extensions.get("io.modelcontextprotocol/ui")) + .and_then(|extension| extension.get("mimeTypes")) + .and_then(|mimetypes| mimetypes.as_array()) + .is_some_and(|mimetypes| mimetypes.contains(&Value::from("text/html;profile=mcp-app"))) +} + #[cfg(test)] mod tests { use super::*; @@ -131,7 +152,7 @@ mod tests { let (parts, _) = request.into_parts(); extensions.insert(parts); - let app_target = AppTarget::try_from(extensions).unwrap(); + let app_target = AppTarget::try_from((extensions, None)).unwrap(); assert!(matches!(app_target, AppTarget::AppsSDK)); } @@ -145,7 +166,7 @@ mod tests { let (parts, _) = request.into_parts(); extensions.insert(parts); - let app_target = AppTarget::try_from(extensions).unwrap(); + let app_target = AppTarget::try_from((extensions, None)).unwrap(); assert!(matches!(app_target, AppTarget::AppsSDK)); } @@ -159,7 +180,7 @@ mod tests { let (parts, _) = request.into_parts(); extensions.insert(parts); - let app_target = AppTarget::try_from(extensions).unwrap(); + let app_target = AppTarget::try_from((extensions, None)).unwrap(); assert!(matches!(app_target, AppTarget::MCPApps)); } @@ -173,7 +194,7 @@ mod tests { let (parts, _) = request.into_parts(); extensions.insert(parts); - let app_target = AppTarget::try_from(extensions).unwrap(); + let app_target = AppTarget::try_from((extensions, None)).unwrap(); assert!(matches!(app_target, AppTarget::MCPApps)); } @@ -187,7 +208,7 @@ mod tests { let (parts, _) = request.into_parts(); extensions.insert(parts); - let result = AppTarget::try_from(extensions); + let result = AppTarget::try_from((extensions, None)); assert!(result.is_err()); let err = result.err().unwrap(); assert_eq!(err.code, ErrorCode::INVALID_REQUEST); @@ -207,7 +228,34 @@ mod tests { let (parts, _) = request.into_parts(); extensions.insert(parts); - let app_target = AppTarget::try_from(extensions).unwrap(); + let app_target = AppTarget::try_from((extensions, None)).unwrap(); assert!(matches!(app_target, AppTarget::AppsSDK)); } + + #[test] + fn test_app_target_missing_with_mcp_app_capability_defaults_to_mcp_apps() { + let mut extensions = Extensions::new(); + let request = axum::http::Request::builder() + .uri("http://localhost") + .body(()) + .unwrap(); + let (parts, _) = request.into_parts(); + extensions.insert(parts); + + let mut extension_capabilities = std::collections::BTreeMap::new(); + extension_capabilities.insert( + "io.modelcontextprotocol/ui".to_string(), + serde_json::json!({"mimeTypes": ["text/html;profile=mcp-app"]}) + .as_object() + .unwrap() + .clone(), + ); + let client_capabilities = ClientCapabilities { + extensions: Some(extension_capabilities), + ..Default::default() + }; + + let app_target = AppTarget::try_from((extensions, Some(&client_capabilities))).unwrap(); + assert!(matches!(app_target, AppTarget::MCPApps)); + } } diff --git a/crates/apollo-mcp-server/src/apps/manifest.rs b/crates/apollo-mcp-server/src/apps/manifest.rs index 78255c0e3..1fffdcc55 100644 --- a/crates/apollo-mcp-server/src/apps/manifest.rs +++ b/crates/apollo-mcp-server/src/apps/manifest.rs @@ -133,6 +133,7 @@ pub(crate) fn load_from_path( output_schema: operation.tool.output_schema.clone(), annotations: operation.tool.annotations.clone(), icons: operation.tool.icons.clone(), + execution: None, }; tools.push(AppTool { diff --git a/crates/apollo-mcp-server/src/apps/resource.rs b/crates/apollo-mcp-server/src/apps/resource.rs index 5c2341a0b..090976397 100644 --- a/crates/apollo-mcp-server/src/apps/resource.rs +++ b/crates/apollo-mcp-server/src/apps/resource.rs @@ -186,7 +186,7 @@ mod tests { .unwrap(); let (parts, _) = request.into_parts(); extensions.insert(parts); - let app_target = AppTarget::try_from(extensions); + let app_target = AppTarget::try_from((extensions, None)); assert!(app_target.is_err()); assert_eq!( diff --git a/crates/apollo-mcp-server/src/operations/operation.rs b/crates/apollo-mcp-server/src/operations/operation.rs index eb8b9381e..070c17b62 100644 --- a/crates/apollo-mcp-server/src/operations/operation.rs +++ b/crates/apollo-mcp-server/src/operations/operation.rs @@ -837,6 +837,7 @@ mod tests { open_world_hint: None, }, ), + execution: None, icons: None, meta: None, } @@ -976,6 +977,7 @@ mod tests { open_world_hint: None, }, ), + execution: None, icons: None, meta: None, } @@ -1126,6 +1128,7 @@ mod tests { open_world_hint: None, }, ), + execution: None, icons: None, meta: None, } @@ -1279,6 +1282,7 @@ mod tests { open_world_hint: None, }, ), + execution: None, icons: None, meta: None, } @@ -1429,6 +1433,7 @@ mod tests { open_world_hint: None, }, ), + execution: None, icons: None, meta: None, } @@ -1576,6 +1581,7 @@ mod tests { open_world_hint: None, }, ), + execution: None, icons: None, meta: None, } @@ -1733,6 +1739,7 @@ mod tests { open_world_hint: None, }, ), + execution: None, icons: None, meta: None, } @@ -1905,6 +1912,7 @@ mod tests { open_world_hint: None, }, ), + execution: None, icons: None, meta: None, } @@ -2042,6 +2050,7 @@ mod tests { open_world_hint: None, }, ), + execution: None, icons: None, meta: None, } @@ -2290,6 +2299,7 @@ mod tests { open_world_hint: None, }, ), + execution: None, icons: None, meta: None, } @@ -2430,6 +2440,7 @@ mod tests { open_world_hint: None, }, ), + execution: None, icons: None, meta: None, } @@ -2574,6 +2585,7 @@ mod tests { open_world_hint: None, }, ), + execution: None, icons: None, meta: None, } @@ -2707,6 +2719,7 @@ mod tests { open_world_hint: None, }, ), + execution: None, icons: None, meta: None, } @@ -3238,6 +3251,7 @@ mod tests { open_world_hint: None, }, ), + execution: None, icons: None, meta: None, } @@ -3365,6 +3379,7 @@ mod tests { open_world_hint: None, }, ), + execution: None, icons: None, meta: None, } @@ -3926,6 +3941,7 @@ mod tests { open_world_hint: None, }, ), + execution: None, icons: None, meta: None, } @@ -4114,6 +4130,7 @@ mod tests { open_world_hint: None, }, ), + execution: None, icons: None, meta: None, } @@ -4317,6 +4334,7 @@ mod tests { open_world_hint: None, }, ), + execution: None, icons: None, meta: None, }, @@ -4446,6 +4464,7 @@ mod tests { open_world_hint: None, }, ), + execution: None, icons: None, meta: None, }, @@ -4575,6 +4594,7 @@ mod tests { open_world_hint: None, }, ), + execution: None, icons: None, meta: None, } diff --git a/crates/apollo-mcp-server/src/server/states/running.rs b/crates/apollo-mcp-server/src/server/states/running.rs index 8a282b918..98949b7ee 100644 --- a/crates/apollo-mcp-server/src/server/states/running.rs +++ b/crates/apollo-mcp-server/src/server/states/running.rs @@ -5,8 +5,8 @@ use opentelemetry::KeyValue; use reqwest::header::HeaderMap; use rmcp::ErrorData; use rmcp::model::{ - Extensions, Implementation, ListResourcesResult, ReadResourceResult, ResourcesCapability, - ToolsCapability, + ClientCapabilities, Extensions, Implementation, ListResourcesResult, ReadResourceResult, + ResourcesCapability, ToolsCapability, }; use rmcp::{ Peer, RoleServer, ServerHandler, ServiceError, @@ -195,7 +195,11 @@ impl Running { *peers = retained_peers; } - async fn list_tools_impl(&self, extensions: Extensions) -> Result { + async fn list_tools_impl( + &self, + extensions: Extensions, + client_capabilities: Option<&ClientCapabilities>, + ) -> Result { let meter = &meter::METER; meter .u64_counter(TelemetryMetric::ListToolsCount.as_str()) @@ -211,7 +215,7 @@ impl Running { .find(|(key, _)| key == "app") .map(|(_, value)| value.into_owned()) }); - let app_target = AppTarget::try_from(extensions)?; + let app_target = AppTarget::try_from((extensions, client_capabilities))?; // If we get the app param, we'll run in a special "app mode" where we only expose the tools for that app (+execute) if let Some(app_name) = app_param { @@ -288,6 +292,7 @@ impl Running { &self, request: rmcp::model::ReadResourceRequestParams, extensions: Extensions, + client_capabilities: Option<&ClientCapabilities>, ) -> Result { let request_uri = Url::parse(&request.uri).map_err(|err| { ErrorData::resource_not_found( @@ -295,7 +300,7 @@ impl Running { None, ) })?; - let app_target = AppTarget::try_from(extensions)?; + let app_target = AppTarget::try_from((extensions, client_capabilities))?; let resource = get_app_resource(&self.apps, request, request_uri, &app_target).await?; @@ -484,7 +489,10 @@ impl ServerHandler for Running { _request: Option, context: RequestContext, ) -> Result { - self.list_tools_impl(context.extensions).await + let client_capabilities = context.peer.peer_info().map(|info| &info.capabilities); + + self.list_tools_impl(context.extensions, client_capabilities) + .await } #[tracing::instrument(skip_all)] @@ -502,7 +510,10 @@ impl ServerHandler for Running { request: rmcp::model::ReadResourceRequestParams, context: RequestContext, ) -> Result { - self.read_resource_impl(request, context.extensions).await + let client_capabilities = context.peer.peer_info().map(|info| &info.capabilities); + + self.read_resource_impl(request, context.extensions, client_capabilities) + .await } fn get_info(&self) -> ServerInfo { @@ -527,6 +538,8 @@ impl ServerHandler for Running { title: self.server_info.title().map(|s| s.to_string()), version: self.server_info.version().to_string(), website_url: self.server_info.website_url().map(|s| s.to_string()), + // TODO: Add support for this via configuration similar to above fields + description: None, }, capabilities, ..Default::default() @@ -776,6 +789,7 @@ mod tests { meta: None, }, Extensions::new(), + None, ) .await .unwrap(); @@ -811,6 +825,7 @@ mod tests { meta: None, }, Extensions::new(), + None, ) .await; assert!(result.is_err()); @@ -830,6 +845,7 @@ mod tests { meta: None, }, Extensions::new(), + None, ) .await; assert!(result.is_err()); @@ -861,6 +877,7 @@ mod tests { meta: None, }, Extensions::new(), + None, ) .await .expect("resource fetch failed"); @@ -899,6 +916,7 @@ mod tests { meta: None, }, Extensions::new(), + None, ) .await .unwrap(); @@ -955,7 +973,10 @@ mod tests { None, ); - let result = running.list_tools_impl(Extensions::new()).await.unwrap(); + let result = running + .list_tools_impl(Extensions::new(), None) + .await + .unwrap(); assert_eq!(result.tools.len(), 0); assert_eq!(result.next_cursor, None); @@ -977,7 +998,7 @@ mod tests { let (parts, _) = request.into_parts(); extensions.insert(parts); - let result = running.list_tools_impl(extensions).await.unwrap(); + let result = running.list_tools_impl(extensions, None).await.unwrap(); assert_eq!(result.tools.len(), 1); assert_eq!(result.tools[0].name, "GetId"); @@ -1000,7 +1021,7 @@ mod tests { let (parts, _) = request.into_parts(); extensions.insert(parts); - let result = running.list_tools_impl(extensions).await; + let result = running.list_tools_impl(extensions, None).await; assert!(result.is_err()); } @@ -1021,7 +1042,7 @@ mod tests { let (parts, _) = request.into_parts(); extensions.insert(parts); - let result = running.list_tools_impl(extensions).await.unwrap(); + let result = running.list_tools_impl(extensions, None).await.unwrap(); let meta = result.tools[0].meta.as_ref().unwrap(); // Should have ui nested metadata with resourceUri and visibility @@ -1051,7 +1072,7 @@ mod tests { let (parts, _) = request.into_parts(); extensions.insert(parts); - let result = running.list_tools_impl(extensions).await.unwrap(); + let result = running.list_tools_impl(extensions, None).await.unwrap(); let meta = result.tools[0].meta.as_ref().unwrap(); // Check nested ui metadata @@ -1086,7 +1107,7 @@ mod tests { let (parts, _) = request.into_parts(); extensions.insert(parts); - let result = running.list_tools_impl(extensions).await.unwrap(); + let result = running.list_tools_impl(extensions, None).await.unwrap(); let meta = result.tools[0].meta.as_ref().unwrap(); // Default should still have ui nested metadata @@ -1099,6 +1120,57 @@ mod tests { assert_eq!(meta.get("ui/resourceUri").unwrap(), RESOURCE_URI); } + #[tokio::test] + async fn list_tools_with_app_and_mcp_app_capability_defaults_to_mcp_target() { + let running = running_with_apps( + AppResource::Single(AppResourceSource::Local("test".to_string())), + None, + None, + ); + + let mut extensions = Extensions::new(); + let request = axum::http::Request::builder() + .uri("http://localhost?app=MyApp") + .body(()) + .unwrap(); + let (parts, _) = request.into_parts(); + extensions.insert(parts); + + let mut extension_capabilities = std::collections::BTreeMap::new(); + extension_capabilities.insert( + "io.modelcontextprotocol/ui".to_string(), + serde_json::json!({"mimeTypes": ["text/html;profile=mcp-app"]}) + .as_object() + .unwrap() + .clone(), + ); + let client_capabilities = ClientCapabilities { + extensions: Some(extension_capabilities), + ..Default::default() + }; + + let result = running + .list_tools_impl(extensions, Some(&client_capabilities)) + .await + .unwrap(); + let meta = result.tools[0].meta.as_ref().unwrap(); + + // Should have MCP-style nested ui metadata + let ui = meta.get("ui").unwrap().as_object().unwrap(); + assert_eq!(ui.get("resourceUri").unwrap(), RESOURCE_URI); + assert_eq!( + ui.get("visibility").unwrap(), + &serde_json::json!(["model", "app"]) + ); + + // Check deprecated root-level ui/resourceUri for backwards compatibility + assert_eq!(meta.get("ui/resourceUri").unwrap(), RESOURCE_URI); + + // Ensure OpenAI-specific keys are NOT present + assert!(meta.get("openai/outputTemplate").is_none()); + assert!(meta.get("openai/widgetAccessible").is_none()); + } + #[tokio::test] async fn list_tools_with_invalid_app_target_returns_error() { let running = running_with_apps( @@ -1115,7 +1187,7 @@ mod tests { let (parts, _) = request.into_parts(); extensions.insert(parts); - let result = running.list_tools_impl(extensions).await; + let result = running.list_tools_impl(extensions, None).await; assert!(result.is_err()); } @@ -1139,6 +1211,7 @@ mod tests { meta: None, }, Extensions::new(), + None, ) .await .unwrap(); @@ -1172,6 +1245,7 @@ mod tests { meta: None, }, Extensions::new(), + None, ) .await .unwrap(); @@ -1204,6 +1278,7 @@ mod tests { meta: None, }, Extensions::new(), + None, ) .await .unwrap(); @@ -1252,6 +1327,7 @@ mod tests { meta: None, }, extensions, + None, ) .await .unwrap(); @@ -1303,6 +1379,7 @@ mod tests { meta: None, }, extensions, + None, ) .await;