From 0c51c79b1729ccb40cc18b4a79bea74b49e0a878 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 11 Nov 2026 16:07:06 +0530 Subject: [PATCH] Never take the official badge from an upstream registry row RegistryServerSummary.official is documented as never trusted from the wire, but the Smithery adapter decodes its rows straight from Smithery's JSON and tag_source cleared only website_url and auth_kind. So with a Smithery key configured, a row arriving with "official": true kept the badge on every uncurated search: RegistrySearch and the guided SetupSearch, which both answer through McpRegistry::registry_search. Only RegistrySearchCurated with tag_official re-tagged it. - tag_source clears official, like the other trust signals. - registry_search clears it on every row from every source, so only registry_search_curated can set it, from the module's own list. The field still deserializes: hosts decode the badge the module sets from its replies, so #[serde(skip_deserializing)] would strip it there. Tests run each search path against loopback stand-ins for the official catalog and Smithery: plain search, and curated search with and without tagging. Two test-only seams let the ops tests point Smithery at a loopback server. --- crates/tinymcp/src/registry/ops/mod_tests.rs | 93 +++++++++++++++++++ crates/tinymcp/src/registry/ops/types.rs | 17 +++- .../src/registry/sources/smithery/mod.rs | 5 +- .../registry/sources/smithery/mod_tests.rs | 10 ++ crates/tinymcp/src/registry/sources/types.rs | 9 ++ .../tinymcp/src/tinybus_module/mod_tests.rs | 74 +++++++++++++++ crates/tinymcp/src/tinybus_module/service.rs | 9 ++ 7 files changed, 215 insertions(+), 2 deletions(-) diff --git a/crates/tinymcp/src/registry/ops/mod_tests.rs b/crates/tinymcp/src/registry/ops/mod_tests.rs index 2961f67c..a82b2a0b 100644 --- a/crates/tinymcp/src/registry/ops/mod_tests.rs +++ b/crates/tinymcp/src/registry/ops/mod_tests.rs @@ -1716,3 +1716,96 @@ 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:?}"); + } +} diff --git a/crates/tinymcp/src/registry/ops/types.rs b/crates/tinymcp/src/registry/ops/types.rs index 3e1136aa..64cce568 100644 --- a/crates/tinymcp/src/registry/ops/types.rs +++ b/crates/tinymcp/src/registry/ops/types.rs @@ -70,6 +70,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 + } + /// The same registry starting stdio servers through `stdio`. /// /// Without it every installed stdio server is refused: an installed @@ -163,10 +172,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 c30fa037..d3c61b94 100644 --- a/crates/tinymcp/src/tinybus_module/service.rs +++ b/crates/tinymcp/src/tinybus_module/service.rs @@ -220,6 +220,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 {