Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions crates/tinymcp/src/registry/ops/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
17 changes: 16 additions & 1 deletion crates/tinymcp/src/registry/ops/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`].
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion crates/tinymcp/src/registry/sources/smithery/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ impl SmitheryRegistry {
/// # Errors
///
/// Returns [`Error::ClientBuild`] when the HTTP client cannot be built.
fn with_base(base: impl Into<String>) -> Result<Self> {
pub(in crate::registry) fn with_base(base: impl Into<String>) -> Result<Self> {
let http = reqwest::Client::builder()
.timeout(TIMEOUT)
.build()
Expand Down Expand Up @@ -172,6 +172,9 @@ pub(super) fn tag_source(mut servers: Vec<RegistryServerSummary>) -> Vec<Registr
}
server.website_url = None;
server.auth_kind = None;
// The badge is the module's claim, made only by curation; a row
// arriving with it set from Smithery must not keep it.
server.official = false;
server.extra.remove("website_url");
server.extra.remove("auth_kind");
}
Expand Down
10 changes: 10 additions & 0 deletions crates/tinymcp/src/registry/sources/smithery/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -541,3 +541,13 @@ fn a_row_that_already_names_its_source_keeps_it() {

assert_eq!(tag_source(vec![server])[0].source, "somewhere_else");
}

#[test]
fn a_row_claiming_the_official_badge_arrives_without_it() {
let mut claimed = bare_summary();
claimed.official = true;

let tagged = tag_source(vec![claimed]);

assert!(!tagged[0].official);
}
9 changes: 9 additions & 0 deletions crates/tinymcp/src/registry/sources/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,15 @@ impl Registries {
})
}

/// The dispatcher over a Smithery adapter pointed at `base`, for tests that
/// stand in for Smithery with a loopback server.
#[cfg(test)]
pub(crate) fn with_smithery_base(auth: McpRegistryAuthConfig, base: &str) -> Result<Self> {
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.
///
Expand Down
74 changes: 74 additions & 0 deletions crates/tinymcp/src/tinybus_module/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
}
}
9 changes: 9 additions & 0 deletions crates/tinymcp/src/tinybus_module/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading