Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
211ace3
fix(registry): read icons[] and prefer a raster icon (#42)
oxoxDev Oct 7, 2026
45ca39d
fix(registry): list only the latest version of each server (#42)
oxoxDev Oct 7, 2026
b6d69b6
fix(registry): give each registry request a budget and a typed timeou…
oxoxDev Oct 7, 2026
c9ca862
feat(bus): report search page freshness and bump the contract to 1.4 …
oxoxDev Oct 7, 2026
136456a
fix(registry): answer from the cache when the registry cannot (#42)
oxoxDev Oct 7, 2026
7841bc2
test(registry): cover Smithery routing through the dispatcher (#42)
oxoxDev Oct 7, 2026
1054e3b
docs: describe registry listing freshness, icons and budgets (#42)
oxoxDev Oct 7, 2026
4123976
fix(registry): match cached server details when search falls back (#42)
oxoxDev Oct 7, 2026
2e70d79
docs: note that search fallback matches cached server details (#42)
oxoxDev Oct 7, 2026
18c4b3f
fix(oauth): discover authorization metadata on the origin when a 401 …
oxoxDev Oct 7, 2026
f171352
fix(oauth): document that discovered origin metadata flags a 401 as o…
oxoxDev Oct 7, 2026
e5f3dd2
fix(oauth): sign in through a server that is its own authorization se…
oxoxDev Oct 7, 2026
5105865
test(oauth): pin bridge outcome and status for origin-published metad…
oxoxDev Oct 7, 2026
e734c7b
docs(oauth): describe well-known discovery for a 401 without resource…
oxoxDev Oct 7, 2026
67db872
feat(registry): curate official servers with endpoint, transport and …
oxoxDev Oct 7, 2026
b70387c
feat(registry): report answers from the local catalog index as indexe…
oxoxDev Oct 7, 2026
3e3c0f9
test(registry): derive curated names at run time in a test (#42)
oxoxDev Oct 7, 2026
45dcd5c
feat(registry): search a background-synced local index of the officia…
oxoxDev Oct 7, 2026
f9f3820
docs: describe the local registry index and curated server entries (#42)
oxoxDev Oct 7, 2026
7184495
fix(registry): match curated servers when search falls back (#42)
oxoxDev Oct 7, 2026
61b1c0b
docs: note that search fallback matches curated servers (#42)
oxoxDev Oct 7, 2026
0c2a70a
fix(registry): pause an index sync at its page limit instead of finis…
oxoxDev Oct 7, 2026
343ff51
docs: describe how an index sync pauses and resumes at its page limit…
oxoxDev Oct 7, 2026
12451a6
fix(registry): lead a first search page with the curated servers it m…
oxoxDev Oct 7, 2026
a09cc02
docs: note that curated servers lead every first search page (#42)
oxoxDev Oct 7, 2026
b124c88
feat(registry): curate Swiggy's Food, Instamart, Dineout and Scenes s…
oxoxDev Oct 9, 2026
efa2a6d
docs: note Swiggy's curated servers and their redirect allowlist
oxoxDev Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,59 @@ Enable the `tools` feature to expose each server tool as a
`tinytools` is a git dependency so a host that links another checkout of it can
`[patch]` the two into one package.

## Browsing the catalogs

`McpRegistry::registry_search` lists the official MCP registry, plus Smithery
when a key is configured.

- **One row per server.** The official adapter asks for `version=latest`, and
when a page still lists several versions of a server it keeps the one marked
`isLatest`. A detail lookup takes the same version.
- **Icons** come from the registry's `icons[]`: a raster image ahead of an SVG,
an SVG when it is the only one, and the legacy `iconUrl` otherwise.
- **Time budgets** are per request kind (`registry::RegistryTimeouts`): connect
5 s, browse 15 s, search 8 s, detail 12 s. A request that runs out is
`Error::RegistryTimeout` (`errors::REGISTRY_TIMEOUT` on the bus), and
`Error::is_registry_unavailable` groups it with transport failures and 408,
429 and 5xx answers.
- **When the registry cannot answer**, a listing is served from the cache: an
earlier answer to the same request first (`RegistryFreshness::Cached`), then,
for the first page of a search, curated servers, cached catalog rows and
cached server details matching every word of the query, curated servers
first (`RegistryFreshness::LocalFallback`).
Only when neither exists does the error reach the caller. A listing that
timed out skips the network for the next 60 s, still answering from the cache
or local matches when either exists. `RegistrySearchPage::freshness` (contract 1.4) tells a host
which kind of answer it got.
- **A local index answers searches.** The registry's `search=` can take tens of
seconds while its plain listing pages quickly, so the first search or browse
starts a background sync that pages `/v0/servers?version=latest` by cursor
into the store, one row per server. Once a sync has finished, a query is
answered from that copy without asking the registry
(`RegistryFreshness::Indexed`): every word must appear in the name, title or
description, and matches rank curated servers first, then name or title
matches, then description matches, each alphabetical. Until then a search
takes the path above. One background run reads at most 200 pages and then
pauses; the next search or browse resumes it from the stored cursor, and
only a sync whose cursor ran out is used or prunes anything. A failed page
keeps what was synced and the next sync resumes from it; at most one sync
runs at a time; the index re-syncs after
six hours (`registry::RegistryIndexSettings`, set with
`McpOfficialRegistry::with_settings` and `Registries::with_official`).
Browsing a page of the catalog is unchanged.
- **Curated servers** (`curation::CURATED_SERVERS`) carry their hosted
endpoint, transport and authentication, not just a name; `OFFICIAL_SERVERS`
is the same list as names. A curated server matches a local search even when
the index lacks it, leads the first page of any search whose words it
matches (added when the registry's answer leaves it out), and its detail
comes from the entry when the registry cannot describe it. Slack's server (`com.slack/mcp`) is not in the registry
and accepts only OAuth clients Slack has registered in advance, so it is
marked `CuratedAuth::OauthPreregistered`. Swiggy's four servers
(`com.swiggy/food`, `com.swiggy/instamart`, `com.swiggy/dineout`,
`com.swiggy/scenes`) are not in the registry either; they take OAuth with
dynamic client registration, but Swiggy accepts only redirect URIs it has
allowlisted for the client.

## `mcp.json` and OAuth for hosts with their own store

`registry::config_doc` reads and writes the `{ "mcpServers": { … } }` document.
Expand All @@ -224,6 +277,30 @@ check. `registry::OAuthBundle` is the stored refresh bundle's shape.
the authorization server shows on its consent screen; it defaults to
`DEFAULT_CLIENT_NAME` (`TinyMCP`).

OAuth discovery starts from the 401. When its challenge names
`resource_metadata`, that protected-resource metadata is followed. When a
Bearer challenge names none, the server's origin is checked in this order:

1. `/.well-known/oauth-protected-resource` under the endpoint's path, then at
the root. A document whose `resource` is on the same origin is followed to
its authorization servers. A document that is authorization-server metadata
whose `issuer` is the origin is used as the authorization server; some
servers publish theirs there.
2. The origin's own `/.well-known/oauth-authorization-server`, then
`/.well-known/openid-configuration`, accepted only when the `issuer` is the
origin (one trailing slash tolerated). This covers servers on the
2025-03-26 authorization spec, where the MCP server is its own
authorization server.

Default `/authorize` and `/token` paths are never guessed. Every lookup is a
`GET` to the MCP origin over a client that follows no redirects, reads at most
64 KiB and gives up after about five seconds. A 3xx, 401, 403, 404 or 410
counts as absent. A 5xx or a network failure is retried on the next 401. When an
authorization server with authorize and token endpoints turns up,
`Error::Unauthorized::resource_metadata` names the document that yielded it, so
`advertises_oauth` and the connection status report a sign-in. A Basic
challenge is never looked up.

## Static linking

Enable the `static-link` feature when compiling this module into a Rust host. It
Expand Down
8 changes: 8 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ out of scope. A roadmap that lists everything is a roadmap nobody trusts.
for host metering and failure surfacing (contract 1.3)
- `mcp.json` reading for hosts with their own store (`parse_with`), and a
guarded OAuth refresh on the flow
- official-registry listings with one row per server at its latest version,
the server's declared icon, per-request time budgets, and cached answers
with a reported freshness when the registry stalls (contract 1.4)
- OAuth discovery for a 401 without `resource_metadata`, from the origin's
well-known protected-resource and authorization-server metadata
- a local, background-synced index of the official catalog that answers
searches instantly, and curated first-party servers with their endpoint,
transport and authentication

## Next

Expand Down
6 changes: 6 additions & 0 deletions crates/tinymcp-bus/src/errors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ pub const SERVER_BIND: &str = "ai.tinyhumans.tinymcp.Error.ServerBind";
pub const CONFIG_DOC: &str = "ai.tinyhumans.tinymcp.Error.ConfigDoc";
/// Tool arguments were valid JSON but did not match the tool schema.
pub const INVALID_ARGUMENTS: &str = "ai.tinyhumans.tinymcp.Error.InvalidArguments";
/// An upstream registry did not answer within its time budget.
///
/// Transient: a host shows the catalog as unavailable for now and offers a
/// retry, rather than reporting a failure.
pub const REGISTRY_TIMEOUT: &str = "ai.tinyhumans.tinymcp.Error.RegistryTimeout";

/// Every name in this table.
pub const ALL: &[&str] = &[
Expand Down Expand Up @@ -113,6 +118,7 @@ pub const ALL: &[&str] = &[
SERVER_BIND,
CONFIG_DOC,
INVALID_ARGUMENTS,
REGISTRY_TIMEOUT,
];

#[cfg(test)]
Expand Down
10 changes: 9 additions & 1 deletion crates/tinymcp-bus/src/errors/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]

use super::{ALL, PREFIX, UNAUTHORIZED};
use super::{ALL, PREFIX, REGISTRY_TIMEOUT, UNAUTHORIZED};

#[test]
fn every_name_shares_the_prefix_and_has_a_suffix() {
Expand Down Expand Up @@ -44,3 +44,11 @@ fn the_unauthorized_name_is_pinned() {
// A host anchors its needs-auth classification on this string.
assert_eq!(UNAUTHORIZED, "ai.tinyhumans.tinymcp.Error.Unauthorized");
}

#[test]
fn the_registry_timeout_name_is_pinned() {
assert_eq!(
REGISTRY_TIMEOUT,
"ai.tinyhumans.tinymcp.Error.RegistryTimeout"
);
}
4 changes: 2 additions & 2 deletions crates/tinymcp-bus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,8 @@ pub use config::{
McpRegistryAuthConfig, McpServerConfig,
};
pub use method::{
ConnectOutcome, InstallOutcome, RegistrySearchPage, RegistrySettings, SearchCuration,
ServerDetail, ToolCallOutcome, UpdateEnvOutcome, UpdateEnvStatus,
ConnectOutcome, InstallOutcome, RegistryFreshness, RegistrySearchPage, RegistrySettings,
SearchCuration, ServerDetail, ToolCallOutcome, UpdateEnvOutcome, UpdateEnvStatus,
};
pub use names::{DIRECTORY_OBJECT_PREFIX, INTERFACE, METHODS, OBJECT_PATH};
pub use registry::{
Expand Down
4 changes: 2 additions & 2 deletions crates/tinymcp-bus/src/method/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
mod types;

pub use types::{
ConnectOutcome, InstallOutcome, RegistrySearchPage, RegistrySettings, SearchCuration,
ServerDetail, ToolCallOutcome, UpdateEnvOutcome, UpdateEnvStatus,
ConnectOutcome, InstallOutcome, RegistryFreshness, RegistrySearchPage, RegistrySettings,
SearchCuration, ServerDetail, ToolCallOutcome, UpdateEnvOutcome, UpdateEnvStatus,
};

#[cfg(test)]
Expand Down
54 changes: 53 additions & 1 deletion crates/tinymcp-bus/src/method/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

use serde_json::json;

use super::{SearchCuration, ServerDetail, ToolCallOutcome};
use super::{RegistryFreshness, RegistrySearchPage, SearchCuration, ServerDetail, ToolCallOutcome};
use crate::{McpServerToolResult, McpToolResult};

#[test]
Expand Down Expand Up @@ -104,3 +104,55 @@ fn a_curation_request_serializes_both_switches() {
json!({ "tag_official": true, "official_first": false }),
);
}

#[test]
fn freshness_travels_in_snake_case() {
for (freshness, wire) in [
(RegistryFreshness::Live, "live"),
(RegistryFreshness::Indexed, "indexed"),
(RegistryFreshness::Cached, "cached"),
(RegistryFreshness::LocalFallback, "local_fallback"),
] {
assert_eq!(serde_json::to_value(freshness).unwrap(), json!(wire));
assert_eq!(
serde_json::from_value::<RegistryFreshness>(json!(wire)).unwrap(),
freshness
);
}
}

#[test]
fn freshness_orders_from_freshest_to_least_fresh() {
assert!(RegistryFreshness::Live < RegistryFreshness::Indexed);
assert!(RegistryFreshness::Indexed < RegistryFreshness::Cached);
assert!(RegistryFreshness::Cached < RegistryFreshness::LocalFallback);
assert_eq!(
RegistryFreshness::Live.max(RegistryFreshness::Indexed),
RegistryFreshness::Indexed
);
assert_eq!(
RegistryFreshness::Live.max(RegistryFreshness::LocalFallback),
RegistryFreshness::LocalFallback
);
}

#[test]
fn a_search_page_from_an_older_module_reads_as_live() {
let page: RegistrySearchPage =
serde_json::from_value(json!({ "servers": [], "page": 1, "total_pages": 1 })).unwrap();

assert_eq!(page.freshness, RegistryFreshness::Live);
}

#[test]
fn a_search_page_serializes_its_freshness() {
let page = RegistrySearchPage {
freshness: RegistryFreshness::Cached,
..RegistrySearchPage::default()
};

assert_eq!(
serde_json::to_value(&page).unwrap()["freshness"],
json!("cached")
);
}
36 changes: 36 additions & 0 deletions crates/tinymcp-bus/src/method/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,42 @@ pub struct RegistrySearchPage {
/// beyond the current one while more results exist, and the current page
/// when they do not.
pub total_pages: u32,
/// Where these rows came from.
///
/// Absent in a frame from a module older than contract 1.4, which reads as
/// [`RegistryFreshness::Live`].
#[serde(default)]
pub freshness: RegistryFreshness,
}

/// Where a page of catalog results came from.
///
/// Ordered from freshest to least fresh, so a page merged from several sources
/// takes the [`Ord::max`] of theirs.
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum RegistryFreshness {
/// Answered by the upstream catalogs, now or within the cache lifetime.
#[default]
Live,
/// Answered from the module's local copy of the official catalog, which it
/// re-syncs in the background.
///
/// Complete as of the last sync, so it can trail the upstream by up to the
/// refresh interval.
Indexed,
/// The upstream could not answer; this is an earlier answer to the same
/// request, however old.
Cached,
/// The upstream could not answer and had no earlier answer to this
/// request; these are rows from earlier catalog pages that match the query.
///
/// Partial by nature. A caller says so rather than presenting the rows as
/// everything the catalog holds.
LocalFallback,
}

/// What installing produced.
Expand Down
6 changes: 4 additions & 2 deletions crates/tinymcp-bus/src/version/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
/// The wire contract version this crate defines.
///
/// 1.1 added agent tools; 1.2 added registry and directory members; 1.3 added
/// the structured call outcome and the credential-store error name.
pub const CONTRACT_VERSION: (u32, u32) = (1, 3);
/// the structured call outcome and the credential-store error name; 1.4 added
/// the search page's freshness, including answers from the local catalog index,
/// and the registry-timeout error name.
pub const CONTRACT_VERSION: (u32, u32) = (1, 4);

/// Returns whether a host holding [`CONTRACT_VERSION`] can bind to a module
/// reporting `module`.
Expand Down
8 changes: 4 additions & 4 deletions crates/tinymcp-bus/src/version/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ use super::{CONTRACT_VERSION, binds, is_compatible};

#[test]
fn the_shipped_contract_version_is_pinned() {
assert_eq!(CONTRACT_VERSION, (1, 3));
assert_eq!(CONTRACT_VERSION, (1, 4));
}

#[test]
fn a_host_on_this_contract_refuses_a_module_from_before_it() {
// 1.3 added the call outcome; a 1.2 module does not report it.
// 1.4 added the search page's freshness; a 1.3 module does not report it.
assert!(!is_compatible((1, 0)));
assert!(!is_compatible((1, 2)));
assert!(!is_compatible((1, 3)));
}

#[test]
Expand All @@ -21,7 +21,7 @@ fn the_contract_binds_to_itself() {

#[test]
fn a_newer_minor_on_the_module_side_binds() {
assert!(is_compatible((1, 3)));
assert!(is_compatible((1, 4)));
assert!(is_compatible((1, 97)));
}

Expand Down
Loading
Loading