From 177d85b295fc8651dd2ceb2b06732ffff9bab409 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 11 Nov 2026 15:26:47 +0530 Subject: [PATCH 1/2] Spec runtime curated servers (#49) Hosts supply extra curated servers in McpRegistryAuthConfig, merged with the built-in list everywhere it is used today, validated at open, with a runtime entry replacing a built-in of the same name. --- docs/specs/runtime-curated-servers.md | 89 +++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 docs/specs/runtime-curated-servers.md diff --git a/docs/specs/runtime-curated-servers.md b/docs/specs/runtime-curated-servers.md new file mode 100644 index 00000000..b19157e2 --- /dev/null +++ b/docs/specs/runtime-curated-servers.md @@ -0,0 +1,89 @@ +# Curated servers at runtime + +Status: proposed (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 module object opens, 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. + +### Precedence + +A runtime entry whose `qualified_name` matches a built-in replaces it. That is +what lets a host correct a moved endpoint without waiting for a release. +Runtime entries come after the built-ins in listing order, so the existing +order is kept. + +### Where the merged list is used + +Everywhere the built-in list is used today: + +- badging (`tag_official`) and `official_first` ordering, through + `RegistrySearchCurated`; +- the curated lead on a first search page (`lead_with_curated`); +- the local-index merge and its ranking (`official/index.rs`); +- the cache fallback when the registry is unreachable (`official/fallback.rs`); +- the detail fallback when the registry has no record (`McpOfficialRegistry::get`). + +`OFFICIAL_SERVERS` and `CURATED_SERVERS` stay as the built-in `const` lists. A +merged accessor, `official_servers(&McpRegistryAuthConfig) -> Vec`, +reports the merged names. No host reads the consts today (openhuman uses only +`RegistrySearchCurated`). + +## Contract + +An additive minor bump of tinymcp-bus: a new optional field and a new payload +type. Its number follows whatever is current when this lands (1.29 is taken by +#164). + +## Out of scope + +Any UI for managing entries. The host's config file is the whole interface. From 838cd11ca7de068b2ed6872ebb70c80a38706979 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 11 Nov 2026 15:32:06 +0530 Subject: [PATCH 2/2] Let a host add curated servers at runtime, never badged official The curated first-party servers were a compiled-in const, so adding or correcting one took a tinymcp release and a bump in every host (#49). Hosts now supply extra entries in McpRegistryAuthConfig::curated_servers (contract 1.31, CuratedServerEntry). For openhuman that is [[mcp_client.registry_auth.curated_servers]] in config.toml, which already reaches the module through the client config, so adding a server is a config edit and a restart. The issue proposed a file the module reads. tinymcp reads no files at runtime and hosts own storage, so the entries travel in the settings instead. The registry dispatcher validates them once when it takes the settings: https with a host and no credentials, non-blank names, no duplicates. A bad entry is dropped with a warning, and the rest load. CuratedCatalog merges them over the built-in list for the local-index merge, the cache fallback and the detail fallback. Runtime entries are never promoted. Only a built-in server nobody replaced is badged official, leads a first page or ranks as curated, because a configuration line must not make an arbitrary endpoint look vetted. An entry naming a built-in replaces its endpoint and takes the badge away, logged at warn with both URLs. Closes #49 --- crates/tinymcp-bus/README.md | 7 + crates/tinymcp-bus/src/config/mod.rs | 8 +- crates/tinymcp-bus/src/config/mod_tests.rs | 61 ++++- crates/tinymcp-bus/src/config/types.rs | 63 +++++ crates/tinymcp-bus/src/lib.rs | 8 +- crates/tinymcp-bus/src/version/mod.rs | 4 +- crates/tinymcp-bus/src/version/mod_tests.rs | 2 +- .../tinymcp/src/registry/config_redaction.rs | 3 + crates/tinymcp/src/registry/config_tests.rs | 2 + .../tinymcp/src/registry/curation/catalog.rs | 251 ++++++++++++++++++ .../src/registry/curation/catalog_tests.rs | 162 +++++++++++ crates/tinymcp/src/registry/curation/mod.rs | 9 + crates/tinymcp/src/registry/ops/mod_tests.rs | 1 + crates/tinymcp/src/registry/ops/types.rs | 6 +- .../src/registry/sources/official/fallback.rs | 17 +- .../src/registry/sources/official/index.rs | 19 +- .../registry/sources/official/index_tests.rs | 69 ++++- .../src/registry/sources/official/mod.rs | 33 ++- .../registry/sources/official/mod_tests.rs | 131 ++++++++- crates/tinymcp/src/registry/sources/types.rs | 11 +- docs/specs/runtime-curated-servers.md | 53 ++-- 21 files changed, 836 insertions(+), 84 deletions(-) create mode 100644 crates/tinymcp/src/registry/curation/catalog.rs create mode 100644 crates/tinymcp/src/registry/curation/catalog_tests.rs diff --git a/crates/tinymcp-bus/README.md b/crates/tinymcp-bus/README.md index 02b713d3..060d157d 100644 --- a/crates/tinymcp-bus/README.md +++ b/crates/tinymcp-bus/README.md @@ -90,6 +90,13 @@ so a host can tell a tool that only reads from one that changes something before it calls it. The field is optional and omitted when absent, and `StaticScrubTools` scrubs it like the rest of a tool's metadata. +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 629f3355..f696a63a 100644 --- a/crates/tinymcp-bus/src/config/types.rs +++ b/crates/tinymcp-bus/src/config/types.rs @@ -494,6 +494,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 200713df..6f40134e 100644 --- a/crates/tinymcp-bus/src/version/mod.rs +++ b/crates/tinymcp-bus/src/version/mod.rs @@ -44,7 +44,9 @@ /// `StdioSandboxConfig::enforcement` (`StdioSandboxEnforcement`), letting the /// host choose `Declared` for a backend with partial enforcement; the default /// stays `Enforced`. 1.28 adds the optional `annotations` on `McpRemoteTool`. -pub const CONTRACT_VERSION: (u32, u32) = (1, 28); +/// 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 33c85162..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, 28)); + 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 2961f67c..264494cb 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 3e1136aa..179be8e3 100644 --- a/crates/tinymcp/src/registry/ops/types.rs +++ b/crates/tinymcp/src/registry/ops/types.rs @@ -202,7 +202,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 bfd926ee..14e2efc2 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 5ad51918..102056a4 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); } @@ -172,7 +174,7 @@ impl McpOfficialRegistry { .search_registry(store, auth, cursors, query, page, page_size) .await?; if page == 1 { - lead_with_curated(&mut found.servers, query, page_size); + lead_with_curated(&mut found.servers, &catalog, query, page_size); } Ok(found) } @@ -189,6 +191,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) @@ -205,7 +208,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 @@ -222,7 +225,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, } @@ -291,14 +294,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), }, @@ -484,14 +487,22 @@ fn served( /// Puts the curated servers matching `query` at the head of a first page, /// adding any the page does not carry and keeping the registry's row for any /// it does, within `page_size` rows. -fn lead_with_curated(servers: &mut Vec, query: &str, page_size: u32) { +/// +/// 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, + page_size: u32, +) { 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!( "{} {} {}", @@ -504,7 +515,7 @@ fn lead_with_curated(servers: &mut Vec, query: &str, page 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 db711e7c..133d311f 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 { @@ -2090,8 +2091,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"); } @@ -2106,7 +2114,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() + ); } // --------------------------------------------------------------------------- @@ -2228,7 +2239,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"]); } @@ -2251,7 +2270,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"]); } @@ -2267,7 +2294,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] @@ -2301,7 +2331,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), @@ -2316,7 +2354,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); } @@ -2351,7 +2397,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", 20); + lead_with_curated(&mut rows, &CuratedCatalog::new(&[]), "notion", 20); assert_eq!(names(&rows), ["com.notion/mcp", "@acme/notion"]); assert_eq!(rows[0].display_name, "From the registry"); @@ -2361,7 +2407,7 @@ fn a_curated_server_already_on_the_page_moves_to_the_head_as_the_registry_row() fn a_curated_lead_stays_within_the_page_size() { let mut rows = summaries(&["@acme/slack", "@acme/slack-2"]); - lead_with_curated(&mut rows, "slack", 2); + lead_with_curated(&mut rows, &CuratedCatalog::new(&[]), "slack", 2); assert_eq!(names(&rows), ["com.slack/mcp", "@acme/slack"]); } @@ -2370,16 +2416,77 @@ fn a_curated_lead_stays_within_the_page_size() { fn a_blank_query_leaves_the_page_alone() { let mut rows = summaries(&["@acme/one", "@acme/two"]); - lead_with_curated(&mut rows, " ", 20); + lead_with_curated(&mut rows, &CuratedCatalog::new(&[]), " ", 20); 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", 20); + + 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", 20); + + 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", 20); + lead_with_curated(&mut rows, &CuratedCatalog::new(&[]), "nonesuch", 20); 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 8507c51c..9a7480e8 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()) + } + /// Starts a background sync of the official catalog's local index when /// one is due, returning whether it did. /// diff --git a/docs/specs/runtime-curated-servers.md b/docs/specs/runtime-curated-servers.md index b19157e2..6fa5fa18 100644 --- a/docs/specs/runtime-curated-servers.md +++ b/docs/specs/runtime-curated-servers.md @@ -1,6 +1,6 @@ # Curated servers at runtime -Status: proposed (tinymcp#49). +Status: accepted (tinymcp#49). ## Problem @@ -43,7 +43,8 @@ new client config, as any other client-config change does. ### Validation -When the module object opens, each entry is checked: +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; @@ -55,34 +56,46 @@ 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. -### Precedence +### 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. -A runtime entry whose `qualified_name` matches a built-in replaces it. That is -what lets a host correct a moved endpoint without waiting for a release. Runtime entries come after the built-ins in listing order, so the existing order is kept. ### Where the merged list is used -Everywhere the built-in list is used today: - -- badging (`tag_official`) and `official_first` ordering, through - `RegistrySearchCurated`; -- the curated lead on a first search page (`lead_with_curated`); -- the local-index merge and its ranking (`official/index.rs`); -- the cache fallback when the registry is unreachable (`official/fallback.rs`); -- the detail fallback when the registry has no record (`McpOfficialRegistry::get`). - -`OFFICIAL_SERVERS` and `CURATED_SERVERS` stay as the built-in `const` lists. A -merged accessor, `official_servers(&McpRegistryAuthConfig) -> Vec`, -reports the merged names. No host reads the consts today (openhuman uses only +`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 a new payload -type. Its number follows whatever is current when this lands (1.29 is taken by -#164). +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