diff --git a/crates/tinymcp-bus/README.md b/crates/tinymcp-bus/README.md index 0c751dba..8f60ac7c 100644 --- a/crates/tinymcp-bus/README.md +++ b/crates/tinymcp-bus/README.md @@ -98,6 +98,13 @@ and metadata addresses. The exception is a server whose URL has an origin the operator listed in `local_oauth_origins`, such as a loopback development server; each exemption is logged. Token refreshes on connect go through the same guard. +Contract 1.31 adds `McpRegistryAuthConfig::curated_servers` +(`CuratedServerEntry`): curated servers a host adds at runtime, merged with +the module's built-in list. They are listed, searched and described like the +built-in ones but never badged official or promoted, and one naming a built-in +replaces its endpoint and takes its badge away. Empty by default and omitted +from the wire when empty. + Library consumers bring implementation extension traits into scope for moved DTO methods. Contract-only hosts call the compiled module. See [the relocation specification](../../docs/specs/pure-vocabulary.md) for public diff --git a/crates/tinymcp-bus/src/config/mod.rs b/crates/tinymcp-bus/src/config/mod.rs index d00624e4..9bf2b77d 100644 --- a/crates/tinymcp-bus/src/config/mod.rs +++ b/crates/tinymcp-bus/src/config/mod.rs @@ -28,10 +28,10 @@ mod types; pub use types::{ - ConfiguredToolCallRequest, HttpHeader, McpAuthConfig, McpClientConfig, McpClientIdentityConfig, - McpProxyConfig, McpRegistryAuthConfig, McpServerConfig, OpenConfiguredRequest, - OpenConfiguredResponse, StaticServerSummary, StdioSandboxConfig, StdioSandboxEnforcement, - TransitionStatus, + ConfiguredToolCallRequest, CuratedEntryAuth, CuratedEntryTransport, CuratedServerEntry, + HttpHeader, McpAuthConfig, McpClientConfig, McpClientIdentityConfig, McpProxyConfig, + McpRegistryAuthConfig, McpServerConfig, OpenConfiguredRequest, OpenConfiguredResponse, + StaticServerSummary, StdioSandboxConfig, StdioSandboxEnforcement, TransitionStatus, }; #[cfg(test)] diff --git a/crates/tinymcp-bus/src/config/mod_tests.rs b/crates/tinymcp-bus/src/config/mod_tests.rs index 23d22e5d..bf252b47 100644 --- a/crates/tinymcp-bus/src/config/mod_tests.rs +++ b/crates/tinymcp-bus/src/config/mod_tests.rs @@ -3,9 +3,9 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use super::{ - ConfiguredToolCallRequest, McpAuthConfig, McpClientConfig, McpServerConfig, - OpenConfiguredRequest, OpenConfiguredResponse, StdioSandboxConfig, StdioSandboxEnforcement, - TransitionStatus, + ConfiguredToolCallRequest, CuratedEntryAuth, CuratedEntryTransport, CuratedServerEntry, + McpAuthConfig, McpClientConfig, McpRegistryAuthConfig, McpServerConfig, OpenConfiguredRequest, + OpenConfiguredResponse, StdioSandboxConfig, StdioSandboxEnforcement, TransitionStatus, }; use serde_json::json; @@ -173,3 +173,58 @@ fn the_stdio_sandbox_defaults_to_net_subprocess_and_the_isolation_floor() { serde_json::from_value(json!({ "command": "npx", "operator_trusted": true })).unwrap(); assert!(trusted.operator_trusted); } + +#[test] +fn runtime_curated_servers_have_a_pinned_wire_form() { + let wire = json!({ + "curated_servers": [{ + "qualified_name": "com.slack/mcp", + "display_name": "Slack", + "description": "Slack's hosted MCP server", + "remote_url": "https://mcp.slack.com/mcp", + "transport": "streamable_http", + "auth": "oauth_preregistered", + }] + }); + let auth: McpRegistryAuthConfig = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!( + auth.curated_servers, + [CuratedServerEntry { + qualified_name: "com.slack/mcp".into(), + display_name: "Slack".into(), + description: "Slack's hosted MCP server".into(), + remote_url: "https://mcp.slack.com/mcp".into(), + transport: CuratedEntryTransport::StreamableHttp, + auth: CuratedEntryAuth::OauthPreregistered, + icon_url: None, + }] + ); + // Only the new member is pinned here; the older optional fields keep their + // existing wire form (`null` when unset). + assert_eq!( + serde_json::to_value(&auth).unwrap()["curated_servers"], + wire["curated_servers"] + ); + // Absent by default, and left off the wire when empty. + let empty = McpRegistryAuthConfig::default(); + assert_eq!(empty.curated_servers, Vec::new()); + assert!( + serde_json::to_value(&empty) + .unwrap() + .get("curated_servers") + .is_none() + ); +} + +#[test] +fn a_misspelled_curated_server_key_is_refused() { + let wire = json!({ + "qualified_name": "com.slack/mcp", + "display_name": "Slack", + "remote_url": "https://mcp.slack.com/mcp", + "transport": "sse", + "auth": "none", + "icon": "https://example.com/slack.png", + }); + assert!(serde_json::from_value::(wire).is_err()); +} diff --git a/crates/tinymcp-bus/src/config/types.rs b/crates/tinymcp-bus/src/config/types.rs index 89ec8064..04f708b8 100644 --- a/crates/tinymcp-bus/src/config/types.rs +++ b/crates/tinymcp-bus/src/config/types.rs @@ -506,6 +506,69 @@ pub struct McpRegistryAuthConfig { /// `MCP_OFFICIAL_REGISTRY_TOKEN`. #[serde(default)] pub mcp_official_token: Option, + /// Curated servers the host adds at runtime, merged with the built-in + /// list. Not a secret. + /// + /// They are listed, searched and described like the built-in ones, but + /// never badged official or promoted: the badge is a claim about software + /// someone vetted, and a configuration line must not be able to make an + /// arbitrary endpoint look like that. An entry naming a built-in server + /// replaces its endpoint and takes its badge away. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub curated_servers: Vec, +} + +/// A curated server supplied by the host at runtime. +/// +/// The owned twin of the module's compiled-in `CuratedServer`. Unknown fields +/// are refused, so a misspelled key fails loudly instead of loading an entry +/// with a default in its place. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(deny_unknown_fields)] +pub struct CuratedServerEntry { + /// The exact registry qualified name. + pub qualified_name: String, + /// The vendor's name for it. + pub display_name: String, + /// A short description. + #[serde(default)] + pub description: String, + /// The vendor-hosted endpoint; `https` only. + pub remote_url: String, + /// How the endpoint speaks MCP. + pub transport: CuratedEntryTransport, + /// How the endpoint authenticates a client. + pub auth: CuratedEntryAuth, + /// An icon for it; `https` only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon_url: Option, +} + +/// How a runtime curated server's endpoint speaks MCP. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum CuratedEntryTransport { + /// Streamable HTTP. + StreamableHttp, + /// Server-sent events. + Sse, +} + +/// How a runtime curated server's endpoint authenticates a client. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum CuratedEntryAuth { + /// OAuth, with dynamic client registration. + Oauth, + /// OAuth for clients the vendor registered in advance only. + OauthPreregistered, + /// A static token sent in the `Authorization` header. + Token, + /// No authentication. + None, } /// A proxy for outbound HTTP, already resolved by the host. diff --git a/crates/tinymcp-bus/src/lib.rs b/crates/tinymcp-bus/src/lib.rs index a3314b06..2ffaca9c 100644 --- a/crates/tinymcp-bus/src/lib.rs +++ b/crates/tinymcp-bus/src/lib.rs @@ -87,10 +87,10 @@ pub use audit::{ }; pub use auth::{AuthDetection, AuthKind}; pub use config::{ - ConfiguredToolCallRequest, HttpHeader, McpAuthConfig, McpClientConfig, McpClientIdentityConfig, - McpProxyConfig, McpRegistryAuthConfig, McpServerConfig, OpenConfiguredRequest, - OpenConfiguredResponse, StaticServerSummary, StdioSandboxConfig, StdioSandboxEnforcement, - TransitionStatus, + ConfiguredToolCallRequest, CuratedEntryAuth, CuratedEntryTransport, CuratedServerEntry, + HttpHeader, McpAuthConfig, McpClientConfig, McpClientIdentityConfig, McpProxyConfig, + McpRegistryAuthConfig, McpServerConfig, OpenConfiguredRequest, OpenConfiguredResponse, + StaticServerSummary, StdioSandboxConfig, StdioSandboxEnforcement, TransitionStatus, }; pub use config_document::{AppliedServer, ConfigApplyReport, ConfigApplyResponse}; pub use method::{ diff --git a/crates/tinymcp-bus/src/version/mod.rs b/crates/tinymcp-bus/src/version/mod.rs index 372aba4d..9fb24460 100644 --- a/crates/tinymcp-bus/src/version/mod.rs +++ b/crates/tinymcp-bus/src/version/mod.rs @@ -47,7 +47,9 @@ /// 1.30 adds `McpClientConfig::local_oauth_origins` and turns the OAuth /// endpoint guard on by default: endpoints a server advertises must be `https` /// and public unless the operator declared that server local. -pub const CONTRACT_VERSION: (u32, u32) = (1, 30); +/// 1.31 adds `McpRegistryAuthConfig::curated_servers` (`CuratedServerEntry`): +/// host-supplied curated servers, never badged official. +pub const CONTRACT_VERSION: (u32, u32) = (1, 31); /// Returns whether a host holding [`CONTRACT_VERSION`] can bind to a module /// reporting `module`. diff --git a/crates/tinymcp-bus/src/version/mod_tests.rs b/crates/tinymcp-bus/src/version/mod_tests.rs index 44d5d1bc..ea4c5fdc 100644 --- a/crates/tinymcp-bus/src/version/mod_tests.rs +++ b/crates/tinymcp-bus/src/version/mod_tests.rs @@ -4,7 +4,7 @@ use super::{CONTRACT_VERSION, binds, is_compatible}; #[test] fn the_shipped_contract_version_is_pinned() { - assert_eq!(CONTRACT_VERSION, (1, 30)); + assert_eq!(CONTRACT_VERSION, (1, 31)); } #[test] diff --git a/crates/tinymcp/src/registry/config_redaction.rs b/crates/tinymcp/src/registry/config_redaction.rs index 111c20d1..e2e95991 100644 --- a/crates/tinymcp/src/registry/config_redaction.rs +++ b/crates/tinymcp/src/registry/config_redaction.rs @@ -50,6 +50,9 @@ impl McpRegistryAuthConfigExt for McpRegistryAuthConfig { smithery_api_key: None, mcp_official_base: self.mcp_official_base.clone(), mcp_official_token: None, + // Endpoints and names, not secrets: validation refuses a URL + // carrying credentials. + curated_servers: self.curated_servers.clone(), }, smithery_set, official_set, diff --git a/crates/tinymcp/src/registry/config_tests.rs b/crates/tinymcp/src/registry/config_tests.rs index 46eea64f..0864dac9 100644 --- a/crates/tinymcp/src/registry/config_tests.rs +++ b/crates/tinymcp/src/registry/config_tests.rs @@ -264,6 +264,7 @@ fn redacting_registry_auth_drops_both_secrets_and_reports_that_they_were_set() { smithery_api_key: Some("smithery-secret".into()), mcp_official_base: Some("https://registry.test".into()), mcp_official_token: Some("official-secret".into()), + ..McpRegistryAuthConfig::default() }; let (redacted, smithery_set, official_set) = auth.redacted(); @@ -294,6 +295,7 @@ fn a_redacted_registry_auth_never_serializes_a_secret() { smithery_api_key: Some("smithery-secret".into()), mcp_official_base: None, mcp_official_token: Some("official-secret".into()), + ..McpRegistryAuthConfig::default() }; let (redacted, _, _) = auth.redacted(); let encoded = serde_json::to_string(&redacted).expect("redacted auth serializes"); diff --git a/crates/tinymcp/src/registry/curation/catalog.rs b/crates/tinymcp/src/registry/curation/catalog.rs new file mode 100644 index 00000000..1b25b279 --- /dev/null +++ b/crates/tinymcp/src/registry/curation/catalog.rs @@ -0,0 +1,251 @@ +//! The curated list in force: the built-in servers plus the host's runtime +//! entries. +//! +//! A host adds curated servers through +//! [`McpRegistryAuthConfig::curated_servers`]. They are checked once, when the +//! registry dispatcher takes the settings ([`validated_runtime_entries`]), and +//! merged per call into a [`CuratedCatalog`]. +//! +//! # Runtime entries are never promoted +//! +//! Only a built-in server nobody replaced is badged official, leads a first +//! search page, or ranks as curated. A runtime entry is listed, searched and +//! described like the rest, but a configuration line must not be able to make +//! an arbitrary endpoint look vetted. An entry naming a built-in server +//! replaces its endpoint and takes the badge away with it. + +use std::collections::HashSet; + +use serde_json::json; +use tinymcp_bus::{ + CuratedEntryAuth, CuratedEntryTransport, CuratedServerEntry, ExtraFields, + McpRegistryAuthConfig, RegistryConnection, RegistryServerDetail, RegistryServerSummary, +}; + +use super::types::{CURATED_SERVERS, CuratedAuth, CuratedServer, CuratedTransport, curated_server}; +use crate::registry::sources::SOURCE_MCP_OFFICIAL; + +/// The value `auth_kind` takes for a server declaring a static credential. +const AUTH_KIND_API_KEY: &str = "api_key"; + +/// The built-in curated servers merged with a host's runtime entries. +#[derive(Debug, Clone)] +pub struct CuratedCatalog { + entries: Vec, + promoted: HashSet, +} + +impl CuratedCatalog { + /// Merges `runtime` (already validated) over the built-in list. + /// + /// The built-in servers come first, in their order, minus any a runtime + /// entry replaces; the runtime entries follow in theirs. + #[must_use] + pub fn new(runtime: &[CuratedServerEntry]) -> Self { + let replaced: HashSet<&str> = runtime + .iter() + .map(|entry| entry.qualified_name.as_str()) + .collect(); + let mut entries: Vec = CURATED_SERVERS + .iter() + .filter(|server| !replaced.contains(server.qualified_name)) + .map(CuratedServer::to_entry) + .collect(); + let promoted = entries + .iter() + .map(|entry| entry.qualified_name.clone()) + .collect(); + entries.extend(runtime.iter().cloned()); + Self { entries, promoted } + } + + /// The catalog in force for `auth`. + #[must_use] + pub fn for_settings(auth: &McpRegistryAuthConfig) -> Self { + Self::new(&auth.curated_servers) + } + + /// Every curated entry, built-in first. + #[must_use] + pub fn entries(&self) -> &[CuratedServerEntry] { + &self.entries + } + + /// The entry for `qualified_name`, by exact match. + #[must_use] + pub fn get(&self, qualified_name: &str) -> Option<&CuratedServerEntry> { + self.entries + .iter() + .find(|entry| entry.qualified_name == qualified_name) + } + + /// Whether `qualified_name` is a built-in server no runtime entry replaced: + /// the only kind that is badged official and promoted. + #[must_use] + pub fn is_promoted(&self, qualified_name: &str) -> bool { + self.promoted.contains(qualified_name) + } + + /// The entries [`Self::is_promoted`] accepts, in order. + pub fn promoted(&self) -> impl Iterator { + self.entries + .iter() + .filter(|entry| self.is_promoted(&entry.qualified_name)) + } + + /// Sets the badge on promoted rows and clears it on every other row, as + /// [`super::tag_official`] does for the built-in list alone. + pub fn tag_official(&self, servers: &mut [RegistryServerSummary]) { + for server in servers.iter_mut() { + server.official = self.is_promoted(&server.qualified_name); + } + } +} + +/// The runtime entries worth merging: each checked, the bad ones dropped. +/// +/// An entry is dropped, with a warning naming it and the reason, when its +/// `remote_url` or `icon_url` is not `https` with a host and no credentials, +/// its `qualified_name` or `display_name` is blank, or an earlier entry has the +/// same name. The rest load, so one bad line does not cost the whole catalog. +/// An entry naming a built-in server is kept and logged, since it takes the +/// badge away. +#[must_use] +pub fn validated_runtime_entries(entries: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut kept = Vec::with_capacity(entries.len()); + for entry in entries { + if let Err(reason) = check_entry(&entry, &seen) { + tracing::warn!( + qualified_name = %entry.qualified_name, + reason, + "dropping a runtime curated server" + ); + continue; + } + if let Some(built_in) = curated_server(&entry.qualified_name) { + tracing::warn!( + qualified_name = %entry.qualified_name, + built_in_url = built_in.remote_url, + runtime_url = %entry.remote_url, + "a runtime curated server replaces a built-in one and is not badged official" + ); + } + seen.insert(entry.qualified_name.clone()); + kept.push(entry); + } + kept +} + +/// Why `entry` cannot be merged, if it cannot. +fn check_entry(entry: &CuratedServerEntry, seen: &HashSet) -> Result<(), &'static str> { + if entry.qualified_name.trim().is_empty() { + return Err("the qualified name is blank"); + } + if entry.display_name.trim().is_empty() { + return Err("the display name is blank"); + } + if seen.contains(&entry.qualified_name) { + return Err("an earlier runtime entry has the same qualified name"); + } + if !is_plain_https(&entry.remote_url) { + return Err("the remote url is not https with a host and no credentials"); + } + if entry + .icon_url + .as_deref() + .is_some_and(|icon| !is_plain_https(icon)) + { + return Err("the icon url is not https with a host and no credentials"); + } + Ok(()) +} + +/// Whether `raw` is an `https` URL with a host and no embedded credentials. +fn is_plain_https(raw: &str) -> bool { + url::Url::parse(raw).is_ok_and(|url| { + url.scheme() == "https" + && url.host_str().is_some_and(|host| !host.is_empty()) + && url.username().is_empty() + && url.password().is_none() + }) +} + +/// `entry` as a catalog row from the official registry, never badged. +#[must_use] +pub fn entry_summary(entry: &CuratedServerEntry) -> RegistryServerSummary { + RegistryServerSummary { + qualified_name: entry.qualified_name.clone(), + display_name: entry.display_name.clone(), + description: Some(entry.description.clone()), + icon_url: entry.icon_url.clone(), + use_count: 0, + is_deployed: true, + source: SOURCE_MCP_OFFICIAL.to_string(), + official: false, + website_url: None, + auth_kind: (entry.auth == CuratedEntryAuth::Token).then(|| AUTH_KIND_API_KEY.to_string()), + extra: ExtraFields::new(), + } +} + +/// `entry` as a catalog detail record with its one hosted connection. +#[must_use] +pub fn entry_detail(entry: &CuratedServerEntry) -> RegistryServerDetail { + let config_schema = (entry.auth == CuratedEntryAuth::Token).then(|| { + json!({ + "properties": { "Authorization": { "x-secret": true } }, + "required": ["Authorization"], + }) + }); + let connection_type = match entry.transport { + CuratedEntryTransport::StreamableHttp => CuratedTransport::StreamableHttp, + CuratedEntryTransport::Sse => CuratedTransport::Sse, + } + .connection_type(); + + RegistryServerDetail { + qualified_name: entry.qualified_name.clone(), + display_name: entry.display_name.clone(), + description: Some(entry.description.clone()), + icon_url: entry.icon_url.clone(), + connections: vec![RegistryConnection { + r#type: connection_type.to_string(), + deployment_url: Some(entry.remote_url.clone()), + config_schema, + example_config: None, + published: true, + extra: ExtraFields::new(), + }], + source: SOURCE_MCP_OFFICIAL.to_string(), + extra: ExtraFields::new(), + } +} + +impl CuratedServer { + /// This built-in server as a runtime-shaped entry. + #[must_use] + pub fn to_entry(&self) -> CuratedServerEntry { + CuratedServerEntry { + qualified_name: self.qualified_name.to_string(), + display_name: self.display_name.to_string(), + description: self.description.to_string(), + remote_url: self.remote_url.to_string(), + transport: match self.transport { + CuratedTransport::StreamableHttp => CuratedEntryTransport::StreamableHttp, + CuratedTransport::Sse => CuratedEntryTransport::Sse, + }, + auth: match self.auth { + CuratedAuth::Oauth => CuratedEntryAuth::Oauth, + CuratedAuth::OauthPreregistered => CuratedEntryAuth::OauthPreregistered, + CuratedAuth::Token => CuratedEntryAuth::Token, + CuratedAuth::None => CuratedEntryAuth::None, + }, + icon_url: self.icon_url.map(ToString::to_string), + } + } +} + +#[cfg(test)] +#[path = "catalog_tests.rs"] +mod tests; diff --git a/crates/tinymcp/src/registry/curation/catalog_tests.rs b/crates/tinymcp/src/registry/curation/catalog_tests.rs new file mode 100644 index 00000000..d0abedd5 --- /dev/null +++ b/crates/tinymcp/src/registry/curation/catalog_tests.rs @@ -0,0 +1,162 @@ +//! Unit tests for the runtime curated catalog. +//! +//! Most of them are about what a runtime entry must *not* get: the official +//! badge and the promotions that go with it. A configuration line is not a +//! vetting. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +/// A valid runtime entry named `name`. +fn entry(name: &str) -> CuratedServerEntry { + CuratedServerEntry { + qualified_name: name.into(), + display_name: "Swiggy Food".into(), + description: "Order food".into(), + remote_url: "https://mcp.swiggy.com/food".into(), + transport: CuratedEntryTransport::StreamableHttp, + auth: CuratedEntryAuth::Oauth, + icon_url: None, + } +} + +/// A row as an upstream would send it. +fn row(name: &str) -> RegistryServerSummary { + serde_json::from_value(json!({ "qualified_name": name, "display_name": name })).unwrap() +} + +#[test] +fn with_no_runtime_entries_the_catalog_is_the_built_in_list() { + let catalog = CuratedCatalog::new(&[]); + + assert_eq!(catalog.entries().len(), CURATED_SERVERS.len()); + for server in CURATED_SERVERS { + assert!(catalog.is_promoted(server.qualified_name)); + assert_eq!(catalog.get(server.qualified_name), Some(&server.to_entry())); + } +} + +#[test] +fn a_runtime_entry_is_listed_but_never_badged_or_promoted() { + let catalog = CuratedCatalog::new(&[entry("com.example/added")]); + + assert_eq!(catalog.entries().last(), Some(&entry("com.example/added"))); + assert!(catalog.get("com.example/added").is_some()); + assert!(!catalog.is_promoted("com.example/added")); + assert!( + catalog + .promoted() + .all(|promoted| promoted.qualified_name != "com.example/added") + ); + + let mut rows = vec![row("com.example/added"), row("com.notion/mcp")]; + rows[0].official = true; + catalog.tag_official(&mut rows); + assert!(!rows[0].official, "a runtime entry must not keep a badge"); + assert!(rows[1].official); +} + +#[test] +fn a_runtime_entry_naming_a_built_in_replaces_it_and_takes_its_badge() { + let mut moved = entry("com.notion/mcp"); + moved.remote_url = "https://notion.example/mcp".into(); + let catalog = CuratedCatalog::new(&[moved.clone()]); + + assert_eq!(catalog.get("com.notion/mcp"), Some(&moved)); + assert_eq!( + catalog + .entries() + .iter() + .filter(|curated| curated.qualified_name == "com.notion/mcp") + .count(), + 1 + ); + assert!(!catalog.is_promoted("com.notion/mcp")); + assert_eq!(catalog.entries().len(), CURATED_SERVERS.len()); + + let mut rows = vec![row("com.notion/mcp"), row("com.stripe/mcp")]; + catalog.tag_official(&mut rows); + assert!(!rows[0].official, "a replaced built-in loses its badge"); + assert!(rows[1].official, "the other built-ins keep theirs"); + + // The replacement is still a valid entry, so validation keeps it. + assert_eq!(validated_runtime_entries(vec![moved.clone()]), [moved]); +} + +#[test] +fn validation_drops_each_bad_entry_and_keeps_the_rest() { + let good = entry("com.example/good"); + let mut plain_http = entry("com.example/http"); + plain_http.remote_url = "http://mcp.example/".into(); + let mut credentials = entry("com.example/creds"); + credentials.remote_url = "https://user:pass@mcp.example/".into(); + let mut no_host = entry("com.example/nohost"); + no_host.remote_url = "https://".into(); + let mut bad_icon = entry("com.example/icon"); + bad_icon.icon_url = Some("http://cdn.example/icon.png".into()); + let mut blank_name = entry(" "); + blank_name.qualified_name = " ".into(); + let mut blank_display = entry("com.example/display"); + blank_display.display_name = String::new(); + let duplicate = entry("com.example/good"); + let mut good_icon = entry("com.example/with-icon"); + good_icon.icon_url = Some("https://cdn.example/icon.png".into()); + + let kept = validated_runtime_entries(vec![ + good.clone(), + plain_http, + credentials, + no_host, + bad_icon, + blank_name, + blank_display, + duplicate, + good_icon.clone(), + ]); + + assert_eq!(kept, [good, good_icon]); +} + +#[test] +fn a_runtime_entry_becomes_an_unbadged_row_and_a_hosted_detail() { + let mut token = entry("com.example/token"); + token.auth = CuratedEntryAuth::Token; + token.transport = CuratedEntryTransport::Sse; + token.icon_url = Some("https://cdn.example/icon.png".into()); + + let summary = entry_summary(&token); + assert_eq!(summary.qualified_name, "com.example/token"); + assert!(!summary.official); + assert_eq!(summary.auth_kind.as_deref(), Some("api_key")); + assert_eq!( + summary.icon_url.as_deref(), + Some("https://cdn.example/icon.png") + ); + assert_eq!(summary.source, SOURCE_MCP_OFFICIAL); + + let detail = entry_detail(&token); + assert_eq!(detail.connections.len(), 1); + assert_eq!(detail.connections[0].r#type, "sse"); + assert_eq!( + detail.connections[0].deployment_url.as_deref(), + Some("https://mcp.swiggy.com/food") + ); + assert!(detail.connections[0].config_schema.is_some()); + + let open = entry_summary(&entry("com.example/open")); + assert_eq!(open.auth_kind, None); + assert!( + entry_detail(&entry("com.example/open")).connections[0] + .config_schema + .is_none() + ); +} + +#[test] +fn a_built_in_server_converts_to_the_same_rows_either_way() { + for server in CURATED_SERVERS { + assert_eq!(entry_summary(&server.to_entry()), server.to_summary()); + assert_eq!(entry_detail(&server.to_entry()), server.to_detail()); + } +} diff --git a/crates/tinymcp/src/registry/curation/mod.rs b/crates/tinymcp/src/registry/curation/mod.rs index 488ee408..54d2d434 100644 --- a/crates/tinymcp/src/registry/curation/mod.rs +++ b/crates/tinymcp/src/registry/curation/mod.rs @@ -14,6 +14,13 @@ //! install reach it when the registry cannot be asked. [`OFFICIAL_SERVERS`] is //! the same list as bare names. //! +//! # A host can add curated servers at runtime +//! +//! [`CuratedCatalog`] merges the host's +//! [`tinymcp_bus::McpRegistryAuthConfig::curated_servers`] over the built-in +//! list. Runtime entries are listed, searched and described, but never badged +//! or promoted; see the `catalog` module. +//! //! # Matching is exact, never a substring //! //! A term like `stripe` or `github` appears in the name of plenty of unrelated @@ -29,9 +36,11 @@ //! emitting those keys could otherwise filter itself into a catalog that //! promises the user a server is safely installable. +mod catalog; mod servers; mod types; +pub use catalog::{CuratedCatalog, entry_detail, entry_summary, validated_runtime_entries}; pub use types::{ CURATED_SERVERS, CuratedAuth, CuratedServer, CuratedTransport, OFFICIAL_SERVERS, curated_server, float_official_first, is_perfect_server, retain_perfect_servers, tag_official, diff --git a/crates/tinymcp/src/registry/ops/mod_tests.rs b/crates/tinymcp/src/registry/ops/mod_tests.rs index dc7cfb0b..ba71c862 100644 --- a/crates/tinymcp/src/registry/ops/mod_tests.rs +++ b/crates/tinymcp/src/registry/ops/mod_tests.rs @@ -647,6 +647,7 @@ fn registry_settings_report_whether_credentials_are_set_and_never_their_values() smithery_api_key: Some("smithery-secret".into()), mcp_official_token: Some("official-secret".into()), mcp_official_base: Some("https://registry.test".into()), + ..McpRegistryAuthConfig::default() }, McpClientIdentityConfig::default(), None, diff --git a/crates/tinymcp/src/registry/ops/types.rs b/crates/tinymcp/src/registry/ops/types.rs index 2f084ed1..1625381a 100644 --- a/crates/tinymcp/src/registry/ops/types.rs +++ b/crates/tinymcp/src/registry/ops/types.rs @@ -233,7 +233,11 @@ impl McpRegistry { let mut results = self.registry_search(query, page, page_size).await?; if curation.tag_official { - curation::tag_official(&mut results.servers); + // The catalog's tagging, not the built-in list's: a runtime entry + // naming a built-in server takes its badge away. + self.registries + .curated_catalog() + .tag_official(&mut results.servers); } if curation.official_first { curation::float_official_first(&mut results.servers); diff --git a/crates/tinymcp/src/registry/sources/official/fallback.rs b/crates/tinymcp/src/registry/sources/official/fallback.rs index 48eab078..20dcffcc 100644 --- a/crates/tinymcp/src/registry/sources/official/fallback.rs +++ b/crates/tinymcp/src/registry/sources/official/fallback.rs @@ -18,7 +18,7 @@ use super::types::{OfficialListResponse, OfficialServer}; use super::{BROWSE_CACHE_PREFIX, CursorCache, DETAIL_CACHE_PREFIX, search_cache_key, served}; use crate::error::Error; use crate::registry::Store; -use crate::registry::curation::{CURATED_SERVERS, CuratedServer, curated_server}; +use crate::registry::curation::{CuratedCatalog, entry_summary}; use crate::registry::sources::types::{RegistryOperation, SourcePage}; use tinymcp_bus::{RegistryFreshness, RegistryServerSummary}; @@ -77,6 +77,7 @@ impl Cooldown { /// What can stand in for a listing the registry could not answer. pub(super) fn serve_cached( store: &Store, + catalog: &CuratedCatalog, cursors: &CursorCache, query: &str, page: u32, @@ -89,7 +90,7 @@ pub(super) fn serve_cached( return None; } - let servers = local_matches(store, query, page_size); + let servers = local_matches(store, catalog, query, page_size); tracing::debug!( query_length = query.len(), matches = servers.len(), @@ -133,7 +134,12 @@ fn stale_page( /// Catalog page rows come before detail rows, and a server appears once. A row /// matches when every word of the query appears in its name, title, or /// description, ignoring case. Rows matching on name or title come first. -fn local_matches(store: &Store, query: &str, limit: u32) -> Vec { +fn local_matches( + store: &Store, + catalog: &CuratedCatalog, + query: &str, + limit: u32, +) -> Vec { let terms: Vec = query.split_whitespace().map(str::to_lowercase).collect(); if terms.is_empty() { return Vec::new(); @@ -155,7 +161,7 @@ fn local_matches(store: &Store, query: &str, limit: u32) -> Vec = page_rows @@ -177,8 +183,7 @@ fn local_matches(store: &Store, query: &str, limit: u32) -> Vec bool { pub(super) fn search( store: &Store, base: &str, + catalog: &CuratedCatalog, query: &str, page: u32, page_size: u32, @@ -305,11 +306,12 @@ pub(super) fn search( .map(|(_, row)| row.qualified_name.clone()) .collect(); matches.extend( - CURATED_SERVERS + catalog + .entries() .iter() - .filter(|curated| !indexed.contains(curated.qualified_name)) + .filter(|curated| !indexed.contains(&curated.qualified_name)) .filter_map(|curated| { - let row = curated.to_summary(); + let row = entry_summary(curated); let label = search_label(&row.qualified_name, &row.display_name); let description = curated.description.to_lowercase(); let in_label = terms.iter().all(|term| label.contains(term.as_str())); @@ -320,7 +322,7 @@ pub(super) fn search( }), ); matches.sort_by(|(left_label, left), (right_label, right)| { - rank(*left_label, left, *right_label, right) + rank(catalog, *left_label, left, *right_label, right) }); let size = usize::try_from(page_size.max(1)).unwrap_or(usize::MAX); @@ -348,15 +350,16 @@ pub(super) fn search( }) } -/// The order of two matches: curated first, then name or title matches, then -/// alphabetical by display name and qualified name. +/// The order of two matches: promoted curated servers first, then name or title +/// matches, then alphabetical by display name and qualified name. fn rank( + catalog: &CuratedCatalog, left_label: bool, left: &RegistryServerSummary, right_label: bool, right: &RegistryServerSummary, ) -> Order { - let curated = |row: &RegistryServerSummary| curated_server(&row.qualified_name).is_none(); + let curated = |row: &RegistryServerSummary| !catalog.is_promoted(&row.qualified_name); curated(left) .cmp(&curated(right)) diff --git a/crates/tinymcp/src/registry/sources/official/index_tests.rs b/crates/tinymcp/src/registry/sources/official/index_tests.rs index 8b691039..33ee40a5 100644 --- a/crates/tinymcp/src/registry/sources/official/index_tests.rs +++ b/crates/tinymcp/src/registry/sources/official/index_tests.rs @@ -8,6 +8,7 @@ use std::sync::atomic::AtomicUsize; use std::time::Duration; +use crate::registry::curation::CuratedCatalog; use axum::Router; use axum::extract::State; use axum::http::Uri; @@ -269,7 +270,7 @@ async fn a_sync_keeps_one_row_per_server_preferring_the_latest_version() { synced(&base, &store).await; assert_eq!(indexed_names(&store), ["io.example/versioned"]); - let page = search(&store, &base, "versioned", 1, 20).unwrap(); + let page = search(&store, &base, &CuratedCatalog::new(&[]), "versioned", 1, 20).unwrap(); assert_eq!(page.servers[0].display_name, "New"); } @@ -639,21 +640,41 @@ async fn an_index_that_has_not_finished_a_sync_is_not_searched() { .begin_index_sync(INDEX_SOURCE, "https://registry.test") .unwrap(); - assert!(search(&store, "https://registry.test", "slack", 1, 20).is_none()); + assert!( + search( + &store, + "https://registry.test", + &CuratedCatalog::new(&[]), + "slack", + 1, + 20 + ) + .is_none() + ); } #[tokio::test] async fn an_index_of_a_different_catalog_is_not_searched() { let (_base, _upstream, store) = indexed(vec![envelope("io.example/a", "A", "")]).await; - assert!(search(&store, "https://elsewhere.test", "a", 1, 20).is_none()); + assert!( + search( + &store, + "https://elsewhere.test", + &CuratedCatalog::new(&[]), + "a", + 1, + 20 + ) + .is_none() + ); } #[tokio::test] async fn a_query_with_no_words_is_not_searched_locally() { let (base, _upstream, store) = indexed(vec![envelope("io.example/a", "A", "")]).await; - assert!(search(&store, &base, " ", 1, 20).is_none()); + assert!(search(&store, &base, &CuratedCatalog::new(&[]), " ", 1, 20).is_none()); } #[tokio::test] @@ -666,7 +687,7 @@ async fn a_search_ranks_curated_then_name_then_description_then_alphabetical() { ]) .await; - let page = search(&store, &base, "Slack", 1, 20).unwrap(); + let page = search(&store, &base, &CuratedCatalog::new(&[]), "Slack", 1, 20).unwrap(); assert_eq!(page.freshness, RegistryFreshness::Indexed); assert_eq!( @@ -689,7 +710,15 @@ async fn every_word_of_the_query_must_match() { ]) .await; - let page = search(&store, &base, "slack posts", 1, 20).unwrap(); + let page = search( + &store, + &base, + &CuratedCatalog::new(&[]), + "slack posts", + 1, + 20, + ) + .unwrap(); assert_eq!(page_names(&page), ["io.example/zeta"]); } @@ -702,7 +731,7 @@ async fn a_curated_server_the_index_holds_appears_once_with_the_registry_record( ]) .await; - let page = search(&store, &base, "notion", 1, 20).unwrap(); + let page = search(&store, &base, &CuratedCatalog::new(&[]), "notion", 1, 20).unwrap(); assert_eq!( page_names(&page), @@ -718,7 +747,15 @@ async fn a_curated_server_the_index_holds_appears_once_with_the_registry_record( async fn a_curated_server_absent_from_the_index_matches_on_its_description() { let (base, _upstream, store) = indexed(vec![envelope("io.example/a", "A", "")]).await; - let page = search(&store, &base, "jira confluence", 1, 20).unwrap(); + let page = search( + &store, + &base, + &CuratedCatalog::new(&[]), + "jira confluence", + 1, + 20, + ) + .unwrap(); assert_eq!(page_names(&page), ["com.atlassian/atlassian-mcp-server"]); assert_eq!(page.servers[0].source, SOURCE_MCP_OFFICIAL); @@ -737,9 +774,9 @@ async fn a_search_pages_through_its_matches() { .collect(); let (base, _upstream, store) = indexed(servers).await; - let first = search(&store, &base, "widget", 1, 2).unwrap(); - let last = search(&store, &base, "widget", 3, 2).unwrap(); - let beyond = search(&store, &base, "widget", 4, 2).unwrap(); + let first = search(&store, &base, &CuratedCatalog::new(&[]), "widget", 1, 2).unwrap(); + let last = search(&store, &base, &CuratedCatalog::new(&[]), "widget", 3, 2).unwrap(); + let beyond = search(&store, &base, &CuratedCatalog::new(&[]), "widget", 4, 2).unwrap(); assert_eq!( page_names(&first), @@ -755,7 +792,15 @@ async fn a_search_pages_through_its_matches() { async fn a_search_matching_nothing_is_an_empty_indexed_page() { let (base, _upstream, store) = indexed(vec![envelope("io.example/a", "A", "")]).await; - let page = search(&store, &base, "nothing-matches-this", 1, 20).unwrap(); + let page = search( + &store, + &base, + &CuratedCatalog::new(&[]), + "nothing-matches-this", + 1, + 20, + ) + .unwrap(); assert_eq!(page_names(&page), Vec::<&str>::new()); assert_eq!(page.total_pages, 1); diff --git a/crates/tinymcp/src/registry/sources/official/mod.rs b/crates/tinymcp/src/registry/sources/official/mod.rs index c03d3b54..c6bb77d1 100644 --- a/crates/tinymcp/src/registry/sources/official/mod.rs +++ b/crates/tinymcp/src/registry/sources/official/mod.rs @@ -62,7 +62,7 @@ use super::types::{ }; use crate::error::{Error, Result}; use crate::registry::Store; -use crate::registry::curation::{CURATED_SERVERS, curated_server}; +use crate::registry::curation::{CuratedCatalog, entry_detail, entry_summary}; use tinymcp_bus::{ McpRegistryAuthConfig, RegistryFreshness, RegistryServerDetail, RegistryServerSummary, }; @@ -164,7 +164,9 @@ impl McpOfficialRegistry { page: u32, page_size: u32, ) -> Result { - if let Some(found) = index::search(store, &base_url(auth), query, page, page_size) { + let catalog = CuratedCatalog::for_settings(auth); + if let Some(found) = index::search(store, &base_url(auth), &catalog, query, page, page_size) + { return Ok(found); } @@ -176,7 +178,7 @@ impl McpOfficialRegistry { // keeps its size; a registry page is followed by its cursor. let cap = (found.freshness == RegistryFreshness::LocalFallback) .then(|| usize::try_from(page_size.max(1)).unwrap_or(usize::MAX)); - lead_with_curated(&mut found.servers, query, cap); + lead_with_curated(&mut found.servers, &catalog, query, cap); } Ok(found) } @@ -193,6 +195,7 @@ impl McpOfficialRegistry { page_size: u32, ) -> Result { let cache_key = search_cache_key(query, page, page_size); + let catalog = CuratedCatalog::for_settings(auth); if let Ok(Some(cached)) = store.cached(&cache_key) && let Ok(parsed) = serde_json::from_str::(&cached) @@ -209,7 +212,7 @@ impl McpOfficialRegistry { .active(operation, &url, self.timeouts.cooldown) { tracing::debug!(%operation, "official registry cooling down; answering from the cache"); - return serve_cached(store, cursors, query, page, page_size).ok_or(error); + return serve_cached(store, &catalog, cursors, query, page, page_size).ok_or(error); } match self @@ -226,7 +229,7 @@ impl McpOfficialRegistry { code = error.wire_name(), "official registry unavailable; answering from the cache" ); - serve_cached(store, cursors, query, page, page_size).ok_or(error) + serve_cached(store, &catalog, cursors, query, page, page_size).ok_or(error) } outcome => outcome, } @@ -295,14 +298,14 @@ impl McpOfficialRegistry { qualified_name: &str, ) -> Result { match self.get_listed(store, auth, qualified_name).await { - Err(error) => match curated_server(qualified_name) { + Err(error) => match CuratedCatalog::for_settings(auth).get(qualified_name) { Some(curated) => { tracing::debug!( qualified_name, code = error.wire_name(), "official detail answered from the curated entry" ); - Ok(curated.to_detail()) + Ok(entry_detail(curated)) } None => Err(error), }, @@ -505,14 +508,22 @@ fn served( /// own cursor, so a row pushed off page one would never appear on any page, /// and page one may run longer by the curated servers it adds. Only a page /// with nothing after it (the local fallback) is cut back to `cap`. -fn lead_with_curated(servers: &mut Vec, query: &str, cap: Option) { +/// +/// Only promoted entries lead (see [`CuratedCatalog::is_promoted`]); a runtime +/// entry is not moved to the top. +fn lead_with_curated( + servers: &mut Vec, + catalog: &CuratedCatalog, + query: &str, + cap: Option, +) { let terms: Vec = query.split_whitespace().map(str::to_lowercase).collect(); if terms.is_empty() { return; } - let mut leading: Vec = CURATED_SERVERS - .iter() + let mut leading: Vec = catalog + .promoted() .filter(|curated| { let text = format!( "{} {} {}", @@ -525,7 +536,7 @@ fn lead_with_curated(servers: &mut Vec, query: &str, cap: servers .iter() .position(|row| row.qualified_name == curated.qualified_name) - .map_or_else(|| curated.to_summary(), |at| servers.remove(at)) + .map_or_else(|| entry_summary(curated), |at| servers.remove(at)) }) .collect(); leading.append(servers); diff --git a/crates/tinymcp/src/registry/sources/official/mod_tests.rs b/crates/tinymcp/src/registry/sources/official/mod_tests.rs index 6d6b8811..e2ced42d 100644 --- a/crates/tinymcp/src/registry/sources/official/mod_tests.rs +++ b/crates/tinymcp/src/registry/sources/official/mod_tests.rs @@ -12,6 +12,7 @@ use serde_json::{Value, json}; use super::types::{OfficialListResponse, OfficialServer}; use super::{lead_with_curated, page_bound, search_cache_key}; +use crate::registry::curation::CuratedCatalog; /// A list response wrapping `servers`. fn list_response(servers: &Value, next_cursor: Option<&str>) -> OfficialListResponse { @@ -2128,8 +2129,15 @@ fn a_local_match_skips_cached_pages_that_do_not_decode() { ) .unwrap(); - let page = super::fallback::serve_cached(&store, &cursors(), "weather", 1, 20) - .expect("the decodable page answers"); + let page = super::fallback::serve_cached( + &store, + &CuratedCatalog::new(&[]), + &cursors(), + "weather", + 1, + 20, + ) + .expect("the decodable page answers"); assert_eq!(page.servers[0].qualified_name, "@acme/weather"); } @@ -2144,7 +2152,10 @@ fn a_blank_query_has_no_local_matches() { ) .unwrap(); - assert!(super::fallback::serve_cached(&store, &cursors(), " ", 1, 20).is_none()); + assert!( + super::fallback::serve_cached(&store, &CuratedCatalog::new(&[]), &cursors(), " ", 1, 20) + .is_none() + ); } // --------------------------------------------------------------------------- @@ -2266,7 +2277,15 @@ fn a_server_cached_as_a_page_row_and_a_detail_appears_once() { .unwrap(); cache_detail(&store, &envelope("com.notion/mcp")["server"]); - let page = super::fallback::serve_cached(&store, &cursors(), "notion", 1, 20).unwrap(); + let page = super::fallback::serve_cached( + &store, + &CuratedCatalog::new(&[]), + &cursors(), + "notion", + 1, + 20, + ) + .unwrap(); assert_eq!(names_of(&page), ["com.notion/mcp"]); } @@ -2289,7 +2308,15 @@ fn a_detail_matching_on_its_name_leads_a_page_row_matching_on_its_description() .unwrap(); cache_detail(&store, &envelope("com.notion/mcp")["server"]); - let page = super::fallback::serve_cached(&store, &cursors(), "notion", 1, 20).unwrap(); + let page = super::fallback::serve_cached( + &store, + &CuratedCatalog::new(&[]), + &cursors(), + "notion", + 1, + 20, + ) + .unwrap(); assert_eq!(names_of(&page), ["com.notion/mcp", "io.github.acme/pages"]); } @@ -2305,7 +2332,10 @@ fn a_detail_offering_no_way_to_connect_is_not_matched() { ) .unwrap(); - assert!(super::fallback::serve_cached(&store, &cursors(), "zzq", 1, 20).is_none()); + assert!( + super::fallback::serve_cached(&store, &CuratedCatalog::new(&[]), &cursors(), "zzq", 1, 20) + .is_none() + ); } #[tokio::test] @@ -2339,7 +2369,15 @@ fn a_curated_server_leads_cached_rows_in_a_local_match() { ) .unwrap(); - let page = super::fallback::serve_cached(&store, &cursors(), "notion", 1, 20).unwrap(); + let page = super::fallback::serve_cached( + &store, + &CuratedCatalog::new(&[]), + &cursors(), + "notion", + 1, + 20, + ) + .unwrap(); assert_eq!( names_of(&page), @@ -2354,7 +2392,15 @@ fn local_matches_from_details_are_capped_at_the_page_size() { cache_detail(&store, &envelope(name)["server"]); } - let page = super::fallback::serve_cached(&store, &cursors(), "notion", 1, 2).unwrap(); + let page = super::fallback::serve_cached( + &store, + &CuratedCatalog::new(&[]), + &cursors(), + "notion", + 1, + 2, + ) + .unwrap(); assert_eq!(page.servers.len(), 2); } @@ -2389,7 +2435,7 @@ fn a_curated_server_already_on_the_page_moves_to_the_head_as_the_registry_row() let mut rows = summaries(&["@acme/notion", "com.notion/mcp"]); rows[1].display_name = "From the registry".to_string(); - lead_with_curated(&mut rows, "notion", None); + lead_with_curated(&mut rows, &CuratedCatalog::new(&[]), "notion", None); assert_eq!(names(&rows), ["com.notion/mcp", "@acme/notion"]); assert_eq!(rows[0].display_name, "From the registry"); @@ -2402,7 +2448,7 @@ fn a_curated_lead_keeps_every_registry_row_on_the_page() { // be shown. let mut rows = summaries(&["@acme/slack", "@acme/slack-2"]); - lead_with_curated(&mut rows, "slack", None); + lead_with_curated(&mut rows, &CuratedCatalog::new(&[]), "slack", None); assert_eq!( names(&rows), @@ -2414,7 +2460,7 @@ fn a_curated_lead_keeps_every_registry_row_on_the_page() { fn a_page_with_nothing_after_it_is_cut_back_to_its_cap() { let mut rows = summaries(&["@acme/slack", "@acme/slack-2"]); - lead_with_curated(&mut rows, "slack", Some(2)); + lead_with_curated(&mut rows, &CuratedCatalog::new(&[]), "slack", Some(2)); assert_eq!(names(&rows), ["com.slack/mcp", "@acme/slack"]); } @@ -2423,16 +2469,77 @@ fn a_page_with_nothing_after_it_is_cut_back_to_its_cap() { fn a_blank_query_leaves_the_page_alone() { let mut rows = summaries(&["@acme/one", "@acme/two"]); - lead_with_curated(&mut rows, " ", None); + lead_with_curated(&mut rows, &CuratedCatalog::new(&[]), " ", None); assert_eq!(names(&rows), ["@acme/one", "@acme/two"]); } +/// A runtime curated entry, as a host would configure it. +fn runtime_entry(name: &str, url: &str) -> tinymcp_bus::CuratedServerEntry { + tinymcp_bus::CuratedServerEntry { + qualified_name: name.into(), + display_name: "Swiggy Food".into(), + description: "Order food from Swiggy".into(), + remote_url: url.into(), + transport: tinymcp_bus::CuratedEntryTransport::StreamableHttp, + auth: tinymcp_bus::CuratedEntryAuth::Oauth, + icon_url: None, + } +} + +#[test] +fn a_runtime_curated_server_does_not_lead_a_page() { + // A name no built-in server shares, so only the runtime entry could lead. + let mut rows = summaries(&["@acme/zebra"]); + let catalog = CuratedCatalog::new(&[runtime_entry( + "io.zebra/mcp", + "https://mcp.zebra.example/mcp", + )]); + + lead_with_curated(&mut rows, &catalog, "zebra", None); + + assert_eq!(names(&rows), ["@acme/zebra"]); +} + +#[test] +fn a_replaced_built_in_no_longer_leads_a_page() { + let mut rows = summaries(&["@acme/notion"]); + let catalog = CuratedCatalog::new(&[runtime_entry( + "com.notion/mcp", + "https://notion.example/mcp", + )]); + + lead_with_curated(&mut rows, &catalog, "notion", None); + + assert_eq!(names(&rows), ["@acme/notion"]); +} + +#[tokio::test] +async fn a_runtime_curated_server_is_described_when_the_registry_does_not_list_it() { + let base = failing_registry(503, "maintenance").await; + let mut auth = auth_at(&base); + auth.curated_servers = vec![runtime_entry( + "in.swiggy/food", + "https://mcp.swiggy.com/food", + )]; + + let detail = adapter() + .get(&store(), &auth, "in.swiggy/food") + .await + .expect("answered from the runtime entry"); + + assert_eq!(detail.qualified_name, "in.swiggy/food"); + assert_eq!( + detail.connections[0].deployment_url.as_deref(), + Some("https://mcp.swiggy.com/food") + ); +} + #[test] fn a_query_matching_no_curated_server_leaves_the_page_alone() { let mut rows = summaries(&["@acme/one"]); - lead_with_curated(&mut rows, "nonesuch", None); + lead_with_curated(&mut rows, &CuratedCatalog::new(&[]), "nonesuch", None); assert_eq!(names(&rows), ["@acme/one"]); } diff --git a/crates/tinymcp/src/registry/sources/types.rs b/crates/tinymcp/src/registry/sources/types.rs index 161a23e4..12ba9c08 100644 --- a/crates/tinymcp/src/registry/sources/types.rs +++ b/crates/tinymcp/src/registry/sources/types.rs @@ -210,9 +210,11 @@ impl Registries { /// Returns [`Error::ClientBuild`] when the Smithery HTTP client cannot be /// built. pub fn with_official( - auth: McpRegistryAuthConfig, + mut auth: McpRegistryAuthConfig, official: McpOfficialRegistry, ) -> Result { + auth.curated_servers = + crate::registry::curation::validated_runtime_entries(auth.curated_servers); Ok(Self { official, smithery: SmitheryRegistry::new()?, @@ -221,6 +223,13 @@ impl Registries { }) } + /// The curated list in force: the built-in servers plus the host's + /// validated runtime entries. + #[must_use] + pub fn curated_catalog(&self) -> crate::registry::curation::CuratedCatalog { + crate::registry::curation::CuratedCatalog::for_settings(&self.auth.lock()) + } + /// The dispatcher over a Smithery adapter pointed at `base`, for tests that /// stand in for Smithery with a loopback server. #[cfg(test)] diff --git a/docs/specs/runtime-curated-servers.md b/docs/specs/runtime-curated-servers.md new file mode 100644 index 00000000..6fa5fa18 --- /dev/null +++ b/docs/specs/runtime-curated-servers.md @@ -0,0 +1,102 @@ +# Curated servers at runtime + +Status: accepted (tinymcp#49). + +## Problem + +The curated first-party servers are a compiled-in `const` +(`registry/curation/servers.rs`). Adding, correcting or removing one means a +tinymcp change, a release and a version bump in every host before it reaches a +directory. A vendor endpoint that moves stays wrong until then. Curated entries +are also the only way a server the official registry doesn't list (Slack, +Swiggy) reaches a host's catalog. + +## Behaviour + +A host may supply extra curated servers at runtime. They are merged with the +built-in list and take part everywhere the built-in ones do. + +### Where they come from + +`McpRegistryAuthConfig` gains `curated_servers: Vec` +(`#[serde(default)]`, empty by default). It travels the same way as the other +registry settings: in `McpClientConfig.registry_auth` when the module object is +opened, and from a host's own config file. For openhuman that is +`[[mcp_client.registry_auth.curated_servers]]` in `config.toml`, which already +deserializes into this type, so adding a server is an edit plus a restart. No +release, and no host code change. + +The module reads no file of its own. A path or URL to fetch was considered and +dropped: tinymcp has no runtime file loading, hosts own storage, and a hosted +list adds a network dependency and a question of who may publish to it. + +`RegistrySettingsSet` does not carry the list; its arguments are positional and +fixed. A runtime-only change of curated servers goes through a re-open with the +new client config, as any other client-config change does. + +### Entry shape + +`CuratedServerEntry` mirrors `CuratedServer` with owned strings: +`qualified_name`, `display_name`, `description`, `remote_url`, `transport` +(`streamable_http` | `sse`), `auth` (`oauth` | `oauth_preregistered` | `token` | +`none`), optional `icon_url`. Unknown fields are refused. + +### Validation + +When the registry dispatcher takes the settings (the module object opening), each +entry is checked: + +- `remote_url` (and `icon_url`, when set) is `https` with a host and no + credentials; +- `qualified_name` is non-blank and unique among the runtime entries; +- `display_name` is non-blank. + +An invalid entry is dropped and logged with its name and the reason. The rest +still load. An entry is never silently changed into something else. Open does +not fail over a bad curated entry, because the catalog is optional and the rest +of the client still has to work. + +### Runtime entries are never promoted + +The official badge is a claim that someone vetted a server. A configuration +line must not be able to make an arbitrary endpoint look like that, so a runtime +entry is never badged official, never leads a first search page, and never +ranks as curated. Those stay with the built-in servers. + +A runtime entry whose `qualified_name` matches a built-in replaces its +endpoint, which lets a host fix a moved endpoint without waiting for a release. +It also takes the built-in's badge and promotions away. The replacement is +logged at warn with both URLs when the settings are taken. + +Runtime entries come after the built-ins in listing order, so the existing +order is kept. + +### Where the merged list is used + +`CuratedCatalog` (`registry/curation/catalog.rs`) is the merged list. Each use +builds it from the registry settings in force: + +- listing and search: the local-index merge (`official/index.rs`) and the + cache fallback when the registry is unreachable (`official/fallback.rs`) + include runtime entries; +- the detail fallback when the registry has no record + (`McpOfficialRegistry::get`) describes a runtime entry, and a replacement wins + over the built-in; +- promotion (badging through `RegistrySearchCurated`, `official_first`, the + curated lead on a first page, and curated-first ranking) uses only built-in + servers nobody replaced (`CuratedCatalog::is_promoted`). + +`OFFICIAL_SERVERS`, `CURATED_SERVERS` and `tag_official` keep their meaning: +the built-in list alone. No host reads them today (openhuman uses only +`RegistrySearchCurated`). + +## Contract + +An additive minor bump of tinymcp-bus: a new optional field and three new +payload types (`CuratedServerEntry`, `CuratedEntryTransport`, +`CuratedEntryAuth`). It takes 1.31: #164 lands as 1.29 and the OAuth endpoint +guard (`local_oauth_origins`) as 1.30, both before this. + +## Out of scope + +Any UI for managing entries. The host's config file is the whole interface.