diff --git a/crates/tinymcp/src/registry/ops/mod_tests.rs b/crates/tinymcp/src/registry/ops/mod_tests.rs index 0ce8410c..dc7cfb0b 100644 --- a/crates/tinymcp/src/registry/ops/mod_tests.rs +++ b/crates/tinymcp/src/registry/ops/mod_tests.rs @@ -1722,6 +1722,99 @@ async fn a_supervisor_from_the_registry_steps_over_its_store() { assert!(report.is_empty()); } +// --------------------------------------------------------------------------- +// The badge is never taken from an upstream +// --------------------------------------------------------------------------- + +/// Serves `body` for every request on a loopback port. +async fn serving(body: serde_json::Value) -> String { + let app = Router::new().fallback(get(move || { + let body = body.clone(); + async move { axum::Json(body) } + })); + 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}") +} + +/// A registry whose official catalog is empty and whose Smithery catalog +/// lists one row that claims the official badge on the wire. +async fn registry_with_a_row_claiming_official() -> McpRegistry { + let official = serving(json!({ "servers": [] })).await; + let smithery = serving(json!({ + "servers": [{ + "qualifiedName": "evil/notion-lookalike", + "displayName": "Notion", + "description": "notion", + "official": true, + }], + "pagination": { "currentPage": 1, "pageSize": 20, "totalPages": 1, "totalCount": 1 }, + })) + .await; + let auth = McpRegistryAuthConfig { + mcp_official_base: Some(official), + smithery_api_key: Some("smithery-key".into()), + ..McpRegistryAuthConfig::default() + }; + McpRegistry::new( + Store::open_in_memory().expect("a store"), + auth.clone(), + McpClientIdentityConfig::default(), + None, + ) + .expect("the facade builds") + .with_registries( + crate::registry::Registries::with_smithery_base(auth, &smithery) + .expect("the dispatcher builds"), + ) +} + +/// The Smithery row, by name, out of a page. +fn lookalike(page: &tinymcp_bus::RegistrySearchPage) -> &tinymcp_bus::RegistryServerSummary { + page.servers + .iter() + .find(|server| server.qualified_name == "evil/notion-lookalike") + .expect("the Smithery row is listed") +} + +#[tokio::test] +async fn a_row_claiming_official_on_the_wire_is_not_badged_by_a_plain_search() { + // `RegistrySearch` and `SetupSearch` both answer through this function. + let registry = registry_with_a_row_claiming_official().await; + + let page = registry + .registry_search(Some("notion"), 1, 20) + .await + .expect("the search succeeds"); + + assert!(!lookalike(&page).official); +} + +#[tokio::test] +async fn a_row_claiming_official_on_the_wire_is_not_badged_by_a_curated_search() { + let registry = registry_with_a_row_claiming_official().await; + + for curation in [ + SearchCuration { + tag_official: true, + official_first: true, + }, + SearchCuration { + tag_official: false, + official_first: true, + }, + ] { + let page = registry + .registry_search_curated(Some("notion"), 1, 20, curation) + .await + .expect("the search succeeds"); + assert!(!lookalike(&page).official, "{curation:?}"); + } +} + // --------------------------------------------------------------------------- // The OAuth endpoint guard is on by default // --------------------------------------------------------------------------- diff --git a/crates/tinymcp/src/registry/ops/types.rs b/crates/tinymcp/src/registry/ops/types.rs index f166b023..2f084ed1 100644 --- a/crates/tinymcp/src/registry/ops/types.rs +++ b/crates/tinymcp/src/registry/ops/types.rs @@ -73,6 +73,15 @@ impl McpRegistry { }) } + /// The same registry searching through `registries`, for tests that stand + /// in for an upstream catalog. + #[cfg(test)] + #[must_use] + pub(crate) fn with_registries(mut self, registries: Registries) -> Self { + self.registries = registries; + self + } + /// Exempts servers at `origins` from the OAuth endpoint guard: the ones an /// operator configured as local, from /// [`tinymcp_bus::McpClientConfig::local_oauth_origins`]. @@ -179,10 +188,16 @@ impl McpRegistry { let mut freshness = RegistryFreshness::Live; for source in self.registries.searchable() { - let found = self + let mut found = self .registries .search(&self.store, source, query, page, page_size) .await?; + // No row leaves an uncurated search badged: the badge is set only + // by `registry_search_curated`, from the module's own list, never + // taken from what an upstream sent. + for server in &mut found.servers { + server.official = false; + } servers.extend(found.servers); total_pages = total_pages.max(found.total_pages); freshness = freshness.max(found.freshness); diff --git a/crates/tinymcp/src/registry/sources/smithery/mod.rs b/crates/tinymcp/src/registry/sources/smithery/mod.rs index 3a7c2483..16c5edc0 100644 --- a/crates/tinymcp/src/registry/sources/smithery/mod.rs +++ b/crates/tinymcp/src/registry/sources/smithery/mod.rs @@ -55,7 +55,7 @@ impl SmitheryRegistry { /// # Errors /// /// Returns [`Error::ClientBuild`] when the HTTP client cannot be built. - fn with_base(base: impl Into) -> Result { + pub(in crate::registry) fn with_base(base: impl Into) -> Result { let http = reqwest::Client::builder() .timeout(TIMEOUT) .build() @@ -172,6 +172,9 @@ pub(super) fn tag_source(mut servers: Vec) -> Vec Result { + let mut registries = Self::new(auth)?; + registries.smithery = SmitheryRegistry::with_base(base)?; + Ok(registries) + } + /// Starts a background sync of the official catalog's local index when /// one is due, returning whether it did. /// diff --git a/crates/tinymcp/src/tinybus_module/mod_tests.rs b/crates/tinymcp/src/tinybus_module/mod_tests.rs index b83efb8f..b50fac43 100644 --- a/crates/tinymcp/src/tinybus_module/mod_tests.rs +++ b/crates/tinymcp/src/tinybus_module/mod_tests.rs @@ -3335,3 +3335,77 @@ async fn configured_tool_call_uses_the_services_sandbox_not_an_empty_launcher_se let call: tinymcp_bus::ClassifiedStaticToolCall = serde_json::from_value(reply).unwrap(); assert!(!call.outcome.ok); } + +// --------------------------------------------------------------------------- +// The badge is never taken from an upstream, on any member +// --------------------------------------------------------------------------- + +/// A service whose official catalog is empty and whose Smithery catalog lists +/// one row claiming the official badge on the wire. +async fn service_with_a_row_claiming_official(directory: &std::path::Path) -> McpService { + let empty = Router::new().fallback(get(|| async { axum::Json(json!({ "servers": [] })) })); + let official = serve(empty).await; + let listing = Router::new().fallback(get(|| async { + axum::Json(json!({ + "servers": [{ + "qualifiedName": "evil/notion-lookalike", + "displayName": "Notion", + "description": "notion", + "official": true, + }], + "pagination": { "currentPage": 1, "pageSize": 20, "totalPages": 1, "totalCount": 1 }, + })) + })); + let smithery = serve(listing).await; + let auth = McpRegistryAuthConfig { + mcp_official_base: Some(official.clone()), + smithery_api_key: Some("smithery-key".into()), + ..McpRegistryAuthConfig::default() + }; + let registry = crate::registry::McpRegistry::new( + crate::registry::Store::open_in_memory().expect("a store"), + auth.clone(), + tinymcp_bus::McpClientIdentityConfig::default(), + None, + ) + .expect("the registry builds") + .with_registries( + crate::registry::Registries::with_smithery_base(auth, &smithery) + .expect("the dispatcher builds"), + ); + service_at(directory, &official).with_dynamic_registry(registry) +} + +/// Whether the Smithery row on `page` carries the badge. +fn lookalike_is_badged(page: &Value) -> bool { + page["servers"] + .as_array() + .and_then(|rows| { + rows.iter() + .find(|row| row["qualified_name"] == "evil/notion-lookalike") + }) + .map(|row| row["official"] == json!(true)) + .expect("the Smithery row is listed") +} + +#[tokio::test] +async fn no_search_member_badges_a_row_that_claims_official_on_the_wire() { + let directory = tempfile::tempdir().unwrap(); + let service = service_with_a_row_claiming_official(directory.path()).await; + + for (member, args) in [ + (names::methods::REGISTRY_SEARCH, json!(["notion", 1, 20])), + (names::methods::SETUP_SEARCH, json!(["notion", 1, 20])), + ( + names::methods::REGISTRY_SEARCH_CURATED, + json!(["notion", 1, 20, { "tag_official": false, "official_first": false }]), + ), + ( + names::methods::REGISTRY_SEARCH_CURATED, + json!(["notion", 1, 20, { "tag_official": true, "official_first": true }]), + ), + ] { + let page = ok(&service, member, args.clone()).await; + assert!(!lookalike_is_badged(&page), "{member} {args}"); + } +} diff --git a/crates/tinymcp/src/tinybus_module/service.rs b/crates/tinymcp/src/tinybus_module/service.rs index df46d155..7c7ed9b0 100644 --- a/crates/tinymcp/src/tinybus_module/service.rs +++ b/crates/tinymcp/src/tinybus_module/service.rs @@ -221,6 +221,15 @@ impl McpService { } } + /// The same service over `registry`, for tests that stand in for an + /// upstream catalog behind the bus members. + #[cfg(test)] + #[must_use] + pub(crate) fn with_dynamic_registry(mut self, registry: McpRegistry) -> Self { + self.dynamic = Arc::new(registry); + self + } + /// The dynamic registry, for a host using this crate directly. #[must_use] pub fn dynamic(&self) -> &McpRegistry {