From 211ace392911ca2376e77a622c8f3aa094165e07 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 17:27:29 +0530 Subject: [PATCH 01/27] fix(registry): read icons[] and prefer a raster icon (#42) --- .../official/fixtures/latest_page.json | 593 ++++++++++++++++++ .../registry/sources/official/mod_tests.rs | 154 +++++ .../src/registry/sources/official/types.rs | 61 +- 3 files changed, 806 insertions(+), 2 deletions(-) create mode 100644 crates/tinymcp/src/registry/sources/official/fixtures/latest_page.json diff --git a/crates/tinymcp/src/registry/sources/official/fixtures/latest_page.json b/crates/tinymcp/src/registry/sources/official/fixtures/latest_page.json new file mode 100644 index 0000000..8129829 --- /dev/null +++ b/crates/tinymcp/src/registry/sources/official/fixtures/latest_page.json @@ -0,0 +1,593 @@ +{ + "servers": [ + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ac.inference.sh/mcp", + "description": "run any ai model. compose agents, stack knowledge, connect tools. one api, pay per run.", + "title": "inference.sh", + "version": "2.0.1", + "remotes": [ + { + "type": "streamable-http", + "url": "https://api.inference.sh/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-07-27T10:44:51.359634Z", + "publishedAt": "2026-07-27T10:44:51.359634Z", + "updatedAt": "2026-07-27T10:44:51.359634Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ac.snag/snag", + "description": "Gives your coding agent the captured console, network, replay and screenshot evidence for a bug.", + "version": "1.0.0", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.snag.ac/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-09-09T10:35:53.194664Z", + "publishedAt": "2026-09-09T10:35:53.194664Z", + "updatedAt": "2026-09-09T10:35:53.194664Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ac.tandem/docs-mcp", + "description": "Remote MCP server for Tandem docs, install guides, SDKs, workflows, and agent setup help.", + "repository": { + "url": "https://github.com/frumu-ai/tandem", + "source": "github" + }, + "version": "0.3.2", + "websiteUrl": "https://tandem.ac/docs-mcp", + "remotes": [ + { + "type": "streamable-http", + "url": "https://tandem.ac/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-04-22T21:06:34.500049Z", + "publishedAt": "2026-04-22T21:06:34.500049Z", + "updatedAt": "2026-04-22T21:06:34.500049Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ad.getle/leads", + "description": "Find B2B leads with verified work emails, find and verify emails, and run cold email outreach.", + "title": "Getlead", + "repository": { + "url": "https://github.com/Adgrowofficial/getlead-mcp", + "source": "github" + }, + "version": "1.0.0", + "websiteUrl": "https://getle.ad/mcp", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.getle.ad/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-09-26T12:28:37.779048Z", + "publishedAt": "2026-09-26T12:28:37.779048Z", + "updatedAt": "2026-09-26T12:28:37.779048Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ad.inside/inside-ads", + "description": "Telegram ad exchange: estimate reach and cost with no account, then create and run campaigns.", + "title": "Inside Ads", + "version": "1.0.0", + "websiteUrl": "https://inside.ad", + "remotes": [ + { + "type": "streamable-http", + "url": "https://app.inside.ad/api/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-08-18T20:55:46.452926Z", + "publishedAt": "2026-08-18T20:55:46.452926Z", + "updatedAt": "2026-08-18T20:55:46.452926Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ae.brainy/grocery-prices", + "description": "Source-backed UAE costs: groceries, schools, housing, transport, utilities, telecom and relocation", + "title": "Brainy Prices — UAE Cost of Living", + "version": "2.0.0", + "websiteUrl": "https://prices.brainy.ae/developers.html", + "remotes": [ + { + "type": "streamable-http", + "url": "https://prices.brainy.ae/mcp/v2" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-10-04T02:29:51.000704Z", + "publishedAt": "2026-10-04T02:29:51.000704Z", + "updatedAt": "2026-10-04T02:29:51.000704Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ae.datadubai/dubai-real-estate", + "description": "Dubai property prices, AED/sqft, sales, rents, yields and projects from DLD registered transactions.", + "title": "Dubai Data — Dubai real estate statistics", + "repository": { + "url": "https://github.com/datadubai/dubai-real-estate-dld", + "source": "github" + }, + "version": "1.1.0", + "websiteUrl": "https://datadubai.ae/mcp/", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.datadubai.ae/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-09-30T08:30:47.911063Z", + "publishedAt": "2026-09-30T08:30:47.911063Z", + "updatedAt": "2026-09-30T08:30:47.911063Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ae.grovisor/grotax", + "description": "UAE Corporate Tax: CT computation, SBR, penalties, free zone test, TP and health-check.", + "title": "groTAX by Grovisor", + "version": "1.3.0", + "websiteUrl": "https://grovisor.ae/tools", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.grovisor.ae/" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-10-06T01:10:14.813856Z", + "publishedAt": "2026-10-06T01:10:14.813856Z", + "updatedAt": "2026-10-06T01:10:14.813856Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ae.plantguide/dubai-gardening", + "description": "What to plant in Dubai, indoor plants, plant care in the heat, garden styles and Garden Care prices.", + "title": "Plant Guide: Dubai gardening research", + "version": "1.1.0", + "websiteUrl": "https://plantguide.ae/agents", + "icons": [ + { + "src": "https://plantguide.ae/brand/plantguide-mark.svg", + "mimeType": "image/svg+xml", + "sizes": [ + "any" + ] + }, + { + "src": "https://plantguide.ae/icons/icon-512.png", + "mimeType": "image/png", + "sizes": [ + "512x512" + ] + } + ], + "remotes": [ + { + "type": "streamable-http", + "url": "https://plantguide.ae/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-10-07T08:44:40.122336Z", + "publishedAt": "2026-10-07T08:44:40.122336Z", + "updatedAt": "2026-10-07T08:44:40.122336Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ae.projectory/mcp", + "description": "Find UAE off-plan developments and registered residential sales.", + "title": "Projectory", + "version": "0.6.0", + "websiteUrl": "https://projectory.ae/mcp/", + "icons": [ + { + "src": "https://projectory.ae/apple-touch-icon.png", + "mimeType": "image/png", + "sizes": [ + "180x180" + ] + } + ], + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.projectory.ae/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-09-29T20:37:54.5633Z", + "publishedAt": "2026-09-29T20:37:54.5633Z", + "updatedAt": "2026-09-29T20:37:54.5633Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ae.propick/propick", + "description": "Manage your real-estate stock on Propick (Dubai): bulk listing sync, lookups and run reports.", + "title": "Propick Integration MCP", + "version": "1.0.0", + "websiteUrl": "https://propick.ae", + "remotes": [ + { + "type": "streamable-http", + "url": "https://propick.ae/mcp", + "headers": [ + { + "description": "Bearer (ppk_live_...) issued in the Propick cabinet when creating an import channel", + "isRequired": true, + "isSecret": true, + "name": "Authorization" + } + ] + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-08-25T11:16:56.879334Z", + "publishedAt": "2026-08-25T11:16:56.879334Z", + "updatedAt": "2026-08-25T11:16:56.879334Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "africa.ugc/ugc", + "description": "The Human Ad Network. Campaigns, clipping, and Clip Studio from chat.", + "title": "UGC, the Human Ad Network", + "version": "1.0.0", + "websiteUrl": "https://ugc.africa", + "icons": [ + { + "src": "https://ugc.africa/icon-512.png", + "mimeType": "image/png", + "sizes": [ + "512x512" + ] + } + ], + "remotes": [ + { + "type": "streamable-http", + "url": "https://api.ugc.africa/mcp" + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "longDescription": "UGC is the Human Ad Network for brands and creators. Connect your UGC account to list and create campaigns, run clip campaigns, discover open creator slots, reserve and submit work, edit in Clip Studio, check wallets, and fund campaigns - all from chat. Tools only access data for the signed-in account. Always confirm before funding or other money-moving actions. UGC is a global product (not region-locked)." + } + } + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-10-03T16:28:09.054327Z", + "publishedAt": "2026-10-03T16:28:09.054327Z", + "updatedAt": "2026-10-03T16:28:09.054327Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ag.hood/name-service", + "description": "Resolve .hood names on Robinhood Chain — forward/reverse, text records, availability & pricing.", + "title": "hood. — .hood name service", + "version": "0.1.0", + "websiteUrl": "https://www.hood.ag/docs", + "remotes": [ + { + "type": "streamable-http", + "url": "https://www.hood.ag/api/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-07-10T04:58:06.628668Z", + "publishedAt": "2026-07-10T04:58:06.628668Z", + "updatedAt": "2026-07-10T04:58:06.628668Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "agency.goji/goji", + "description": "Answers on AEO, SEO, web and brand from GOJI's published material. Melbourne, Australia.", + "repository": { + "url": "https://github.com/goji-agency/goji-mcp", + "source": "github" + }, + "version": "1.0.1", + "websiteUrl": "https://goji.agency", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.goji.agency/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-08-13T13:12:26.047978Z", + "publishedAt": "2026-08-13T13:12:26.047978Z", + "updatedAt": "2026-08-13T13:12:26.047978Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "agency.justidea/justidea-agency", + "description": "Services, prices, inquiries and free analytics and AI visibility scans by JustIdea, a Polish agency.", + "title": "JustIdea Agency", + "version": "1.1.0", + "websiteUrl": "https://justidea.agency/", + "icons": [ + { + "src": "https://justidea.agency/icon-512.png", + "mimeType": "image/png", + "sizes": [ + "512x512" + ] + } + ], + "remotes": [ + { + "type": "streamable-http", + "url": "https://justidea.agency/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-10-04T01:59:27.439267Z", + "publishedAt": "2026-10-04T01:59:27.439267Z", + "updatedAt": "2026-10-04T01:59:27.439267Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "agency.kesey/pretrip", + "description": "Screen regulated-health marketing copy against source-cited rulesets, all 50 states.", + "title": "Pre-Trip compliance scanner", + "version": "1.0.1", + "websiteUrl": "https://scan.kesey.agency/developers/", + "packages": [ + { + "registryType": "npm", + "identifier": "pretrip-mcp", + "version": "1.0.1", + "transport": { + "type": "stdio" + } + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-07-26T18:03:21.463557Z", + "publishedAt": "2026-07-26T18:03:21.463557Z", + "updatedAt": "2026-07-26T18:03:21.463557Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "agency.lona/trading", + "description": "AI-powered trading strategy development: backtesting, market data, and portfolio analysis", + "repository": { + "url": "https://github.com/mindsightventures/lona", + "source": "github", + "id": "891584339", + "subfolder": "packages/lona-mcp-server" + }, + "version": "2.0.0", + "websiteUrl": "https://lona.agency", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.lona.agency/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-02-24T00:07:27.525636Z", + "publishedAt": "2026-02-24T00:07:27.525636Z", + "updatedAt": "2026-02-24T00:07:27.525636Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "agency.ottobot/business-contact-finder", + "description": "Check how to contact a business website, and whether that contact path actually works.", + "title": "Business Contact Finder", + "repository": { + "url": "https://github.com/modelcontextprotocol/registry", + "source": "github" + }, + "version": "0.2.0", + "remotes": [ + { + "type": "streamable-http", + "url": "https://business-contact-finder-mcp.ottobot2025.workers.dev/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-09-07T21:23:32.359958Z", + "publishedAt": "2026-09-07T21:23:32.359958Z", + "updatedAt": "2026-09-07T21:23:32.359958Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "agency.ottobot/contractor-license-changes", + "description": "Did this contractor's licence change? Observed lapses and reinstatements, not a snapshot.", + "title": "Contractor Licence Changes", + "repository": { + "url": "https://github.com/modelcontextprotocol/registry", + "source": "github" + }, + "version": "0.1.3", + "remotes": [ + { + "type": "streamable-http", + "url": "https://license-changes-mcp.ottobot2025.workers.dev/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-09-07T23:30:00.319394Z", + "publishedAt": "2026-09-07T23:30:00.319394Z", + "updatedAt": "2026-09-07T23:30:00.319394Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "agency.ottobot/licensed-house-painters", + "description": "Find licensed house painters in 11 US states, with dated license status from state boards.", + "title": "Licensed House Painters", + "repository": { + "url": "https://github.com/modelcontextprotocol/registry", + "source": "github" + }, + "version": "0.1.2", + "remotes": [ + { + "type": "streamable-http", + "url": "https://house-painters.ottobot.agency/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-09-26T14:57:01.113801Z", + "publishedAt": "2026-09-26T14:57:01.113801Z", + "updatedAt": "2026-09-26T14:57:01.113801Z", + "isLatest": true + } + } + } + ], + "metadata": { + "nextCursor": "agency.ottobot/licensed-house-painters:0.1.2", + "count": 20 + } +} diff --git a/crates/tinymcp/src/registry/sources/official/mod_tests.rs b/crates/tinymcp/src/registry/sources/official/mod_tests.rs index 944902c..2d17757 100644 --- a/crates/tinymcp/src/registry/sources/official/mod_tests.rs +++ b/crates/tinymcp/src/registry/sources/official/mod_tests.rs @@ -192,6 +192,160 @@ fn a_name_is_derived_from_its_last_segment_with_separators_spaced() { } } +// --------------------------------------------------------------------------- +// Icons +// --------------------------------------------------------------------------- + +/// A page recorded from the live registry with `version=latest`. +const LATEST_PAGE: &str = include_str!("fixtures/latest_page.json"); + +/// The rows of a recorded page. +fn recorded(page: &str) -> Vec { + serde_json::from_str::(page) + .expect("the recorded page decodes") + .into_summaries() +} + +/// The icon a recorded row ended up with. +fn icon_of<'a>(rows: &'a [tinymcp_bus::RegistryServerSummary], name: &str) -> Option<&'a str> { + rows.iter() + .find(|row| row.qualified_name == name) + .unwrap_or_else(|| panic!("{name} is on the recorded page")) + .icon_url + .as_deref() +} + +#[test] +fn a_recorded_page_carries_its_declared_icons() { + let rows = recorded(LATEST_PAGE); + + assert_eq!( + icon_of(&rows, "ae.projectory/mcp"), + Some("https://projectory.ae/apple-touch-icon.png") + ); + assert_eq!( + icon_of(&rows, "africa.ugc/ugc"), + Some("https://ugc.africa/icon-512.png") + ); + assert_eq!(icon_of(&rows, "ac.snag/snag"), None); +} + +#[test] +fn a_raster_icon_is_preferred_over_an_svg_listed_first() { + let rows = recorded(LATEST_PAGE); + + assert_eq!( + icon_of(&rows, "ae.plantguide/dubai-gardening"), + Some("https://plantguide.ae/icons/icon-512.png") + ); +} + +#[test] +fn an_svg_is_used_when_it_is_the_only_icon() { + let server: OfficialServer = serde_json::from_value(json!({ + "name": "svg/only", + "icons": [{ "src": "https://svg.test/mark.svg", "mimeType": "image/svg+xml" }], + })) + .unwrap(); + + assert_eq!( + server.best_icon().as_deref(), + Some("https://svg.test/mark.svg") + ); +} + +#[test] +fn an_svg_is_recognised_by_its_extension_when_no_type_is_declared() { + let server: OfficialServer = serde_json::from_value(json!({ + "name": "mixed/icons", + "icons": [ + { "src": "https://icons.test/mark.SVG?v=2" }, + { "src": "https://icons.test/mark.webp" }, + ], + })) + .unwrap(); + + assert_eq!( + server.best_icon().as_deref(), + Some("https://icons.test/mark.webp") + ); +} + +#[test] +fn a_blank_icon_source_is_skipped() { + let server: OfficialServer = serde_json::from_value(json!({ + "name": "blank/icon", + "icons": [ + { "src": " ", "mimeType": "image/png" }, + { "src": "https://icons.test/mark.svg", "mimeType": "image/svg+xml" }, + ], + })) + .unwrap(); + + assert_eq!( + server.best_icon().as_deref(), + Some("https://icons.test/mark.svg") + ); +} + +#[test] +fn the_legacy_icon_url_still_answers_when_no_icons_are_declared() { + let server: OfficialServer = serde_json::from_value(json!({ + "name": "legacy/icon", + "iconUrl": "https://legacy.test/icon.png", + })) + .unwrap(); + + assert_eq!( + server.best_icon().as_deref(), + Some("https://legacy.test/icon.png") + ); +} + +#[test] +fn declared_icons_win_over_the_legacy_icon_url() { + let server: OfficialServer = serde_json::from_value(json!({ + "name": "both/icons", + "iconUrl": "https://legacy.test/icon.png", + "icons": [{ "src": "https://icons.test/icon.png", "mimeType": "image/png" }], + })) + .unwrap(); + + assert_eq!( + server.best_icon().as_deref(), + Some("https://icons.test/icon.png") + ); +} + +#[test] +fn a_server_declaring_no_icon_has_none() { + let server: OfficialServer = serde_json::from_value(json!({ + "name": "no/icon", + "iconUrl": " ", + })) + .unwrap(); + + assert_eq!(server.best_icon(), None); +} + +#[test] +fn a_detail_record_carries_the_preferred_icon() { + let server: OfficialServer = serde_json::from_value(json!({ + "name": "detail/icon", + "icons": [ + { "src": "https://icons.test/mark.svg", "mimeType": "image/svg+xml" }, + { "src": "https://icons.test/mark.png", "mimeType": "image/png" }, + ], + "remotes": [{ "url": "https://api.test/mcp" }], + })) + .unwrap(); + + assert_eq!( + server.into_detail().icon_url.as_deref(), + Some("https://icons.test/mark.png") + ); +} + // --------------------------------------------------------------------------- // Trust signals // --------------------------------------------------------------------------- diff --git a/crates/tinymcp/src/registry/sources/official/types.rs b/crates/tinymcp/src/registry/sources/official/types.rs index a55dc46..0d22deb 100644 --- a/crates/tinymcp/src/registry/sources/official/types.rs +++ b/crates/tinymcp/src/registry/sources/official/types.rs @@ -114,6 +114,8 @@ pub(super) struct OfficialServer { title: Option, #[serde(default)] description: Option, + #[serde(default)] + icons: Vec, #[serde(default, rename = "iconUrl")] icon_url: Option, /// Hosted endpoints. @@ -127,6 +129,28 @@ pub(super) struct OfficialServer { } impl OfficialServer { + /// The icon to show for this server. + /// + /// A raster image ahead of an SVG, and an SVG when it is the only one + /// declared. The legacy `iconUrl` answers when `icons` names nothing + /// usable. + pub(super) fn best_icon(&self) -> Option { + let usable = || self.icons.iter().filter(|icon| icon.source().is_some()); + + usable() + .find(|icon| !icon.is_svg()) + .or_else(|| usable().next()) + .and_then(OfficialIcon::source) + .map(ToString::to_string) + .or_else(|| { + self.icon_url + .as_deref() + .map(str::trim) + .filter(|url| !url.is_empty()) + .map(ToString::to_string) + }) + } + /// The declared vendor site, when it declares a non-blank one. fn website(&self) -> Option { self.website_url @@ -185,6 +209,7 @@ impl OfficialServer { /// This server as a catalog row. pub(super) fn into_summary(self) -> RegistryServerSummary { let display_name = self.display_name(); + let icon_url = self.best_icon(); let website_url = self.website(); let auth_kind = self .declares_secret_credential() @@ -194,7 +219,7 @@ impl OfficialServer { qualified_name: self.name, display_name, description: self.description, - icon_url: self.icon_url, + icon_url, // The official registry publishes no install count. use_count: 0, is_deployed: !self.remotes.is_empty(), @@ -210,6 +235,7 @@ impl OfficialServer { /// This server as a detail record, with one connection per way in. pub(super) fn into_detail(self) -> RegistryServerDetail { let display_name = self.display_name(); + let icon_url = self.best_icon(); let mut connections = Vec::with_capacity(self.remotes.len() + self.packages.len()); @@ -239,7 +265,7 @@ impl OfficialServer { qualified_name: self.name, display_name, description: self.description, - icon_url: self.icon_url, + icon_url, connections, source: SOURCE_MCP_OFFICIAL.to_string(), extra: ExtraFields::new(), @@ -247,6 +273,37 @@ impl OfficialServer { } } +/// One declared icon. +#[derive(Debug, Clone, Deserialize)] +struct OfficialIcon { + #[serde(default)] + src: Option, + #[serde(default, rename = "mimeType")] + mime_type: Option, +} + +impl OfficialIcon { + /// The icon's address, when it is not blank. + fn source(&self) -> Option<&str> { + self.src + .as_deref() + .map(str::trim) + .filter(|src| !src.is_empty()) + } + + /// Whether the icon is an SVG, by declared type or by file extension. + fn is_svg(&self) -> bool { + if let Some(mime_type) = self.mime_type.as_deref() { + return mime_type.trim().eq_ignore_ascii_case("image/svg+xml"); + } + + self.source().is_some_and(|src| { + let path = src.split(['?', '#']).next().unwrap_or(src); + path.to_ascii_lowercase().ends_with(".svg") + }) + } +} + /// A hosted endpoint. #[derive(Debug, Clone, Deserialize)] pub(super) struct OfficialRemote { From 45ca39d185a01ccaf6ed938f979c13d7f6a0b4f5 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 17:28:40 +0530 Subject: [PATCH 02/27] fix(registry): list only the latest version of each server (#42) --- .../official/fixtures/every_version_page.json | 579 ++++++++++++++++++ .../src/registry/sources/official/mod.rs | 28 +- .../registry/sources/official/mod_tests.rs | 155 ++++- .../src/registry/sources/official/types.rs | 72 ++- 4 files changed, 808 insertions(+), 26 deletions(-) create mode 100644 crates/tinymcp/src/registry/sources/official/fixtures/every_version_page.json diff --git a/crates/tinymcp/src/registry/sources/official/fixtures/every_version_page.json b/crates/tinymcp/src/registry/sources/official/fixtures/every_version_page.json new file mode 100644 index 0000000..fbe0578 --- /dev/null +++ b/crates/tinymcp/src/registry/sources/official/fixtures/every_version_page.json @@ -0,0 +1,579 @@ +{ + "servers": [ + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ac.inference.sh/mcp", + "description": "Run 150+ AI apps — image, video, audio, LLMs, 3D and more. Browse, execute, stream results.", + "title": "inference.sh", + "version": "1.0.0", + "remotes": [ + { + "type": "streamable-http", + "url": "https://sh.inference.ac" + }, + { + "type": "streamable-http", + "url": "https://api.inference.sh/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-04-13T17:32:20.852269Z", + "publishedAt": "2026-04-13T17:32:20.852269Z", + "updatedAt": "2026-04-13T17:32:20.852269Z", + "isLatest": false + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ac.inference.sh/mcp", + "description": "Run 150+ AI apps — image, video, audio, LLMs, 3D and more. Browse, execute, stream results.", + "title": "inference.sh", + "version": "1.0.1", + "remotes": [ + { + "type": "streamable-http", + "url": "https://sh.inference.ac" + }, + { + "type": "streamable-http", + "url": "https://api.inference.sh/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-04-13T17:33:26.613537Z", + "publishedAt": "2026-04-13T17:33:26.613537Z", + "updatedAt": "2026-04-13T17:33:26.613537Z", + "isLatest": false + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ac.inference.sh/mcp", + "description": "run any ai model. compose agents, stack knowledge, connect tools. one api, pay per run.", + "title": "inference.sh", + "version": "2.0.0", + "remotes": [ + { + "type": "streamable-http", + "url": "https://sh.inference.ac" + }, + { + "type": "streamable-http", + "url": "https://api.inference.sh/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-07-20T18:02:33.295284Z", + "publishedAt": "2026-07-20T18:02:33.295284Z", + "updatedAt": "2026-07-20T18:02:33.295284Z", + "isLatest": false + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ac.inference.sh/mcp", + "description": "run any ai model. compose agents, stack knowledge, connect tools. one api, pay per run.", + "title": "inference.sh", + "version": "2.0.1", + "remotes": [ + { + "type": "streamable-http", + "url": "https://api.inference.sh/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-07-27T10:44:51.359634Z", + "publishedAt": "2026-07-27T10:44:51.359634Z", + "updatedAt": "2026-07-27T10:44:51.359634Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ac.snag/snag", + "description": "Gives your coding agent the captured console, network, replay and screenshot evidence for a bug.", + "version": "1.0.0", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.snag.ac/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-09-09T10:35:53.194664Z", + "publishedAt": "2026-09-09T10:35:53.194664Z", + "updatedAt": "2026-09-09T10:35:53.194664Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ac.tandem/docs-mcp", + "description": "Remote MCP server for Tandem docs, install guides, SDKs, workflows, and agent setup help.", + "repository": { + "url": "https://github.com/frumu-ai/tandem", + "source": "github" + }, + "version": "0.3.0", + "remotes": [ + { + "type": "streamable-http", + "url": "https://tandem.ac/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-04-02T11:22:40.005172Z", + "publishedAt": "2026-04-02T11:22:40.005172Z", + "updatedAt": "2026-04-02T11:22:40.005172Z", + "isLatest": false + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ac.tandem/docs-mcp", + "description": "Remote MCP server for Tandem docs, install guides, SDKs, workflows, and agent setup help.", + "repository": { + "url": "https://github.com/frumu-ai/tandem", + "source": "github" + }, + "version": "0.3.1", + "websiteUrl": "https://tandem.ac/docs-mcp", + "remotes": [ + { + "type": "streamable-http", + "url": "https://tandem.ac/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-04-02T11:40:41.429277Z", + "publishedAt": "2026-04-02T11:40:41.429277Z", + "updatedAt": "2026-04-02T11:40:41.429277Z", + "isLatest": false + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ac.tandem/docs-mcp", + "description": "Remote MCP server for Tandem docs, install guides, SDKs, workflows, and agent setup help.", + "repository": { + "url": "https://github.com/frumu-ai/tandem", + "source": "github" + }, + "version": "0.3.2", + "websiteUrl": "https://tandem.ac/docs-mcp", + "remotes": [ + { + "type": "streamable-http", + "url": "https://tandem.ac/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-04-22T21:06:34.500049Z", + "publishedAt": "2026-04-22T21:06:34.500049Z", + "updatedAt": "2026-04-22T21:06:34.500049Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ad.getle/leads", + "description": "Find B2B leads with verified work emails, find and verify emails, and run cold email outreach.", + "title": "Getlead", + "repository": { + "url": "https://github.com/Adgrowofficial/getlead-mcp", + "source": "github" + }, + "version": "1.0.0", + "websiteUrl": "https://getle.ad/mcp", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.getle.ad/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-09-26T12:28:37.779048Z", + "publishedAt": "2026-09-26T12:28:37.779048Z", + "updatedAt": "2026-09-26T12:28:37.779048Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ad.inside/inside-ads", + "description": "Telegram ad exchange: estimate reach and cost with no account, then create and run campaigns.", + "title": "Inside Ads", + "version": "1.0.0", + "websiteUrl": "https://inside.ad", + "remotes": [ + { + "type": "streamable-http", + "url": "https://app.inside.ad/api/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-08-18T20:55:46.452926Z", + "publishedAt": "2026-08-18T20:55:46.452926Z", + "updatedAt": "2026-08-18T20:55:46.452926Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ae.brainy/grocery-prices", + "description": "Compare Dubai grocery lists, observed prices, delivery rules and indicative online offers in AED.", + "title": "Dubai Grocery Prices", + "version": "1.0.0", + "websiteUrl": "https://prices.brainy.ae/developers.html", + "remotes": [ + { + "type": "streamable-http", + "url": "https://prices.brainy.ae/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-09-30T10:34:55.977998Z", + "publishedAt": "2026-09-30T10:34:55.977998Z", + "updatedAt": "2026-09-30T10:34:55.977998Z", + "isLatest": false + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ae.brainy/grocery-prices", + "description": "Dubai cost of living: groceries, schools, fuel, rent, utilities, phone plans, electronics and visas", + "title": "Dubai Cost of Living Prices", + "version": "1.1.0", + "websiteUrl": "https://prices.brainy.ae/developers.html", + "remotes": [ + { + "type": "streamable-http", + "url": "https://prices.brainy.ae/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-10-02T04:58:59.331523Z", + "publishedAt": "2026-10-02T04:58:59.331523Z", + "updatedAt": "2026-10-02T04:58:59.331523Z", + "isLatest": false + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ae.brainy/grocery-prices", + "description": "Source-backed UAE costs: groceries, schools, housing, transport, utilities, telecom and relocation", + "title": "Brainy Prices — UAE Cost of Living", + "version": "2.0.0", + "websiteUrl": "https://prices.brainy.ae/developers.html", + "remotes": [ + { + "type": "streamable-http", + "url": "https://prices.brainy.ae/mcp/v2" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-10-04T02:29:51.000704Z", + "publishedAt": "2026-10-04T02:29:51.000704Z", + "updatedAt": "2026-10-04T02:29:51.000704Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ae.datadubai/dubai-real-estate", + "description": "Dubai property prices, AED/sqft, sales, rents, yields and projects from DLD registered transactions.", + "title": "Dubai Data — Dubai real estate statistics", + "repository": { + "url": "https://github.com/datadubai/dubai-real-estate-dld", + "source": "github" + }, + "version": "1.1.0", + "websiteUrl": "https://datadubai.ae/mcp/", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.datadubai.ae/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-09-30T08:30:47.911063Z", + "publishedAt": "2026-09-30T08:30:47.911063Z", + "updatedAt": "2026-09-30T08:30:47.911063Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ae.grovisor/grotax", + "description": "UAE Corporate Tax: CT computation, SBR, penalties, free zone test, TP and health-check.", + "title": "groTAX by Grovisor", + "version": "1.2.0", + "websiteUrl": "https://grovisor.ae/tools", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.grovisor.ae/" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-10-02T03:01:23.583792Z", + "publishedAt": "2026-10-02T03:01:23.583792Z", + "updatedAt": "2026-10-02T03:01:23.583792Z", + "isLatest": false + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ae.grovisor/grotax", + "description": "UAE Corporate Tax: CT computation, SBR, penalties, free zone test, TP and health-check.", + "title": "groTAX by Grovisor", + "version": "1.3.0", + "websiteUrl": "https://grovisor.ae/tools", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.grovisor.ae/" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-10-06T01:10:14.813856Z", + "publishedAt": "2026-10-06T01:10:14.813856Z", + "updatedAt": "2026-10-06T01:10:14.813856Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ae.plantguide/dubai-gardening", + "description": "What to plant in Dubai each month, plant care in the heat, garden styles and Garden Care prices.", + "title": "Plant Guide: Dubai gardening research", + "version": "1.0.0", + "websiteUrl": "https://plantguide.ae/agents", + "icons": [ + { + "src": "https://plantguide.ae/brand/plantguide-mark.svg", + "mimeType": "image/svg+xml", + "sizes": [ + "any" + ] + }, + { + "src": "https://plantguide.ae/icons/icon-512.png", + "mimeType": "image/png", + "sizes": [ + "512x512" + ] + } + ], + "remotes": [ + { + "type": "streamable-http", + "url": "https://plantguide.ae/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-10-07T06:55:35.364125Z", + "publishedAt": "2026-10-07T06:55:35.364125Z", + "updatedAt": "2026-10-07T06:55:35.364125Z", + "isLatest": false + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ae.plantguide/dubai-gardening", + "description": "What to plant in Dubai, indoor plants, plant care in the heat, garden styles and Garden Care prices.", + "title": "Plant Guide: Dubai gardening research", + "version": "1.1.0", + "websiteUrl": "https://plantguide.ae/agents", + "icons": [ + { + "src": "https://plantguide.ae/brand/plantguide-mark.svg", + "mimeType": "image/svg+xml", + "sizes": [ + "any" + ] + }, + { + "src": "https://plantguide.ae/icons/icon-512.png", + "mimeType": "image/png", + "sizes": [ + "512x512" + ] + } + ], + "remotes": [ + { + "type": "streamable-http", + "url": "https://plantguide.ae/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-10-07T08:44:40.122336Z", + "publishedAt": "2026-10-07T08:44:40.122336Z", + "updatedAt": "2026-10-07T08:44:40.122336Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ae.projectory/mcp", + "description": "Find UAE off-plan developments and registered residential sales.", + "title": "Projectory", + "version": "0.6.0", + "websiteUrl": "https://projectory.ae/mcp/", + "icons": [ + { + "src": "https://projectory.ae/apple-touch-icon.png", + "mimeType": "image/png", + "sizes": [ + "180x180" + ] + } + ], + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.projectory.ae/mcp" + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-09-29T20:37:54.5633Z", + "publishedAt": "2026-09-29T20:37:54.5633Z", + "updatedAt": "2026-09-29T20:37:54.5633Z", + "isLatest": true + } + } + }, + { + "server": { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "ae.propick/propick", + "description": "Manage your real-estate stock on Propick (Dubai): bulk listing sync, lookups and run reports.", + "title": "Propick Integration MCP", + "version": "1.0.0", + "websiteUrl": "https://propick.ae", + "remotes": [ + { + "type": "streamable-http", + "url": "https://propick.ae/mcp", + "headers": [ + { + "description": "Bearer (ppk_live_...) issued in the Propick cabinet when creating an import channel", + "isRequired": true, + "isSecret": true, + "name": "Authorization" + } + ] + } + ] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "statusChangedAt": "2026-08-25T11:16:56.879334Z", + "publishedAt": "2026-08-25T11:16:56.879334Z", + "updatedAt": "2026-08-25T11:16:56.879334Z", + "isLatest": true + } + } + } + ], + "metadata": { + "nextCursor": "ae.propick/propick:1.0.0", + "count": 20 + } +} diff --git a/crates/tinymcp/src/registry/sources/official/mod.rs b/crates/tinymcp/src/registry/sources/official/mod.rs index 1c075f7..480d7dc 100644 --- a/crates/tinymcp/src/registry/sources/official/mod.rs +++ b/crates/tinymcp/src/registry/sources/official/mod.rs @@ -1,8 +1,9 @@ //! The official `modelcontextprotocol/registry` catalog. //! -//! `GET /v0/servers` lists; `GET /v0/servers/{name}/versions` details — the -//! registry has no single-server endpoint, so a detail lookup reads the version -//! list and takes the newest. +//! `GET /v0/servers?version=latest` lists one row per server; +//! `GET /v0/servers/{name}/versions` details — the registry has no +//! single-server endpoint, so a detail lookup reads the version list and takes +//! the one marked latest. //! //! # Pages over cursors //! @@ -35,7 +36,7 @@ use std::time::Duration; use parking_lot::Mutex; use serde_json::Value; -use self::types::{OfficialListResponse, OfficialServer}; +use self::types::{OfficialListResponse, OfficialServer, latest_version}; use super::encode::encode_path_segment; use super::shared::{MAX_ERROR_BODY_BYTES, cache, truncate}; use super::types::non_blank_env; @@ -178,18 +179,14 @@ impl McpOfficialRegistry { let document: Value = serde_json::from_str(&body) .map_err(|error| Error::malformed(format!("official versions response: {error}")))?; - // The versions endpoint answers with the same envelope array as the - // list endpoint; the newest version leads it. - let newest = document - .pointer("/servers/0/server") - .ok_or_else(|| Error::UnknownServer { - server: qualified_name.to_string(), - })?; + let latest = latest_version(&document).ok_or_else(|| Error::UnknownServer { + server: qualified_name.to_string(), + })?; // Cached as the inner object, which is what the hit path above reads. - cache(store, &cache_key, &newest.to_string()); + cache(store, &cache_key, &latest.to_string()); - let server: OfficialServer = serde_json::from_value(newest.clone()) + let server: OfficialServer = serde_json::from_value(latest.clone()) .map_err(|error| Error::malformed(format!("official server record: {error}")))?; Ok(server.into_detail()) @@ -275,7 +272,8 @@ impl McpOfficialRegistry { let url = format!("{}/v0/servers", base_url(auth)); let mut request = self .request(auth, &url) - .query(&[("limit", limit.to_string())]); + .query(&[("limit", limit.to_string())]) + .query(&[("version", "latest")]); if !query.is_empty() { request = request.query(&[("search", query)]); } @@ -322,7 +320,7 @@ impl McpOfficialRegistry { /// The cache key for one page of one search. fn search_cache_key(query: &str, page: u32, page_size: u32) -> String { - format!("mcp_official:search:{query}:{page}:{page_size}") + format!("mcp_official:search:latest:{query}:{page}:{page_size}") } /// Records which cursor produced a page. diff --git a/crates/tinymcp/src/registry/sources/official/mod_tests.rs b/crates/tinymcp/src/registry/sources/official/mod_tests.rs index 2d17757..f782064 100644 --- a/crates/tinymcp/src/registry/sources/official/mod_tests.rs +++ b/crates/tinymcp/src/registry/sources/official/mod_tests.rs @@ -152,6 +152,118 @@ fn a_repeated_name_appears_once() { assert_eq!(response.into_summaries().len(), 1); } +// --------------------------------------------------------------------------- +// Versions +// --------------------------------------------------------------------------- + +/// An envelope for one version of `name`, marked latest or not. +fn version(name: &str, title: &str, latest: Option) -> Value { + let mut row = json!({ + "server": { + "name": name, + "title": title, + "remotes": [{ "url": "https://api.test/mcp" }], + }, + }); + if let Some(latest) = latest { + row["_meta"] = json!({ + "io.modelcontextprotocol.registry/official": { "status": "active", "isLatest": latest }, + }); + } + row +} + +#[test] +fn a_page_listing_every_version_keeps_one_row_per_server() { + let rows = recorded(EVERY_VERSION_PAGE); + let names: std::collections::BTreeSet<&str> = + rows.iter().map(|row| row.qualified_name.as_str()).collect(); + + assert_eq!(rows.len(), 11); + assert_eq!(names.len(), rows.len(), "every server appears once"); +} + +#[test] +fn the_version_marked_latest_is_the_one_kept() { + let rows = recorded(EVERY_VERSION_PAGE); + let brainy = rows + .iter() + .find(|row| row.qualified_name == "ae.brainy/grocery-prices") + .expect("listed"); + + assert_eq!(brainy.display_name, "Brainy Prices — UAE Cost of Living"); +} + +#[test] +fn a_recorded_latest_page_keeps_every_row() { + assert_eq!(recorded(LATEST_PAGE).len(), 20); +} + +#[test] +fn a_page_keeps_the_order_of_each_server_first_row() { + let rows = recorded(EVERY_VERSION_PAGE); + + assert_eq!(rows[0].qualified_name, "ac.inference.sh/mcp"); + assert_eq!(rows[1].qualified_name, "ac.snag/snag"); +} + +#[test] +fn a_latest_row_is_not_displaced_by_a_later_one() { + let response = list_response( + &json!([ + version("same/server", "Latest", Some(true)), + version("same/server", "Listed after", Some(false)), + ]), + None, + ); + + assert_eq!(response.into_summaries()[0].display_name, "Latest"); +} + +#[test] +fn without_a_latest_mark_the_last_listed_version_is_kept() { + let response = list_response( + &json!([ + version("same/server", "Older", None), + version("same/server", "Newer", None), + ]), + None, + ); + + let rows = response.into_summaries(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].display_name, "Newer"); +} + +#[test] +fn a_versions_document_yields_the_version_marked_latest() { + let document = json!({ + "servers": [ + version("same/server", "Older", Some(false)), + version("same/server", "Latest", Some(true)), + ], + }); + + let latest = super::types::latest_version(&document).expect("a version"); + assert_eq!(latest["title"], "Latest"); +} + +#[test] +fn a_versions_document_with_no_mark_yields_its_first_version() { + let document = json!({ + "servers": [version("same/server", "First", None), version("same/server", "Second", None)], + }); + + let latest = super::types::latest_version(&document).expect("a version"); + assert_eq!(latest["title"], "First"); +} + +#[test] +fn a_versions_document_with_no_servers_yields_nothing() { + assert!(super::types::latest_version(&json!({})).is_none()); + assert!(super::types::latest_version(&json!({ "servers": [] })).is_none()); +} + // --------------------------------------------------------------------------- // Names // --------------------------------------------------------------------------- @@ -199,6 +311,10 @@ fn a_name_is_derived_from_its_last_segment_with_separators_spaced() { /// A page recorded from the live registry with `version=latest`. const LATEST_PAGE: &str = include_str!("fixtures/latest_page.json"); +/// A page recorded from the live registry without `version=latest`, so it +/// lists every version of each server. +const EVERY_VERSION_PAGE: &str = include_str!("fixtures/every_version_page.json"); + /// The rows of a recorded page. fn recorded(page: &str) -> Vec { serde_json::from_str::(page) @@ -730,7 +846,7 @@ fn a_response_with_no_metadata_has_no_cursor() { fn a_cache_key_separates_query_page_and_size() { assert_eq!( search_cache_key("weather", 2, 50), - "mcp_official:search:weather:2:50" + "mcp_official:search:latest:weather:2:50" ); assert_ne!(search_cache_key("a", 1, 20), search_cache_key("a", 2, 20)); assert_ne!(search_cache_key("a", 1, 20), search_cache_key("a", 1, 50)); @@ -766,6 +882,7 @@ use tinymcp_bus::McpRegistryAuthConfig; struct Seen { pages: AtomicUsize, cursors: Mutex>>, + versions: Mutex>>, authorization: Mutex>, detail_path: Mutex>, } @@ -811,6 +928,7 @@ async fn paged_registry(pages: usize) -> (String, Arc) { let cursor = param(&uri, "cursor"); seen.cursors.lock().push(cursor.clone()); + seen.versions.lock().push(param(&uri, "version")); let page: usize = cursor .as_deref() @@ -898,6 +1016,21 @@ async fn the_first_page_is_fetched_without_a_cursor() { assert_eq!(seen.cursors.lock().as_slice(), &[None]); } +#[tokio::test] +async fn every_page_asks_for_the_latest_version_only() { + let (base, seen) = paged_registry(3).await; + + adapter() + .search(&store(), &auth_at(&base), &cursors(), "", 2, 20) + .await + .unwrap(); + + assert_eq!( + seen.versions.lock().as_slice(), + &[Some("latest".to_string()), Some("latest".to_string())] + ); +} + #[tokio::test] async fn a_page_with_more_behind_it_reports_one_page_beyond() { // A bound, not a total: knowing the true count would mean walking the whole @@ -1159,6 +1292,26 @@ async fn a_detail_lookup_takes_the_newest_version() { assert_eq!(detail.qualified_name, "@acme/weather"); } +#[tokio::test] +async fn a_detail_lookup_takes_the_version_marked_latest() { + let app = Router::new().fallback(get(|| async { + axum::Json(json!({ + "servers": [ + version("@acme/weather", "Weather 1", Some(false)), + version("@acme/weather", "Weather 2", Some(true)), + ], + })) + })); + let base = serve(app).await; + + let detail = adapter() + .get(&store(), &auth_at(&base), "@acme/weather") + .await + .expect("the lookup succeeds"); + + assert_eq!(detail.display_name, "Weather 2"); +} + #[tokio::test] async fn a_repeated_detail_lookup_is_served_from_the_cache() { let (base, seen) = paged_registry(1).await; diff --git a/crates/tinymcp/src/registry/sources/official/types.rs b/crates/tinymcp/src/registry/sources/official/types.rs index 0d22deb..8c0714d 100644 --- a/crates/tinymcp/src/registry/sources/official/types.rs +++ b/crates/tinymcp/src/registry/sources/official/types.rs @@ -5,6 +5,8 @@ //! exception, marked below, where permissiveness caused the bug it was supposed //! to prevent. +use std::collections::HashMap; + use serde::Deserialize; use serde_json::{Map, Value}; @@ -30,21 +32,41 @@ pub(super) struct OfficialListResponse { } impl OfficialListResponse { - /// The rows worth showing, deduplicated by name. + /// The rows worth showing, one per server. /// /// Drops anything that cannot actually be installed and anything the /// registry has deprecated. Both are noise: a row a user cannot install is /// a dead end they can only discover by trying. + /// + /// A page can list several versions of one server. The one the registry + /// marks latest is kept; without that mark, the last one listed is, since + /// the registry lists a server's versions oldest first. Each server keeps + /// the position of its first row. pub(super) fn into_summaries(self) -> Vec { - let mut seen = std::collections::HashSet::new(); + let mut order: Vec = Vec::new(); + let mut chosen: HashMap = HashMap::new(); - self.servers + for envelope in self + .servers .into_iter() .filter(|envelope| envelope.is_installable() && !envelope.is_deprecated()) - .filter_map(|envelope| { - seen.insert(envelope.server.name.clone()) - .then(|| envelope.server.into_summary()) - }) + { + match chosen.get(&envelope.server.name) { + Some(current) if current.is_latest() => {} + Some(_) => { + chosen.insert(envelope.server.name.clone(), envelope); + } + None => { + order.push(envelope.server.name.clone()); + chosen.insert(envelope.server.name.clone(), envelope); + } + } + } + + order + .iter() + .filter_map(|name| chosen.remove(name)) + .map(|envelope| envelope.server.into_summary()) .collect() } @@ -95,13 +117,43 @@ impl OfficialServerEnvelope { /// Absent metadata counts as not deprecated, which is what a row cached by /// an older build looks like. fn is_deprecated(&self) -> bool { - self.meta - .as_ref() - .and_then(|meta| meta.get(REGISTRY_META_KEY)) + registry_meta(self.meta.as_ref()) .and_then(|registry| registry.get("status")) .and_then(Value::as_str) == Some(STATUS_DEPRECATED) } + + /// Whether the registry marks this version as the server's latest. + fn is_latest(&self) -> bool { + marked_latest(self.meta.as_ref()) + } +} + +/// The registry's own bookkeeping inside a row's `_meta`. +fn registry_meta(meta: Option<&Value>) -> Option<&Value> { + meta.and_then(|meta| meta.get(REGISTRY_META_KEY)) +} + +/// Whether a row's `_meta` marks it as the server's latest version. +fn marked_latest(meta: Option<&Value>) -> bool { + registry_meta(meta) + .and_then(|registry| registry.get("isLatest")) + .and_then(Value::as_bool) + == Some(true) +} + +/// The server record to use from a versions response. +/// +/// The version the registry marks latest, or the first one listed when none +/// is marked. +pub(super) fn latest_version(document: &Value) -> Option<&Value> { + let envelopes = document.get("servers")?.as_array()?; + + envelopes + .iter() + .find(|envelope| marked_latest(envelope.get("_meta"))) + .or_else(|| envelopes.first()) + .and_then(|envelope| envelope.get("server")) } /// One server, as the official registry describes it. From b6d69b6ad9fe182f18d64cace37f495b417d0e23 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 17:31:16 +0530 Subject: [PATCH 03/27] fix(registry): give each registry request a budget and a typed timeout (#42) --- crates/tinymcp-bus/src/errors/mod.rs | 6 ++ crates/tinymcp-bus/src/errors/mod_tests.rs | 10 +- crates/tinymcp/src/error/mod.rs | 81 +++++++++++++++ crates/tinymcp/src/error/mod_tests.rs | 88 +++++++++++++++++ crates/tinymcp/src/registry/mod.rs | 2 +- crates/tinymcp/src/registry/sources/mod.rs | 5 +- .../tinymcp/src/registry/sources/mod_tests.rs | 51 +++++++++- .../src/registry/sources/official/mod.rs | 62 ++++++------ .../registry/sources/official/mod_tests.rs | 98 +++++++++++++++++++ crates/tinymcp/src/registry/sources/shared.rs | 56 +++++++++++ .../src/registry/sources/smithery/mod.rs | 34 +------ crates/tinymcp/src/registry/sources/types.rs | 82 ++++++++++++++++ 12 files changed, 508 insertions(+), 67 deletions(-) diff --git a/crates/tinymcp-bus/src/errors/mod.rs b/crates/tinymcp-bus/src/errors/mod.rs index e9341e9..4e33717 100644 --- a/crates/tinymcp-bus/src/errors/mod.rs +++ b/crates/tinymcp-bus/src/errors/mod.rs @@ -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] = &[ @@ -113,6 +118,7 @@ pub const ALL: &[&str] = &[ SERVER_BIND, CONFIG_DOC, INVALID_ARGUMENTS, + REGISTRY_TIMEOUT, ]; #[cfg(test)] diff --git a/crates/tinymcp-bus/src/errors/mod_tests.rs b/crates/tinymcp-bus/src/errors/mod_tests.rs index eea7697..94f321e 100644 --- a/crates/tinymcp-bus/src/errors/mod_tests.rs +++ b/crates/tinymcp-bus/src/errors/mod_tests.rs @@ -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() { @@ -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" + ); +} diff --git a/crates/tinymcp/src/error/mod.rs b/crates/tinymcp/src/error/mod.rs index 24acbb1..afcb01e 100644 --- a/crates/tinymcp/src/error/mod.rs +++ b/crates/tinymcp/src/error/mod.rs @@ -32,8 +32,12 @@ //! and user interfaces, and a URL with credentials in its userinfo would reach //! all three. +use std::time::Duration; + use tinymcp_bus::{CommandKind, McpAuthChallenge}; +use crate::registry::RegistryOperation; + /// Errors returned by this crate. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -119,6 +123,21 @@ pub enum Error { source: Box, }, + /// An upstream registry did not answer within its time budget. + /// + /// Transient. A caller shows the catalog as unavailable for now rather + /// than reporting a failure; [`Self::is_registry_unavailable`] groups it + /// with the other upstream outages. + #[error("mcp registry {operation} timed out after {}ms at `{endpoint}`", timeout.as_millis())] + RegistryTimeout { + /// The redacted endpoint that did not answer. + endpoint: String, + /// What was being asked of it. + operation: RegistryOperation, + /// The budget it had. + timeout: Duration, + }, + /// A server negotiated a protocol version this client does not speak. /// /// Continuing would mean guessing at framing that has never been tested, @@ -394,6 +413,7 @@ impl Error { Self::MissingRuntime { .. } => errors::MISSING_RUNTIME, Self::Http { .. } => errors::HTTP, Self::Transport { .. } => errors::TRANSPORT, + Self::RegistryTimeout { .. } => errors::REGISTRY_TIMEOUT, Self::UnsupportedProtocolVersion { .. } => errors::UNSUPPORTED_PROTOCOL_VERSION, Self::MalformedResponse { .. } => errors::MALFORMED_RESPONSE, Self::Rpc { .. } => errors::RPC, @@ -472,6 +492,67 @@ impl Error { matches!(self, Self::MissingRuntime { .. }) } + /// Whether this error is a request that ran out of time. + /// + /// A registry timeout, or a transport failure the HTTP client reports as + /// a timeout. + /// + /// # Examples + /// + /// ``` + /// # use std::time::Duration; + /// # use tinymcp::Error; + /// # use tinymcp::registry::RegistryOperation; + /// let error = Error::RegistryTimeout { + /// endpoint: "https://registry.test".into(), + /// operation: RegistryOperation::Search, + /// timeout: Duration::from_secs(8), + /// }; + /// assert!(error.is_timeout()); + /// ``` + #[must_use] + pub fn is_timeout(&self) -> bool { + match self { + Self::RegistryTimeout { .. } => true, + Self::Transport { source, .. } => source.is_timeout(), + _ => false, + } + } + + /// Whether this error means "the upstream could not answer right now". + /// + /// A timeout, a transport failure, or a status that names the upstream + /// rather than the request: 408, 429, or any 5xx. A caller serves what it + /// already has, or offers a retry, instead of reporting the request as + /// wrong. + /// + /// # Examples + /// + /// ``` + /// # use tinymcp::Error; + /// let outage = Error::Http { + /// endpoint: "https://registry.test".into(), + /// status: 503, + /// body: String::new(), + /// }; + /// assert!(outage.is_registry_unavailable()); + /// + /// let refused = Error::Http { + /// endpoint: "https://registry.test".into(), + /// status: 400, + /// body: String::new(), + /// }; + /// assert!(!refused.is_registry_unavailable()); + /// ``` + #[must_use] + pub const fn is_registry_unavailable(&self) -> bool { + match self { + Self::RegistryTimeout { .. } | Self::Transport { .. } => true, + Self::Http { status, .. } => matches!(*status, 408 | 429 | 500..=599), + _ => false, + } + } + /// Whether the 401 advertised OAuth. /// /// `false` for every error that is not a 401. A server that advertises diff --git a/crates/tinymcp/src/error/mod_tests.rs b/crates/tinymcp/src/error/mod_tests.rs index 962820b..8c1c0a6 100644 --- a/crates/tinymcp/src/error/mod_tests.rs +++ b/crates/tinymcp/src/error/mod_tests.rs @@ -27,9 +27,28 @@ fn bare_unauthorized_error() -> Error { } } +/// A registry search that ran out of time. +fn registry_timeout() -> Error { + Error::RegistryTimeout { + endpoint: "https://registry.test/v0/servers".into(), + operation: crate::registry::RegistryOperation::Search, + timeout: std::time::Duration::from_secs(8), + } +} + +/// An HTTP failure with `status`. +fn http(status: u16) -> Error { + Error::Http { + endpoint: "https://registry.test".into(), + status, + body: String::new(), + } +} + /// One of every variant that does not need a live `reqwest` failure to build. fn assorted_other_errors() -> Vec { vec![ + registry_timeout(), Error::Http { endpoint: "https://example.test".into(), status: 500, @@ -423,3 +442,72 @@ fn server_failures_name_what_failed_and_keep_their_cause() { ); assert_eq!(bind.source().unwrap().to_string(), "address in use"); } + +// --------------------------------------------------------------------------- +// Registry outages +// --------------------------------------------------------------------------- + +#[test] +fn a_registry_timeout_names_the_operation_and_its_budget() { + let rendered = registry_timeout().to_string(); + + assert!(rendered.contains("search"), "{rendered}"); + assert!(rendered.contains("8000ms"), "{rendered}"); + assert!( + rendered.contains("https://registry.test/v0/servers"), + "{rendered}" + ); +} + +#[test] +fn a_registry_timeout_travels_under_its_own_name() { + assert_eq!( + registry_timeout().wire_name(), + tinymcp_bus::errors::REGISTRY_TIMEOUT + ); +} + +#[test] +fn a_registry_timeout_is_a_timeout_and_an_outage() { + let error = registry_timeout(); + + assert!(error.is_timeout()); + assert!(error.is_registry_unavailable()); + assert!(!error.is_unauthorized()); +} + +#[test] +fn a_transport_failure_is_an_outage_but_not_necessarily_a_timeout() { + let error = Error::Transport { + endpoint: "https://registry.test".into(), + source: Box::new(a_reqwest_error()), + }; + + assert!(error.is_registry_unavailable()); + assert!(!error.is_timeout()); +} + +#[test] +fn statuses_naming_the_upstream_are_outages() { + for status in [408, 429, 500, 502, 503, 504, 599] { + assert!(http(status).is_registry_unavailable(), "{status}"); + } +} + +#[test] +fn statuses_naming_the_request_are_not_outages() { + for status in [400, 401, 403, 404, 422, 600] { + assert!(!http(status).is_registry_unavailable(), "{status}"); + } + assert!(!http(503).is_timeout()); +} + +#[test] +fn only_outage_variants_count_as_registry_unavailable() { + for error in assorted_other_errors() { + let expected = matches!(error, Error::RegistryTimeout { .. } | Error::Http { .. }); + assert_eq!(error.is_registry_unavailable(), expected, "{error:?}"); + } + assert!(!oauth_challenge_error().is_registry_unavailable()); + assert!(!bare_unauthorized_error().is_timeout()); +} diff --git a/crates/tinymcp/src/registry/mod.rs b/crates/tinymcp/src/registry/mod.rs index b0250d0..6696ef3 100644 --- a/crates/tinymcp/src/registry/mod.rs +++ b/crates/tinymcp/src/registry/mod.rs @@ -28,7 +28,7 @@ pub use oauth::{ }; pub use ops::McpRegistry; pub use setup::{SecretRef, SecretVault}; -pub use sources::{Registries, RegistrySource}; +pub use sources::{Registries, RegistryOperation, RegistrySource, RegistryTimeouts}; pub use store::Store; pub use supervisor::{ ServerRef, SupervisedHost, Supervisor, SupervisorConfig, SupervisorEvent, TickReport, diff --git a/crates/tinymcp/src/registry/sources/mod.rs b/crates/tinymcp/src/registry/sources/mod.rs index 7bd7b5d..42a88e4 100644 --- a/crates/tinymcp/src/registry/sources/mod.rs +++ b/crates/tinymcp/src/registry/sources/mod.rs @@ -37,7 +37,10 @@ pub(crate) mod types; pub use encode::encode_path_segment; pub use official::McpOfficialRegistry; pub use smithery::SmitheryRegistry; -pub use types::{Registries, RegistrySource, SOURCE_MCP_OFFICIAL, SOURCE_SMITHERY}; +pub use types::{ + Registries, RegistryOperation, RegistrySource, RegistryTimeouts, SOURCE_MCP_OFFICIAL, + SOURCE_SMITHERY, +}; #[cfg(test)] #[path = "mod_tests.rs"] diff --git a/crates/tinymcp/src/registry/sources/mod_tests.rs b/crates/tinymcp/src/registry/sources/mod_tests.rs index 380d35d..cc655f9 100644 --- a/crates/tinymcp/src/registry/sources/mod_tests.rs +++ b/crates/tinymcp/src/registry/sources/mod_tests.rs @@ -7,7 +7,10 @@ use serde_json::json; use super::encode::encode_path_segment; use super::shared::{MAX_ERROR_BODY_BYTES, truncate}; use super::smithery::tag_source; -use super::types::{Registries, RegistrySource, SOURCE_MCP_OFFICIAL, SOURCE_SMITHERY}; +use super::types::{ + Registries, RegistryOperation, RegistrySource, RegistryTimeouts, SOURCE_MCP_OFFICIAL, + SOURCE_SMITHERY, +}; use tinymcp_bus::{McpRegistryAuthConfig, RegistryServerSummary}; /// A dispatcher with the given registry credentials. @@ -23,6 +26,52 @@ fn with_smithery_key() -> McpRegistryAuthConfig { } } +// --------------------------------------------------------------------------- +// Operations and their budgets +// --------------------------------------------------------------------------- + +#[test] +fn a_listing_with_a_query_is_a_search_and_without_one_a_browse() { + assert_eq!(RegistryOperation::for_query(""), RegistryOperation::Browse); + assert_eq!( + RegistryOperation::for_query("github"), + RegistryOperation::Search + ); +} + +#[test] +fn every_operation_renders_its_lowercase_name() { + for (operation, name) in [ + (RegistryOperation::Browse, "browse"), + (RegistryOperation::Search, "search"), + (RegistryOperation::Detail, "detail"), + ] { + assert_eq!(operation.as_str(), name); + assert_eq!(operation.to_string(), name); + } +} + +#[test] +fn the_default_budgets_are_pinned() { + use std::time::Duration; + + let timeouts = RegistryTimeouts::default(); + + assert_eq!(timeouts.connect, Duration::from_secs(5)); + assert_eq!( + timeouts.budget(RegistryOperation::Browse), + Duration::from_secs(15) + ); + assert_eq!( + timeouts.budget(RegistryOperation::Search), + Duration::from_secs(8) + ); + assert_eq!( + timeouts.budget(RegistryOperation::Detail), + Duration::from_secs(12) + ); +} + // --------------------------------------------------------------------------- // Source identifiers // --------------------------------------------------------------------------- diff --git a/crates/tinymcp/src/registry/sources/official/mod.rs b/crates/tinymcp/src/registry/sources/official/mod.rs index 480d7dc..f7c0b69 100644 --- a/crates/tinymcp/src/registry/sources/official/mod.rs +++ b/crates/tinymcp/src/registry/sources/official/mod.rs @@ -31,15 +31,14 @@ mod types; use std::collections::HashMap; -use std::time::Duration; use parking_lot::Mutex; use serde_json::Value; use self::types::{OfficialListResponse, OfficialServer, latest_version}; use super::encode::encode_path_segment; -use super::shared::{MAX_ERROR_BODY_BYTES, cache, truncate}; -use super::types::non_blank_env; +use super::shared::{cache, read_body}; +use super::types::{RegistryOperation, RegistryTimeouts, non_blank_env}; use crate::error::{Error, Result}; use crate::registry::Store; use tinymcp_bus::{McpRegistryAuthConfig, RegistryServerDetail, RegistryServerSummary}; @@ -47,9 +46,6 @@ use tinymcp_bus::{McpRegistryAuthConfig, RegistryServerDetail, RegistryServerSum /// Where the registry lives when nothing overrides it. const DEFAULT_BASE: &str = "https://registry.modelcontextprotocol.io"; -/// How long to wait on the registry. -const TIMEOUT: Duration = Duration::from_secs(15); - /// How far the adapter will walk to reach a deep page with a cold map. /// /// At fifty rows a page this reaches the two-thousand-five-hundredth result. @@ -64,23 +60,33 @@ type CursorCache = Mutex>; #[derive(Debug)] pub struct McpOfficialRegistry { http: reqwest::Client, + timeouts: RegistryTimeouts, } impl McpOfficialRegistry { - /// Builds the adapter. + /// Builds the adapter with the default [`RegistryTimeouts`]. /// /// # Errors /// /// Returns [`Error::ClientBuild`] when the HTTP client cannot be built. pub fn new() -> Result { + Self::with_timeouts(RegistryTimeouts::default()) + } + + /// Builds the adapter with its own time budgets. + /// + /// # Errors + /// + /// Returns [`Error::ClientBuild`] when the HTTP client cannot be built. + pub fn with_timeouts(timeouts: RegistryTimeouts) -> Result { let http = reqwest::Client::builder() - .timeout(TIMEOUT) + .connect_timeout(timeouts.connect) .build() .map_err(|source| Error::ClientBuild { source: Box::new(source.without_url()), })?; - Ok(Self { http }) + Ok(Self { http, timeouts }) } /// Searches the catalog. @@ -174,7 +180,9 @@ impl McpOfficialRegistry { base_url(auth), encode_path_segment(qualified_name) ); - let body = self.send(self.request(auth, &url), &url).await?; + let body = self + .send(self.request(auth, &url), &url, RegistryOperation::Detail) + .await?; let document: Value = serde_json::from_str(&body) .map_err(|error| Error::malformed(format!("official versions response: {error}")))?; @@ -281,7 +289,8 @@ impl McpOfficialRegistry { request = request.query(&[("cursor", cursor)]); } - self.send(request, &url).await + self.send(request, &url, RegistryOperation::for_query(query)) + .await } /// A request carrying the accept header and any configured token. @@ -293,28 +302,15 @@ impl McpOfficialRegistry { } } - /// Sends a request and returns its body, judging the status first. - async fn send(&self, request: reqwest::RequestBuilder, url: &str) -> Result { - let response = request - .send() - .await - .map_err(|error| Error::transport(url, error))?; - - let status = response.status(); - let body = response - .text() - .await - .map_err(|error| Error::transport(url, error))?; - - if !status.is_success() { - return Err(Error::Http { - endpoint: crate::redact_endpoint(url), - status: status.as_u16(), - body: truncate(&body, MAX_ERROR_BODY_BYTES), - }); - } - - Ok(body) + /// Sends a request within `operation`'s budget and returns its body. + async fn send( + &self, + request: reqwest::RequestBuilder, + url: &str, + operation: RegistryOperation, + ) -> Result { + let timeout = self.timeouts.budget(operation); + read_body(request.timeout(timeout), url, operation, timeout).await } } diff --git a/crates/tinymcp/src/registry/sources/official/mod_tests.rs b/crates/tinymcp/src/registry/sources/official/mod_tests.rs index f782064..7548402 100644 --- a/crates/tinymcp/src/registry/sources/official/mod_tests.rs +++ b/crates/tinymcp/src/registry/sources/official/mod_tests.rs @@ -1452,3 +1452,101 @@ async fn a_list_body_that_does_not_decode_is_reported_as_malformed() { "{error:?}" ); } + +// --------------------------------------------------------------------------- +// Time budgets +// --------------------------------------------------------------------------- + +use std::time::Duration; + +use super::super::types::{RegistryOperation, RegistryTimeouts}; + +/// A registry that holds every request for `delay` before answering. +async fn slow_registry(delay: Duration) -> (String, Arc) { + let seen = Arc::new(Seen::default()); + + let app = Router::new() + .fallback(get(move |State(seen): State>| async move { + seen.pages.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(delay).await; + axum::Json(json!({ "servers": [envelope("@acme/slow")] })) + })) + .with_state(Arc::clone(&seen)); + + (serve(app).await, seen) +} + +/// Budgets short enough for a test, with `search` shorter than the rest. +fn short_budgets() -> RegistryTimeouts { + RegistryTimeouts { + connect: Duration::from_secs(5), + browse: Duration::from_secs(5), + search: Duration::from_millis(100), + detail: Duration::from_millis(100), + } +} + +/// An adapter with [`short_budgets`]. +fn impatient_adapter() -> McpOfficialRegistry { + McpOfficialRegistry::with_timeouts(short_budgets()).expect("the adapter builds") +} + +#[tokio::test] +async fn a_stalled_search_fails_within_its_budget_as_a_registry_timeout() { + let (base, _seen) = slow_registry(Duration::from_secs(5)).await; + let started = std::time::Instant::now(); + + let error = impatient_adapter() + .search(&store(), &auth_at(&base), &cursors(), "github", 1, 20) + .await + .expect_err("the search stalls"); + + assert!( + started.elapsed() < Duration::from_secs(4), + "{:?}", + started.elapsed() + ); + match error { + Error::RegistryTimeout { + operation, timeout, .. + } => { + assert_eq!(operation, RegistryOperation::Search); + assert_eq!(timeout, Duration::from_millis(100)); + } + other => panic!("expected a registry timeout, got {other:?}"), + } +} + +#[tokio::test] +async fn a_browse_has_its_own_longer_budget() { + let (base, _seen) = slow_registry(Duration::from_millis(300)).await; + + let (servers, _) = impatient_adapter() + .search(&store(), &auth_at(&base), &cursors(), "", 1, 20) + .await + .expect("the browse budget covers the delay"); + + assert_eq!(servers.len(), 1); +} + +#[tokio::test] +async fn a_stalled_detail_lookup_is_a_detail_timeout() { + let (base, _seen) = slow_registry(Duration::from_secs(5)).await; + + let error = impatient_adapter() + .get(&store(), &auth_at(&base), "@acme/slow") + .await + .expect_err("the lookup stalls"); + + assert!( + matches!( + error, + Error::RegistryTimeout { + operation: RegistryOperation::Detail, + .. + } + ), + "{error:?}" + ); + assert!(error.is_registry_unavailable()); +} diff --git a/crates/tinymcp/src/registry/sources/shared.rs b/crates/tinymcp/src/registry/sources/shared.rs index 9114011..013854c 100644 --- a/crates/tinymcp/src/registry/sources/shared.rs +++ b/crates/tinymcp/src/registry/sources/shared.rs @@ -1,5 +1,9 @@ //! Helpers both catalog adapters need. +use std::time::Duration; + +use super::types::RegistryOperation; +use crate::error::{Error, Result}; use crate::registry::Store; /// How much of an upstream failure body to keep. @@ -32,3 +36,55 @@ pub(super) fn cache(store: &Store, cache_key: &str, body: &str) { tracing::debug!(cache_key, "could not cache an upstream response: {error}"); } } + +/// Sends a request and returns its body, judging the status first. +pub(super) async fn read_body( + request: reqwest::RequestBuilder, + url: &str, + operation: RegistryOperation, + timeout: Duration, +) -> Result { + let response = request + .send() + .await + .map_err(|error| upstream_error(url, error, operation, timeout))?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| upstream_error(url, error, operation, timeout))?; + + if !status.is_success() { + return Err(Error::Http { + endpoint: crate::redact_endpoint(url), + status: status.as_u16(), + body: truncate(&body, MAX_ERROR_BODY_BYTES), + }); + } + + Ok(body) +} + +/// The error for a request to `url` that failed before a status was judged. +/// +/// A timeout becomes [`Error::RegistryTimeout`], so a caller can tell a +/// stalled catalog from an unreachable one; anything else is a transport +/// failure. +pub(super) fn upstream_error( + url: &str, + error: reqwest::Error, + operation: RegistryOperation, + timeout: Duration, +) -> Error { + if error.is_timeout() { + tracing::debug!(%operation, timeout_ms = timeout.as_millis(), "registry request timed out"); + return Error::RegistryTimeout { + endpoint: crate::redact_endpoint(url), + operation, + timeout, + }; + } + + Error::transport(url, error) +} diff --git a/crates/tinymcp/src/registry/sources/smithery/mod.rs b/crates/tinymcp/src/registry/sources/smithery/mod.rs index 393e8a9..3a7c248 100644 --- a/crates/tinymcp/src/registry/sources/smithery/mod.rs +++ b/crates/tinymcp/src/registry/sources/smithery/mod.rs @@ -18,8 +18,8 @@ use crate::registry::Store; use tinymcp_bus::{RegistryListResponse, RegistryServerDetail, RegistryServerSummary}; use super::encode::encode_path_segment; -use super::shared::{MAX_ERROR_BODY_BYTES, cache, truncate}; -use super::types::SOURCE_SMITHERY; +use super::shared::{cache, read_body}; +use super::types::{RegistryOperation, SOURCE_SMITHERY}; /// Where Smithery's registry lives. const BASE_URL: &str = "https://registry.smithery.ai"; @@ -110,7 +110,7 @@ impl SmitheryRegistry { request = request.bearer_auth(key); } - let body = read_body(request, &url).await?; + let body = read_body(request, &url, RegistryOperation::for_query(query), TIMEOUT).await?; let parsed: RegistryListResponse = serde_json::from_str(&body) .map_err(|error| Error::malformed(format!("smithery list response: {error}")))?; @@ -154,7 +154,7 @@ impl SmitheryRegistry { request = request.bearer_auth(key); } - let body = read_body(request, &url).await?; + let body = read_body(request, &url, RegistryOperation::Detail, TIMEOUT).await?; let mut detail: RegistryServerDetail = serde_json::from_str(&body) .map_err(|error| Error::malformed(format!("smithery detail response: {error}")))?; detail.source = SOURCE_SMITHERY.to_string(); @@ -164,32 +164,6 @@ impl SmitheryRegistry { } } -/// Sends a request and returns its body, judging the status first. -async fn read_body(request: reqwest::RequestBuilder, url: &str) -> Result { - let response = request - .send() - .await - .map_err(|error| Error::transport(url, error))?; - - let status = response.status(); - let body = response - .text() - .await - .map_err(|error| Error::transport(url, error))?; - - if !status.is_success() { - return Err(Error::Http { - endpoint: crate::redact_endpoint(url), - status: status.as_u16(), - // Bounded: an upstream failure body can be a whole error page, and - // this ends up in a log and an error message. - body: truncate(&body, MAX_ERROR_BODY_BYTES), - }); - } - - Ok(body) -} - /// Stamps the source and clears the trust signals. See the module note. pub(super) fn tag_source(mut servers: Vec) -> Vec { for server in &mut servers { diff --git a/crates/tinymcp/src/registry/sources/types.rs b/crates/tinymcp/src/registry/sources/types.rs index 34be845..f7b697e 100644 --- a/crates/tinymcp/src/registry/sources/types.rs +++ b/crates/tinymcp/src/registry/sources/types.rs @@ -1,6 +1,8 @@ //! The dispatcher over the upstream catalogs. use std::collections::HashMap; +use std::fmt; +use std::time::Duration; use parking_lot::Mutex; @@ -21,6 +23,86 @@ pub const SOURCE_MCP_OFFICIAL: &str = "mcp_official"; /// The default page size when a caller does not ask for one. const DEFAULT_PAGE_SIZE: u32 = 20; +/// What a request to an upstream catalog was asking for. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum RegistryOperation { + /// A page of the unfiltered catalog. + Browse, + /// A page of results for a query. + Search, + /// One server's detail. + Detail, +} + +impl RegistryOperation { + /// The operation's lowercase name. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Browse => "browse", + Self::Search => "search", + Self::Detail => "detail", + } + } + + /// The operation a listing is: a search when there is a query, a browse + /// otherwise. + #[must_use] + pub const fn for_query(query: &str) -> Self { + if query.is_empty() { + Self::Browse + } else { + Self::Search + } + } +} + +impl fmt::Display for RegistryOperation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// How long the official catalog adapter waits on each kind of request. +/// +/// Search has the shortest budget: it is what a user is typing into, and the +/// registry's search can stall while its plain listing answers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RegistryTimeouts { + /// Establishing a connection. + pub connect: Duration, + /// A page of the unfiltered catalog. + pub browse: Duration, + /// A page of results for a query. + pub search: Duration, + /// One server's detail. + pub detail: Duration, +} + +impl RegistryTimeouts { + /// The budget for `operation`. + #[must_use] + pub const fn budget(&self, operation: RegistryOperation) -> Duration { + match operation { + RegistryOperation::Browse => self.browse, + RegistryOperation::Search => self.search, + RegistryOperation::Detail => self.detail, + } + } +} + +impl Default for RegistryTimeouts { + fn default() -> Self { + Self { + connect: Duration::from_secs(5), + browse: Duration::from_secs(15), + search: Duration::from_secs(8), + detail: Duration::from_secs(12), + } + } +} + /// One upstream catalog. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] From c9ca8624b996f57f4457e85cf0c279c5e1bd48b8 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 17:33:12 +0530 Subject: [PATCH 04/27] feat(bus): report search page freshness and bump the contract to 1.4 (#42) --- crates/tinymcp-bus/src/lib.rs | 4 +- crates/tinymcp-bus/src/method/mod.rs | 4 +- crates/tinymcp-bus/src/method/mod_tests.rs | 48 ++++++++++++++++++++- crates/tinymcp-bus/src/method/types.rs | 30 +++++++++++++ crates/tinymcp-bus/src/version/mod.rs | 5 ++- crates/tinymcp-bus/src/version/mod_tests.rs | 8 ++-- crates/tinymcp/src/lib.rs | 8 ++-- crates/tinymcp/src/registry/ops/types.rs | 5 ++- crates/tinymcp/tests/public_reexports.rs | 4 ++ 9 files changed, 99 insertions(+), 17 deletions(-) diff --git a/crates/tinymcp-bus/src/lib.rs b/crates/tinymcp-bus/src/lib.rs index 2126ad2..33cc56e 100644 --- a/crates/tinymcp-bus/src/lib.rs +++ b/crates/tinymcp-bus/src/lib.rs @@ -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::{ diff --git a/crates/tinymcp-bus/src/method/mod.rs b/crates/tinymcp-bus/src/method/mod.rs index a5a14c8..b5088c7 100644 --- a/crates/tinymcp-bus/src/method/mod.rs +++ b/crates/tinymcp-bus/src/method/mod.rs @@ -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)] diff --git a/crates/tinymcp-bus/src/method/mod_tests.rs b/crates/tinymcp-bus/src/method/mod_tests.rs index 6d1adc9..ac9a2a7 100644 --- a/crates/tinymcp-bus/src/method/mod_tests.rs +++ b/crates/tinymcp-bus/src/method/mod_tests.rs @@ -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] @@ -104,3 +104,49 @@ 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::Cached, "cached"), + (RegistryFreshness::LocalFallback, "local_fallback"), + ] { + assert_eq!(serde_json::to_value(freshness).unwrap(), json!(wire)); + assert_eq!( + serde_json::from_value::(json!(wire)).unwrap(), + freshness + ); + } +} + +#[test] +fn freshness_orders_from_freshest_to_least_fresh() { + assert!(RegistryFreshness::Live < RegistryFreshness::Cached); + assert!(RegistryFreshness::Cached < RegistryFreshness::LocalFallback); + 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") + ); +} diff --git a/crates/tinymcp-bus/src/method/types.rs b/crates/tinymcp-bus/src/method/types.rs index f2777b4..0243f5b 100644 --- a/crates/tinymcp-bus/src/method/types.rs +++ b/crates/tinymcp-bus/src/method/types.rs @@ -23,6 +23,36 @@ 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, + /// 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. diff --git a/crates/tinymcp-bus/src/version/mod.rs b/crates/tinymcp-bus/src/version/mod.rs index ad80972..7f3cfee 100644 --- a/crates/tinymcp-bus/src/version/mod.rs +++ b/crates/tinymcp-bus/src/version/mod.rs @@ -10,8 +10,9 @@ /// 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 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`. diff --git a/crates/tinymcp-bus/src/version/mod_tests.rs b/crates/tinymcp-bus/src/version/mod_tests.rs index e7ab098..58a9a34 100644 --- a/crates/tinymcp-bus/src/version/mod_tests.rs +++ b/crates/tinymcp-bus/src/version/mod_tests.rs @@ -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] @@ -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))); } diff --git a/crates/tinymcp/src/lib.rs b/crates/tinymcp/src/lib.rs index 16c3b25..ca8448b 100644 --- a/crates/tinymcp/src/lib.rs +++ b/crates/tinymcp/src/lib.rs @@ -121,8 +121,8 @@ pub use tinymcp_bus::{ McpClientIdentityConfig, McpClientInfo, McpInitializeResult, McpProxyConfig, McpRegistryAuthConfig, McpRemoteTool, McpServerConfig, McpServerToolResult, McpSseEvent, McpTool, McpToolContent, McpToolResult, McpWriteListQuery, McpWriteRecord, NewMcpWriteRecord, - OBJECT_PATH, ProtectedResourceMetadata, RegistryConnection, RegistryListResponse, - RegistryPagination, RegistryServerDetail, RegistryServerSummary, SUPPORTED_PROTOCOL_VERSIONS, - SearchCuration, ServerDetail, ServerStatus, Transport, config, is_compatible, names, sanitize, - version, + OBJECT_PATH, ProtectedResourceMetadata, RegistryConnection, RegistryFreshness, + RegistryListResponse, RegistryPagination, RegistryServerDetail, RegistryServerSummary, + SUPPORTED_PROTOCOL_VERSIONS, SearchCuration, ServerDetail, ServerStatus, Transport, config, + is_compatible, names, sanitize, version, }; diff --git a/crates/tinymcp/src/registry/ops/types.rs b/crates/tinymcp/src/registry/ops/types.rs index adf39fb..32cd09a 100644 --- a/crates/tinymcp/src/registry/ops/types.rs +++ b/crates/tinymcp/src/registry/ops/types.rs @@ -14,8 +14,8 @@ use crate::registry::{ use tinymcp_bus::{ AuthDetection, ConnStatus, ConnectOutcome, ConnectedServerOverview, InstallOutcome, InstalledServer, McpClientIdentityConfig, McpProxyConfig, McpRegistryAuthConfig, McpTool, - RegistrySearchPage, RegistryServerDetail, RegistrySettings, SearchCuration, ToolCallOutcome, - Transport, UpdateEnvOutcome, UpdateEnvStatus, normalize_tool_arguments, + RegistryFreshness, RegistrySearchPage, RegistryServerDetail, RegistrySettings, SearchCuration, + ToolCallOutcome, Transport, UpdateEnvOutcome, UpdateEnvStatus, normalize_tool_arguments, }; /// The separator a source-routed name uses. @@ -152,6 +152,7 @@ impl McpRegistry { servers, page: page.max(1), total_pages, + freshness: RegistryFreshness::Live, }) } diff --git a/crates/tinymcp/tests/public_reexports.rs b/crates/tinymcp/tests/public_reexports.rs index 7dcd3e2..fc0a789 100644 --- a/crates/tinymcp/tests/public_reexports.rs +++ b/crates/tinymcp/tests/public_reexports.rs @@ -2,6 +2,7 @@ use tinymcp::{ CONTRACT_VERSION, MCP_CALL_RESULT_KIND, McpAuthChallenge, McpCallError, McpCallOutcome, + RegistryFreshness, }; #[test] @@ -20,4 +21,7 @@ fn the_contract_types_resolve_at_the_crate_root_to_the_bus_definitions() { ); assert_eq!(outcome.kind, MCP_CALL_RESULT_KIND); assert_eq!(CONTRACT_VERSION, tinymcp_bus::CONTRACT_VERSION); + + let freshness: tinymcp_bus::RegistryFreshness = RegistryFreshness::LocalFallback; + assert_ne!(freshness, RegistryFreshness::Live); } From 136456aa585a3708d66410fbfc6e68087d20ee1b Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 17:38:46 +0530 Subject: [PATCH 05/27] fix(registry): answer from the cache when the registry cannot (#42) --- crates/tinymcp/src/registry/ops/types.rs | 14 +- crates/tinymcp/src/registry/sources/mod.rs | 2 +- .../src/registry/sources/official/fallback.rs | 168 ++++++ .../src/registry/sources/official/mod.rs | 125 +++- .../registry/sources/official/mod_tests.rs | 542 +++++++++++++++++- crates/tinymcp/src/registry/sources/types.rs | 46 +- .../tinymcp/src/registry/store/mod_tests.rs | 53 ++ crates/tinymcp/src/registry/store/types.rs | 44 +- 8 files changed, 950 insertions(+), 44 deletions(-) create mode 100644 crates/tinymcp/src/registry/sources/official/fallback.rs diff --git a/crates/tinymcp/src/registry/ops/types.rs b/crates/tinymcp/src/registry/ops/types.rs index 32cd09a..bb40031 100644 --- a/crates/tinymcp/src/registry/ops/types.rs +++ b/crates/tinymcp/src/registry/ops/types.rs @@ -129,7 +129,9 @@ impl McpRegistry { /// /// Returns whatever an upstream returns. A source that fails takes the call /// with it rather than silently returning a partial catalog that reads as - /// "this server does not exist". + /// "this server does not exist". A source that answers from its cache + /// instead is not a failure; the page's `freshness` reports the least fresh + /// of the sources. pub async fn registry_search( &self, query: Option<&str>, @@ -138,21 +140,23 @@ impl McpRegistry { ) -> Result { let mut servers = Vec::new(); let mut total_pages = page.max(1); + let mut freshness = RegistryFreshness::Live; for source in self.registries.searchable() { - let (found, pages) = self + let found = self .registries .search(&self.store, source, query, page, page_size) .await?; - servers.extend(found); - total_pages = total_pages.max(pages); + servers.extend(found.servers); + total_pages = total_pages.max(found.total_pages); + freshness = freshness.max(found.freshness); } Ok(RegistrySearchPage { servers, page: page.max(1), total_pages, - freshness: RegistryFreshness::Live, + freshness, }) } diff --git a/crates/tinymcp/src/registry/sources/mod.rs b/crates/tinymcp/src/registry/sources/mod.rs index 42a88e4..24aedab 100644 --- a/crates/tinymcp/src/registry/sources/mod.rs +++ b/crates/tinymcp/src/registry/sources/mod.rs @@ -39,7 +39,7 @@ pub use official::McpOfficialRegistry; pub use smithery::SmitheryRegistry; pub use types::{ Registries, RegistryOperation, RegistrySource, RegistryTimeouts, SOURCE_MCP_OFFICIAL, - SOURCE_SMITHERY, + SOURCE_SMITHERY, SourcePage, }; #[cfg(test)] diff --git a/crates/tinymcp/src/registry/sources/official/fallback.rs b/crates/tinymcp/src/registry/sources/official/fallback.rs new file mode 100644 index 0000000..6f956ac --- /dev/null +++ b/crates/tinymcp/src/registry/sources/official/fallback.rs @@ -0,0 +1,168 @@ +//! What the official adapter serves when the registry cannot answer. +//! +//! In order: an earlier answer to the same request, however old; for the +//! first page of a search, rows from cached catalog pages that match the +//! query; otherwise nothing, and the caller returns the error. +//! +//! A listing that timed out also starts a cooldown for listings of its kind, +//! so the next keystrokes go straight to the cache instead of each waiting out +//! the same budget. + +use std::collections::{HashMap, HashSet}; +use std::time::{Duration, Instant}; + +use parking_lot::Mutex; + +use super::types::OfficialListResponse; +use super::{BROWSE_CACHE_PREFIX, CursorCache, search_cache_key, served}; +use crate::error::Error; +use crate::registry::Store; +use crate::registry::sources::types::{RegistryOperation, SourcePage}; +use tinymcp_bus::{RegistryFreshness, RegistryServerSummary}; + +/// One timed-out listing, remembered until its cooldown ends. +#[derive(Debug, Clone)] +struct Stall { + since: Instant, + url: String, + timeout: Duration, +} + +/// The listings currently skipping the network, by kind. +#[derive(Debug, Default)] +pub(super) struct Cooldown { + stalls: Mutex>, +} + +impl Cooldown { + /// Records that a listing against `url` ran out of `timeout`. + pub(super) fn start(&self, operation: RegistryOperation, url: &str, timeout: Duration) { + tracing::debug!(%operation, "official registry cooling down after a timeout"); + self.stalls.lock().insert( + operation, + Stall { + since: Instant::now(), + url: url.to_string(), + timeout, + }, + ); + } + + /// The timeout to report for `operation` against `url`, when it is still + /// within `period` of the last one. + pub(super) fn active( + &self, + operation: RegistryOperation, + url: &str, + period: Duration, + ) -> Option { + let mut stalls = self.stalls.lock(); + let stall = stalls.get(&operation)?; + + if stall.url != url || stall.since.elapsed() >= period { + stalls.remove(&operation); + return None; + } + + Some(Error::RegistryTimeout { + endpoint: crate::redact_endpoint(url), + operation, + timeout: stall.timeout, + }) + } +} + +/// What can stand in for a listing the registry could not answer. +pub(super) fn serve_cached( + store: &Store, + cursors: &CursorCache, + query: &str, + page: u32, + page_size: u32, +) -> Option { + if let Some(page) = stale_page(store, cursors, query, page, page_size) { + return Some(page); + } + if query.is_empty() || page != 1 { + return None; + } + + let servers = local_matches(store, query, page_size); + tracing::debug!( + query_length = query.len(), + matches = servers.len(), + "official search served from cached catalog pages" + ); + + (!servers.is_empty()).then_some(SourcePage { + servers, + total_pages: 1, + freshness: RegistryFreshness::LocalFallback, + }) +} + +/// An earlier answer to exactly this listing. +fn stale_page( + store: &Store, + cursors: &CursorCache, + query: &str, + page: u32, + page_size: u32, +) -> Option { + let body = store + .cached_stale(&search_cache_key(query, page, page_size)) + .ok()??; + let parsed: OfficialListResponse = serde_json::from_str(&body).ok()?; + + tracing::debug!( + page, + page_size, + "official listing served from a stale cache entry" + ); + Some(SourcePage { + freshness: RegistryFreshness::Cached, + ..served(parsed, cursors, query, page, page_size) + }) +} + +/// Rows from every cached catalog page that match `query`, at most `limit`. +/// +/// 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 { + let terms: Vec = query.split_whitespace().map(str::to_lowercase).collect(); + if terms.is_empty() { + return Vec::new(); + } + + let bodies = store + .cached_with_prefix(BROWSE_CACHE_PREFIX) + .unwrap_or_default(); + let mut seen = HashSet::new(); + let mut matches: Vec<(bool, RegistryServerSummary)> = bodies + .iter() + .filter_map(|body| serde_json::from_str::(body).ok()) + .flat_map(OfficialListResponse::into_summaries) + .filter(|row| seen.insert(row.qualified_name.clone())) + .filter_map(|row| { + let label = format!("{} {}", row.qualified_name, row.display_name).to_lowercase(); + let description = row + .description + .as_deref() + .unwrap_or_default() + .to_lowercase(); + let in_label = terms.iter().all(|term| label.contains(term.as_str())); + let anywhere = terms + .iter() + .all(|term| label.contains(term.as_str()) || description.contains(term.as_str())); + anywhere.then_some((in_label, row)) + }) + .collect(); + + matches.sort_by_key(|(in_label, _)| !in_label); + matches + .into_iter() + .map(|(_, row)| row) + .take(usize::try_from(limit).unwrap_or(usize::MAX)) + .collect() +} diff --git a/crates/tinymcp/src/registry/sources/official/mod.rs b/crates/tinymcp/src/registry/sources/official/mod.rs index f7c0b69..a4a5abb 100644 --- a/crates/tinymcp/src/registry/sources/official/mod.rs +++ b/crates/tinymcp/src/registry/sources/official/mod.rs @@ -21,6 +21,14 @@ //! The walk also consults the stored response cache before making a request, //! so a cold in-memory map after a restart does not mean a cold network. //! +//! # When the registry cannot answer +//! +//! Each kind of request has its own time budget ([`RegistryTimeouts`]). A +//! listing that times out, cannot connect, or is answered 408, 429 or 5xx is +//! served from the cache instead, and its freshness says which kind of answer +//! it is; a timed-out listing also skips the network for a cooldown. The +//! `fallback` module holds the order. +//! //! # The page count is a bound, not a total //! //! Knowing the true total would mean walking the whole cursor chain, which is @@ -28,6 +36,7 @@ //! the current one while more results exist, which is what a caller needs to //! decide whether to offer a "next" control. +mod fallback; mod types; use std::collections::HashMap; @@ -35,13 +44,14 @@ use std::collections::HashMap; use parking_lot::Mutex; use serde_json::Value; +use self::fallback::{Cooldown, serve_cached}; use self::types::{OfficialListResponse, OfficialServer, latest_version}; use super::encode::encode_path_segment; use super::shared::{cache, read_body}; -use super::types::{RegistryOperation, RegistryTimeouts, non_blank_env}; +use super::types::{RegistryOperation, RegistryTimeouts, SourcePage, non_blank_env}; use crate::error::{Error, Result}; use crate::registry::Store; -use tinymcp_bus::{McpRegistryAuthConfig, RegistryServerDetail, RegistryServerSummary}; +use tinymcp_bus::{McpRegistryAuthConfig, RegistryFreshness, RegistryServerDetail}; /// Where the registry lives when nothing overrides it. const DEFAULT_BASE: &str = "https://registry.modelcontextprotocol.io"; @@ -53,6 +63,9 @@ const DEFAULT_BASE: &str = "https://registry.modelcontextprotocol.io"; /// denial of service aimed at someone else. const MAX_CURSOR_WALK_PAGES: u32 = 50; +/// The cache key prefix every page of the unfiltered catalog shares. +const BROWSE_CACHE_PREFIX: &str = "mcp_official:search:latest::"; + /// The map from page to the cursor that produced it. type CursorCache = Mutex>; @@ -61,6 +74,7 @@ type CursorCache = Mutex>; pub struct McpOfficialRegistry { http: reqwest::Client, timeouts: RegistryTimeouts, + cooldown: Cooldown, } impl McpOfficialRegistry { @@ -86,15 +100,24 @@ impl McpOfficialRegistry { source: Box::new(source.without_url()), })?; - Ok(Self { http, timeouts }) + Ok(Self { + http, + timeouts, + cooldown: Cooldown::default(), + }) } /// Searches the catalog. /// + /// When the registry cannot answer — it timed out, was unreachable, or + /// answered 408, 429 or 5xx — the page is served from the cache instead, + /// with its freshness saying so. The fallback module sets out the order. + /// /// # Errors /// /// Returns [`Error::MalformedResponse`] when a deep page is asked for with - /// a cold map, plus whatever the upstream returns. + /// a cold map, and whatever the upstream returns when nothing cached can + /// stand in. pub(super) async fn search( &self, store: &Store, @@ -103,19 +126,58 @@ impl McpOfficialRegistry { query: &str, page: u32, page_size: u32, - ) -> Result<(Vec, u32)> { + ) -> Result { let cache_key = search_cache_key(query, page, page_size); if let Ok(Some(cached)) = store.cached(&cache_key) && let Ok(parsed) = serde_json::from_str::(&cached) { tracing::debug!(page, page_size, "official search cache hit"); - let total_pages = page_bound(page, parsed.next_cursor().is_some()); - if let Some(cursor) = parsed.next_cursor() { - remember_cursor(cursors, query, page_size, page, cursor.to_string()); + return Ok(served(parsed, cursors, query, page, page_size)); + } + + let operation = RegistryOperation::for_query(query); + let url = list_url(auth); + + if let Some(error) = self + .cooldown + .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); + } + + match self + .search_live(store, auth, cursors, query, page, page_size) + .await + { + Err(error) if error.is_registry_unavailable() => { + if error.is_timeout() { + self.cooldown + .start(operation, &url, self.timeouts.budget(operation)); + } + tracing::debug!( + %operation, + code = error.wire_name(), + "official registry unavailable; answering from the cache" + ); + serve_cached(store, cursors, query, page, page_size).ok_or(error) } - return Ok((parsed.into_summaries(), total_pages)); + outcome => outcome, } + } + + /// Searches the registry itself, filling the caches on the way. + async fn search_live( + &self, + store: &Store, + auth: &McpRegistryAuthConfig, + cursors: &CursorCache, + query: &str, + page: u32, + page_size: u32, + ) -> Result { + let cache_key = search_cache_key(query, page, page_size); let cursor = match page { 1 => None, @@ -130,7 +192,13 @@ impl McpOfficialRegistry { // The chain ended before reaching the page asked for. // An empty result reporting this page as the last is // what stops a caller paging further. - None => return Ok((Vec::new(), page)), + None => { + return Ok(SourcePage { + servers: Vec::new(), + total_pages: page, + freshness: RegistryFreshness::Live, + }); + } } } }, @@ -141,17 +209,9 @@ impl McpOfficialRegistry { .await?; let parsed: OfficialListResponse = serde_json::from_str(&body) .map_err(|error| Error::malformed(format!("official list response: {error}")))?; - - let next_cursor = parsed.next_cursor().map(ToString::to_string); - if let Some(cursor) = next_cursor.clone() { - remember_cursor(cursors, query, page_size, page, cursor); - } cache(store, &cache_key, &body); - Ok(( - parsed.into_summaries(), - page_bound(page, next_cursor.is_some()), - )) + Ok(served(parsed, cursors, query, page, page_size)) } /// Fetches one server's detail. @@ -277,7 +337,7 @@ impl McpOfficialRegistry { "fetching an official registry page" ); - let url = format!("{}/v0/servers", base_url(auth)); + let url = list_url(auth); let mut request = self .request(auth, &url) .query(&[("limit", limit.to_string())]) @@ -314,6 +374,31 @@ impl McpOfficialRegistry { } } +/// A parsed page as served, recording the cursor it carries. +fn served( + parsed: OfficialListResponse, + cursors: &CursorCache, + query: &str, + page: u32, + page_size: u32, +) -> SourcePage { + let has_next = parsed.next_cursor().is_some(); + if let Some(cursor) = parsed.next_cursor() { + remember_cursor(cursors, query, page_size, page, cursor.to_string()); + } + + SourcePage { + servers: parsed.into_summaries(), + total_pages: page_bound(page, has_next), + freshness: RegistryFreshness::Live, + } +} + +/// The list endpoint. +fn list_url(auth: &McpRegistryAuthConfig) -> String { + format!("{}/v0/servers", base_url(auth)) +} + /// The cache key for one page of one search. fn search_cache_key(query: &str, page: u32, page_size: u32) -> String { format!("mcp_official:search:latest:{query}:{page}:{page_size}") diff --git a/crates/tinymcp/src/registry/sources/official/mod_tests.rs b/crates/tinymcp/src/registry/sources/official/mod_tests.rs index 7548402..7dd3979 100644 --- a/crates/tinymcp/src/registry/sources/official/mod_tests.rs +++ b/crates/tinymcp/src/registry/sources/official/mod_tests.rs @@ -872,6 +872,7 @@ use axum::http::{HeaderMap, Uri}; use axum::routing::get; use parking_lot::Mutex; +use super::super::types::SourcePage; use super::{MAX_CURSOR_WALK_PAGES, McpOfficialRegistry}; use crate::error::Error; use crate::registry::Store; @@ -1007,7 +1008,7 @@ fn adapter() -> McpOfficialRegistry { async fn the_first_page_is_fetched_without_a_cursor() { let (base, seen) = paged_registry(3).await; - let (servers, _) = adapter() + let SourcePage { servers, .. } = adapter() .search(&store(), &auth_at(&base), &cursors(), "", 1, 20) .await .expect("the search succeeds"); @@ -1038,7 +1039,7 @@ async fn a_page_with_more_behind_it_reports_one_page_beyond() { // what a caller needs to decide whether to offer a "next" control. let (base, _seen) = paged_registry(3).await; - let (_, total_pages) = adapter() + let SourcePage { total_pages, .. } = adapter() .search(&store(), &auth_at(&base), &cursors(), "", 1, 20) .await .unwrap(); @@ -1050,7 +1051,7 @@ async fn a_page_with_more_behind_it_reports_one_page_beyond() { async fn the_last_page_reports_itself_as_the_last() { let (base, _seen) = paged_registry(1).await; - let (_, total_pages) = adapter() + let SourcePage { total_pages, .. } = adapter() .search(&store(), &auth_at(&base), &cursors(), "", 1, 20) .await .unwrap(); @@ -1122,7 +1123,7 @@ async fn a_cold_map_walks_forward_to_reach_a_deep_page() { // A link straight to page four, or the first search after a restart. let (base, seen) = paged_registry(5).await; - let (servers, _) = adapter() + let SourcePage { servers, .. } = adapter() .search(&store(), &auth_at(&base), &cursors(), "", 4, 20) .await .expect("the walk reaches page four"); @@ -1187,7 +1188,11 @@ async fn a_page_past_the_end_of_the_chain_comes_back_empty() { // naming this page as the last is what stops a caller paging further. let (base, _seen) = paged_registry(2).await; - let (servers, total_pages) = adapter() + let SourcePage { + servers, + total_pages, + .. + } = adapter() .search(&store(), &auth_at(&base), &cursors(), "", 5, 20) .await .expect("running out of pages is not a failure"); @@ -1235,7 +1240,11 @@ async fn a_repeated_search_is_served_from_the_stored_cache() { .search(&store, &auth, &cursors(), "", 1, 20) .await .unwrap(); - let (servers, total_pages) = adapter + let SourcePage { + servers, + total_pages, + .. + } = adapter .search(&store, &auth, &cursors(), "", 1, 20) .await .unwrap(); @@ -1483,6 +1492,7 @@ fn short_budgets() -> RegistryTimeouts { browse: Duration::from_secs(5), search: Duration::from_millis(100), detail: Duration::from_millis(100), + cooldown: Duration::from_secs(60), } } @@ -1521,7 +1531,7 @@ async fn a_stalled_search_fails_within_its_budget_as_a_registry_timeout() { async fn a_browse_has_its_own_longer_budget() { let (base, _seen) = slow_registry(Duration::from_millis(300)).await; - let (servers, _) = impatient_adapter() + let SourcePage { servers, .. } = impatient_adapter() .search(&store(), &auth_at(&base), &cursors(), "", 1, 20) .await .expect("the browse budget covers the delay"); @@ -1550,3 +1560,521 @@ async fn a_stalled_detail_lookup_is_a_detail_timeout() { ); assert!(error.is_registry_unavailable()); } + +// --------------------------------------------------------------------------- +// Serving from the cache when the registry cannot answer +// --------------------------------------------------------------------------- + +use axum::response::IntoResponse as _; +use tinymcp_bus::RegistryFreshness; + +/// The registry answers normally. +const UP: usize = 0; +/// The registry answers 503. +const FAILING: usize = 1; +/// The registry holds every request past any test budget. +const STALLED: usize = 2; +/// The registry answers 200 with a body that is not a list. +const GARBLED: usize = 3; + +/// A registry whose behaviour a test switches between [`UP`], [`FAILING`], +/// [`STALLED`] and [`GARBLED`]. +#[derive(Debug, Default)] +struct Switchable { + mode: AtomicUsize, + requests: AtomicUsize, +} + +impl Switchable { + fn set(&self, mode: usize) { + self.mode.store(mode, Ordering::SeqCst); + } + + fn requests(&self) -> usize { + self.requests.load(Ordering::SeqCst) + } +} + +/// A switchable registry: browsing answers the recorded latest page, and a +/// search for `q` answers one row named `@acme/q`. +async fn switchable_registry() -> (String, Arc) { + let state = Arc::new(Switchable::default()); + + let app = Router::new() + .route( + "/v0/servers", + get( + |State(state): State>, uri: Uri| async move { + state.requests.fetch_add(1, Ordering::SeqCst); + match state.mode.load(Ordering::SeqCst) { + FAILING => { + (axum::http::StatusCode::SERVICE_UNAVAILABLE, "down").into_response() + } + STALLED => { + tokio::time::sleep(Duration::from_secs(5)).await; + axum::Json(json!({ "servers": [] })).into_response() + } + GARBLED => "[1, 2, 3]".into_response(), + _ => match param(&uri, "search") { + Some(query) => axum::Json(json!({ + "servers": [envelope(&format!("@acme/{query}"))], + })) + .into_response(), + None => LATEST_PAGE.into_response(), + }, + } + }, + ), + ) + .with_state(Arc::clone(&state)); + + (serve(app).await, state) +} + +/// Moves every cached entry past the cache lifetime. +fn expire_cache(store: &Store) { + store.with_connection(|connection| { + connection + .execute( + "UPDATE mcp_registry_cache SET cached_at = cached_at - ?1", + rusqlite::params![24 * 60 * 60 * 1_000i64], + ) + .unwrap(); + }); +} + +/// Budgets with every listing short, and `cooldown` as given. +fn budgets_with_cooldown(cooldown: Duration) -> RegistryTimeouts { + RegistryTimeouts { + browse: Duration::from_millis(200), + cooldown, + ..short_budgets() + } +} + +/// An adapter with [`budgets_with_cooldown`]. +fn adapter_with_cooldown(cooldown: Duration) -> McpOfficialRegistry { + McpOfficialRegistry::with_timeouts(budgets_with_cooldown(cooldown)).expect("the adapter builds") +} + +#[tokio::test] +async fn a_live_answer_is_reported_as_live() { + let (base, _state) = switchable_registry().await; + + let page = adapter() + .search(&store(), &auth_at(&base), &cursors(), "", 1, 20) + .await + .unwrap(); + + assert_eq!(page.freshness, RegistryFreshness::Live); + assert_eq!(page.servers.len(), 20); +} + +#[tokio::test] +async fn a_stalled_search_answers_with_its_earlier_result() { + let (base, state) = switchable_registry().await; + let auth = auth_at(&base); + let store = store(); + let adapter = adapter_with_cooldown(Duration::from_secs(60)); + + adapter + .search(&store, &auth, &cursors(), "weather", 1, 20) + .await + .unwrap(); + expire_cache(&store); + state.set(STALLED); + + let page = adapter + .search(&store, &auth, &cursors(), "weather", 1, 20) + .await + .expect("the earlier result stands in"); + + assert_eq!(page.freshness, RegistryFreshness::Cached); + assert_eq!(page.servers[0].qualified_name, "@acme/weather"); +} + +#[tokio::test] +async fn a_failing_browse_answers_with_its_earlier_page_and_cursor() { + let (base, state) = switchable_registry().await; + let auth = auth_at(&base); + let store = store(); + let warm = cursors(); + let adapter = adapter(); + + adapter + .search(&store, &auth, &cursors(), "", 1, 20) + .await + .unwrap(); + expire_cache(&store); + state.set(FAILING); + + let page = adapter + .search(&store, &auth, &warm, "", 1, 20) + .await + .expect("the earlier page stands in"); + + assert_eq!(page.freshness, RegistryFreshness::Cached); + assert_eq!(page.servers.len(), 20); + assert_eq!(page.total_pages, 2); + assert!( + warm.lock().contains_key(&(String::new(), 20, 1)), + "the stale page's cursor was recorded" + ); +} + +#[tokio::test] +async fn a_stalled_search_with_no_earlier_result_matches_cached_catalog_pages() { + let (base, state) = switchable_registry().await; + let auth = auth_at(&base); + let store = store(); + let adapter = adapter_with_cooldown(Duration::from_secs(60)); + + adapter + .search(&store, &auth, &cursors(), "", 1, 20) + .await + .unwrap(); + state.set(STALLED); + + let page = adapter + .search(&store, &auth, &cursors(), "Dubai", 1, 20) + .await + .expect("cached catalog rows stand in"); + + let names: Vec<&str> = page + .servers + .iter() + .map(|row| row.qualified_name.as_str()) + .collect(); + assert_eq!(page.freshness, RegistryFreshness::LocalFallback); + assert_eq!(page.total_pages, 1); + assert_eq!( + names, + [ + "ae.datadubai/dubai-real-estate", + "ae.plantguide/dubai-gardening", + "ae.propick/propick", + ], + "name and title matches lead description matches" + ); +} + +#[tokio::test] +async fn a_local_match_needs_every_word_of_the_query() { + let (base, state) = switchable_registry().await; + let auth = auth_at(&base); + let store = store(); + let adapter = adapter(); + + adapter + .search(&store, &auth, &cursors(), "", 1, 20) + .await + .unwrap(); + state.set(FAILING); + + let page = adapter + .search(&store, &auth, &cursors(), "dubai gardening", 1, 20) + .await + .unwrap(); + + assert_eq!(page.servers.len(), 1); + assert_eq!( + page.servers[0].qualified_name, + "ae.plantguide/dubai-gardening" + ); +} + +#[tokio::test] +async fn a_local_match_is_capped_at_the_page_size() { + let (base, state) = switchable_registry().await; + let auth = auth_at(&base); + let store = store(); + let adapter = adapter(); + + adapter + .search(&store, &auth, &cursors(), "", 1, 20) + .await + .unwrap(); + state.set(FAILING); + + let page = adapter + .search(&store, &auth, &cursors(), "a", 1, 3) + .await + .unwrap(); + + assert_eq!(page.servers.len(), 3); +} + +#[tokio::test] +async fn a_later_search_page_has_no_local_fallback() { + let (base, state) = switchable_registry().await; + let auth = auth_at(&base); + let store = store(); + let adapter = adapter(); + + adapter + .search(&store, &auth, &cursors(), "", 1, 20) + .await + .unwrap(); + state.set(FAILING); + + let error = adapter + .search(&store, &auth, &cursors(), "dubai", 2, 20) + .await + .expect_err("only the first page falls back"); + + assert!( + matches!(error, Error::Http { status: 503, .. }), + "{error:?}" + ); +} + +#[tokio::test] +async fn a_search_matching_nothing_cached_reports_the_typed_error() { + let (base, state) = switchable_registry().await; + let auth = auth_at(&base); + let store = store(); + let adapter = adapter_with_cooldown(Duration::from_secs(60)); + + adapter + .search(&store, &auth, &cursors(), "", 1, 20) + .await + .unwrap(); + state.set(STALLED); + + let error = adapter + .search(&store, &auth, &cursors(), "zzqqxx", 1, 20) + .await + .expect_err("nothing cached matches"); + + assert!(error.is_timeout(), "{error:?}"); + assert_eq!(error.wire_name(), tinymcp_bus::errors::REGISTRY_TIMEOUT); +} + +#[tokio::test] +async fn a_search_with_an_empty_cache_reports_the_typed_error() { + let (base, state) = switchable_registry().await; + state.set(FAILING); + + let error = adapter() + .search(&store(), &auth_at(&base), &cursors(), "github", 1, 20) + .await + .expect_err("nothing cached"); + + assert!(error.is_registry_unavailable(), "{error:?}"); +} + +#[tokio::test] +async fn a_body_that_does_not_decode_is_not_hidden_behind_the_cache() { + let (base, state) = switchable_registry().await; + let auth = auth_at(&base); + let store = store(); + let adapter = adapter(); + + adapter + .search(&store, &auth, &cursors(), "", 1, 20) + .await + .unwrap(); + expire_cache(&store); + state.set(GARBLED); + + let error = adapter + .search(&store, &auth, &cursors(), "", 1, 20) + .await + .expect_err("a malformed answer is not an outage"); + + assert!( + matches!(error, Error::MalformedResponse { .. }), + "{error:?}" + ); +} + +#[tokio::test] +async fn a_stalled_search_skips_the_network_for_the_cooldown() { + let (base, state) = switchable_registry().await; + let auth = auth_at(&base); + let store = store(); + let adapter = adapter_with_cooldown(Duration::from_secs(60)); + + state.set(STALLED); + adapter + .search(&store, &auth, &cursors(), "github", 1, 20) + .await + .expect_err("stalled"); + let after_first = state.requests(); + let started = std::time::Instant::now(); + + let error = adapter + .search(&store, &auth, &cursors(), "notion", 1, 20) + .await + .expect_err("still cooling down"); + + assert_eq!(state.requests(), after_first, "nothing was sent"); + assert!( + started.elapsed() < Duration::from_millis(100), + "{:?}", + started.elapsed() + ); + match error { + Error::RegistryTimeout { + operation, timeout, .. + } => { + assert_eq!(operation, RegistryOperation::Search); + assert_eq!(timeout, Duration::from_millis(100)); + } + other => panic!("expected a registry timeout, got {other:?}"), + } +} + +#[tokio::test] +async fn a_cooling_down_search_still_answers_from_the_cache() { + let (base, state) = switchable_registry().await; + let auth = auth_at(&base); + let store = store(); + let adapter = adapter_with_cooldown(Duration::from_secs(60)); + + adapter + .search(&store, &auth, &cursors(), "", 1, 20) + .await + .unwrap(); + state.set(STALLED); + adapter + .search(&store, &auth, &cursors(), "github", 1, 20) + .await + .expect_err("stalled"); + let after_first = state.requests(); + + let page = adapter + .search(&store, &auth, &cursors(), "dubai", 1, 20) + .await + .expect("cached catalog rows stand in"); + + assert_eq!(state.requests(), after_first); + assert_eq!(page.freshness, RegistryFreshness::LocalFallback); +} + +#[tokio::test] +async fn a_search_cooldown_does_not_hold_back_browsing() { + let (base, state) = switchable_registry().await; + let auth = auth_at(&base); + let store = store(); + let adapter = adapter_with_cooldown(Duration::from_secs(60)); + + state.set(STALLED); + adapter + .search(&store, &auth, &cursors(), "github", 1, 20) + .await + .expect_err("stalled"); + state.set(UP); + + let page = adapter + .search(&store, &auth, &cursors(), "", 1, 20) + .await + .expect("browsing goes to the network"); + + assert_eq!(page.freshness, RegistryFreshness::Live); +} + +#[tokio::test] +async fn the_network_is_tried_again_once_the_cooldown_ends() { + let (base, state) = switchable_registry().await; + let auth = auth_at(&base); + let store = store(); + let adapter = adapter_with_cooldown(Duration::ZERO); + + state.set(STALLED); + adapter + .search(&store, &auth, &cursors(), "github", 1, 20) + .await + .expect_err("stalled"); + state.set(UP); + + let page = adapter + .search(&store, &auth, &cursors(), "notion", 1, 20) + .await + .expect("the registry answers again"); + + assert_eq!(page.freshness, RegistryFreshness::Live); + assert_eq!(page.servers[0].qualified_name, "@acme/notion"); +} + +#[tokio::test] +async fn a_cooldown_is_scoped_to_the_registry_that_stalled() { + let (stalled_base, stalled) = switchable_registry().await; + let (healthy_base, _healthy) = switchable_registry().await; + let store = store(); + let adapter = adapter_with_cooldown(Duration::from_secs(60)); + + stalled.set(STALLED); + adapter + .search(&store, &auth_at(&stalled_base), &cursors(), "github", 1, 20) + .await + .expect_err("stalled"); + + let page = adapter + .search(&store, &auth_at(&healthy_base), &cursors(), "github", 1, 20) + .await + .expect("another registry is not cooling down"); + + assert_eq!(page.freshness, RegistryFreshness::Live); +} + +#[tokio::test] +async fn a_failure_status_starts_no_cooldown() { + let (base, state) = switchable_registry().await; + let auth = auth_at(&base); + let store = store(); + let adapter = adapter_with_cooldown(Duration::from_secs(60)); + + state.set(FAILING); + adapter + .search(&store, &auth, &cursors(), "github", 1, 20) + .await + .expect_err("503"); + state.set(UP); + + let page = adapter + .search(&store, &auth, &cursors(), "github", 1, 20) + .await + .expect("an answered failure is retried at once"); + + assert_eq!(page.freshness, RegistryFreshness::Live); +} + +#[test] +fn every_browse_page_shares_the_browse_prefix_and_no_search_page_does() { + assert!(search_cache_key("", 1, 20).starts_with(super::BROWSE_CACHE_PREFIX)); + assert!(search_cache_key("", 7, 50).starts_with(super::BROWSE_CACHE_PREFIX)); + assert!(!search_cache_key("github", 1, 20).starts_with(super::BROWSE_CACHE_PREFIX)); +} + +#[test] +fn a_local_match_skips_cached_pages_that_do_not_decode() { + let store = store(); + store + .cache(&search_cache_key("", 1, 20), "not json") + .unwrap(); + store + .cache( + &search_cache_key("", 2, 20), + &json!({ "servers": [envelope("@acme/weather")] }).to_string(), + ) + .unwrap(); + + let page = super::fallback::serve_cached(&store, &cursors(), "weather", 1, 20) + .expect("the decodable page answers"); + + assert_eq!(page.servers[0].qualified_name, "@acme/weather"); +} + +#[test] +fn a_blank_query_has_no_local_matches() { + let store = store(); + store + .cache( + &search_cache_key("", 1, 20), + &json!({ "servers": [envelope("@acme/weather")] }).to_string(), + ) + .unwrap(); + + assert!(super::fallback::serve_cached(&store, &cursors(), " ", 1, 20).is_none()); +} diff --git a/crates/tinymcp/src/registry/sources/types.rs b/crates/tinymcp/src/registry/sources/types.rs index f7b697e..69530ef 100644 --- a/crates/tinymcp/src/registry/sources/types.rs +++ b/crates/tinymcp/src/registry/sources/types.rs @@ -11,7 +11,8 @@ use super::smithery::SmitheryRegistry; use crate::error::{Error, Result}; use crate::registry::Store; use tinymcp_bus::{ - McpRegistryAuthConfig, RegistryServerDetail, RegistryServerSummary, RegistrySettings, + McpRegistryAuthConfig, RegistryFreshness, RegistryServerDetail, RegistryServerSummary, + RegistrySettings, }; /// The identifier Smithery stamps on its rows. @@ -78,6 +79,9 @@ pub struct RegistryTimeouts { pub search: Duration, /// One server's detail. pub detail: Duration, + /// How long listings of the same kind skip the network after one timed + /// out, answering from what is cached instead. + pub cooldown: Duration, } impl RegistryTimeouts { @@ -99,10 +103,22 @@ impl Default for RegistryTimeouts { browse: Duration::from_secs(15), search: Duration::from_secs(8), detail: Duration::from_secs(12), + cooldown: Duration::from_secs(60), } } } +/// One page from one upstream catalog. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct SourcePage { + /// The rows on this page. + pub servers: Vec, + /// A best-effort upper bound on the page count. See [`Registries::search`]. + pub total_pages: u32, + /// Where the rows came from. + pub freshness: RegistryFreshness, +} + /// One upstream catalog. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] @@ -180,15 +196,19 @@ impl Registries { /// Searches one source. /// - /// Returns the rows and a best-effort upper bound on the page count. A - /// source that cannot know the true total reports the current page plus one - /// while more results exist, which is enough for a caller to offer a "next" - /// control without committing to a number it would have to walk the whole - /// catalog to learn. + /// Returns the rows, a best-effort upper bound on the page count, and + /// where the rows came from. A source that cannot know the true total + /// reports the current page plus one while more results exist, which is + /// enough for a caller to offer a "next" control without committing to a + /// number it would have to walk the whole catalog to learn. + /// + /// When the official registry cannot answer, the page is served from what + /// is cached and its freshness says so; see + /// [`RegistryFreshness`](tinymcp_bus::RegistryFreshness). /// /// # Errors /// - /// Returns whatever the upstream returns. + /// Returns whatever the upstream returns when nothing cached can stand in. pub async fn search( &self, store: &Store, @@ -196,7 +216,7 @@ impl Registries { query: Option<&str>, page: u32, page_size: u32, - ) -> Result<(Vec, u32)> { + ) -> Result { let query = query.unwrap_or_default().trim(); let page = page.max(1); let page_size = if page_size == 0 { @@ -217,9 +237,15 @@ impl Registries { } RegistrySource::Smithery => { let key = self.smithery_key(); - self.smithery + let (servers, total_pages) = self + .smithery .search(store, key.as_deref(), query, page, page_size) - .await + .await?; + Ok(SourcePage { + servers, + total_pages, + freshness: RegistryFreshness::Live, + }) } } } diff --git a/crates/tinymcp/src/registry/store/mod_tests.rs b/crates/tinymcp/src/registry/store/mod_tests.rs index 2ca18e7..87786e6 100644 --- a/crates/tinymcp/src/registry/store/mod_tests.rs +++ b/crates/tinymcp/src/registry/store/mod_tests.rs @@ -523,6 +523,59 @@ fn an_entry_older_than_the_lifetime_misses() { assert_eq!(store.cached("key").unwrap(), None); } +/// Moves every cached entry `minutes` into the past. +fn age_cache(store: &Store, minutes: i64) { + store.with_connection(|connection| { + connection + .execute( + "UPDATE mcp_registry_cache SET cached_at = cached_at - ?1", + rusqlite::params![minutes * 60 * 1_000], + ) + .unwrap(); + }); +} + +#[test] +fn a_stale_read_returns_an_entry_past_its_lifetime() { + let (_directory, store) = store(); + store.cache("key", "old").unwrap(); + age_cache(&store, 60 * 24); + + assert_eq!(store.cached("key").unwrap(), None); + assert_eq!(store.cached_stale("key").unwrap().as_deref(), Some("old")); + assert_eq!(store.cached_stale("other").unwrap(), None); +} + +#[test] +fn a_prefix_read_returns_matching_entries_newest_first() { + let (_directory, store) = store(); + store.cache("browse:1", "older").unwrap(); + age_cache(&store, 30); + store.cache("browse:2", "newer").unwrap(); + store.cache("search:x:1", "unrelated").unwrap(); + + assert_eq!( + store.cached_with_prefix("browse:").unwrap(), + vec!["newer".to_string(), "older".to_string()] + ); +} + +#[test] +fn a_prefix_read_treats_like_wildcards_literally() { + let (_directory, store) = store(); + store.cache("a_b:1", "literal").unwrap(); + store.cache("axb:1", "wildcard match").unwrap(); + + assert_eq!( + store.cached_with_prefix("a_b:").unwrap(), + vec!["literal".to_string()] + ); + assert_eq!( + store.cached_with_prefix("nothing:").unwrap(), + Vec::::new() + ); +} + #[test] fn cache_keys_are_independent() { let (_directory, store) = store(); diff --git a/crates/tinymcp/src/registry/store/types.rs b/crates/tinymcp/src/registry/store/types.rs index 815d571..953d97b 100644 --- a/crates/tinymcp/src/registry/store/types.rs +++ b/crates/tinymcp/src/registry/store/types.rs @@ -404,6 +404,48 @@ impl Store { }) } + /// A cached browse response, however old. + /// + /// For serving something when the upstream cannot answer; a fresh answer + /// reads [`Self::cached`]. + /// + /// # Errors + /// + /// Returns [`Error::Store`] when the query fails. + pub(crate) fn cached_stale(&self, cache_key: &str) -> Result> { + self.connection + .lock() + .query_row( + "SELECT body_json FROM mcp_registry_cache WHERE cache_key = ?1", + params![cache_key], + |row| row.get(0), + ) + .optional() + .map_err(|source| Error::store("reading the browse cache", source)) + } + + /// Every cached browse response whose key starts with `prefix`, however + /// old, newest first. + /// + /// # Errors + /// + /// Returns [`Error::Store`] when the query fails. + pub(crate) fn cached_with_prefix(&self, prefix: &str) -> Result> { + let connection = self.connection.lock(); + let mut statement = connection + .prepare( + "SELECT body_json FROM mcp_registry_cache + WHERE instr(cache_key, ?1) = 1 + ORDER BY cached_at DESC, cache_key", + ) + .map_err(|source| Error::store("reading the browse cache", source))?; + + statement + .query_map(params![prefix], |row| row.get(0)) + .and_then(Iterator::collect) + .map_err(|source| Error::store("reading the browse cache", source)) + } + /// Caches a browse response against the current time. /// /// # Errors @@ -423,7 +465,7 @@ impl Store { /// Runs `body` against the connection. For tests that need raw access. #[cfg(test)] - pub(super) fn with_connection(&self, body: impl FnOnce(&Connection) -> T) -> T { + pub(crate) fn with_connection(&self, body: impl FnOnce(&Connection) -> T) -> T { body(&self.connection.lock()) } } From 7841bc21891023caeefdfc0499f21ef49786f218 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 17:40:35 +0530 Subject: [PATCH 06/27] test(registry): cover Smithery routing through the dispatcher (#42) --- .../tinymcp/src/registry/sources/mod_tests.rs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/crates/tinymcp/src/registry/sources/mod_tests.rs b/crates/tinymcp/src/registry/sources/mod_tests.rs index cc655f9..bf36471 100644 --- a/crates/tinymcp/src/registry/sources/mod_tests.rs +++ b/crates/tinymcp/src/registry/sources/mod_tests.rs @@ -171,6 +171,52 @@ async fn a_detail_lookup_for_an_unknown_source_is_refused() { ); } +#[tokio::test] +async fn a_smithery_detail_lookup_routes_to_smithery() { + let store = crate::registry::Store::open_in_memory().unwrap(); + store + .cache( + "smithery:detail:@acme/weather", + &json!({ "qualifiedName": "@acme/weather", "displayName": "Weather" }).to_string(), + ) + .unwrap(); + + let detail = registries(McpRegistryAuthConfig::default()) + .get(&store, SOURCE_SMITHERY, "@acme/weather") + .await + .expect("served from the cache"); + + assert_eq!(detail.source, SOURCE_SMITHERY); +} + +// --------------------------------------------------------------------------- +// Search routing +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn a_smithery_search_is_live_and_a_zero_page_size_means_the_default() { + let store = crate::registry::Store::open_in_memory().unwrap(); + store + .cache( + "smithery:search:weather:1:20", + &json!({ + "servers": [{ "qualifiedName": "@acme/weather", "displayName": "Weather" }], + "pagination": { "totalPages": 3 }, + }) + .to_string(), + ) + .unwrap(); + + let page = registries(with_smithery_key()) + .search(&store, RegistrySource::Smithery, Some(" weather "), 0, 0) + .await + .expect("served from the cache"); + + assert_eq!(page.servers[0].qualified_name, "@acme/weather"); + assert_eq!(page.total_pages, 3); + assert_eq!(page.freshness, tinymcp_bus::RegistryFreshness::Live); +} + // --------------------------------------------------------------------------- // Smithery trust-signal scrubbing // --------------------------------------------------------------------------- From 1054e3bbd7121e507ee57da1bd228eed7f7592a1 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 17:40:35 +0530 Subject: [PATCH 07/27] docs: describe registry listing freshness, icons and budgets (#42) --- README.md | 23 +++++++++++++++++++++++ ROADMAP.md | 3 +++ 2 files changed, 26 insertions(+) diff --git a/README.md b/README.md index 54bd4ff..0fd3f9f 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,29 @@ 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, cached catalog rows matching every word of + the query (`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. `RegistrySearchPage::freshness` (contract 1.4) tells a host + which kind of answer it got. + ## `mcp.json` and OAuth for hosts with their own store `registry::config_doc` reads and writes the `{ "mcpServers": { … } }` document. diff --git a/ROADMAP.md b/ROADMAP.md index 50dd0eb..c80509f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,6 +18,9 @@ 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) ## Next From 41239764898445f320e916094a1a504a6295f256 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 20:03:20 +0530 Subject: [PATCH 08/27] fix(registry): match cached server details when search falls back (#42) --- .../src/registry/sources/official/fallback.rs | 36 ++-- .../src/registry/sources/official/mod.rs | 5 +- .../registry/sources/official/mod_tests.rs | 173 ++++++++++++++++++ .../src/registry/sources/official/types.rs | 7 +- 4 files changed, 208 insertions(+), 13 deletions(-) diff --git a/crates/tinymcp/src/registry/sources/official/fallback.rs b/crates/tinymcp/src/registry/sources/official/fallback.rs index 6f956ac..abe38f2 100644 --- a/crates/tinymcp/src/registry/sources/official/fallback.rs +++ b/crates/tinymcp/src/registry/sources/official/fallback.rs @@ -1,8 +1,9 @@ //! What the official adapter serves when the registry cannot answer. //! //! In order: an earlier answer to the same request, however old; for the -//! first page of a search, rows from cached catalog pages that match the -//! query; otherwise nothing, and the caller returns the error. +//! first page of a search, rows from cached catalog pages and cached server +//! details that match the query; otherwise nothing, and the caller returns the +//! error. //! //! A listing that timed out also starts a cooldown for listings of its kind, //! so the next keystrokes go straight to the cache instead of each waiting out @@ -13,8 +14,8 @@ use std::time::{Duration, Instant}; use parking_lot::Mutex; -use super::types::OfficialListResponse; -use super::{BROWSE_CACHE_PREFIX, CursorCache, search_cache_key, served}; +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::sources::types::{RegistryOperation, SourcePage}; @@ -91,7 +92,7 @@ pub(super) fn serve_cached( tracing::debug!( query_length = query.len(), matches = servers.len(), - "official search served from cached catalog pages" + "official search served from cached catalog pages and details" ); (!servers.is_empty()).then_some(SourcePage { @@ -125,9 +126,11 @@ fn stale_page( }) } -/// Rows from every cached catalog page that match `query`, at most `limit`. +/// Rows from every cached catalog page and server detail that match `query`, +/// at most `limit`. /// -/// A row matches when every word of the query appears in its name, title, or +/// 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 { let terms: Vec = query.split_whitespace().map(str::to_lowercase).collect(); @@ -135,14 +138,25 @@ fn local_matches(store: &Store, query: &str, limit: u32) -> Vec = bodies + let details = store + .cached_with_prefix(DETAIL_CACHE_PREFIX) + .unwrap_or_default(); + let page_rows = pages .iter() .filter_map(|body| serde_json::from_str::(body).ok()) - .flat_map(OfficialListResponse::into_summaries) + .flat_map(OfficialListResponse::into_summaries); + let detail_rows = details + .iter() + .filter_map(|body| serde_json::from_str::(body).ok()) + .filter(OfficialServer::is_installable) + .map(OfficialServer::into_summary); + + let mut seen = HashSet::new(); + let mut matches: Vec<(bool, RegistryServerSummary)> = page_rows + .chain(detail_rows) .filter(|row| seen.insert(row.qualified_name.clone())) .filter_map(|row| { let label = format!("{} {}", row.qualified_name, row.display_name).to_lowercase(); diff --git a/crates/tinymcp/src/registry/sources/official/mod.rs b/crates/tinymcp/src/registry/sources/official/mod.rs index a4a5abb..c7a5fc9 100644 --- a/crates/tinymcp/src/registry/sources/official/mod.rs +++ b/crates/tinymcp/src/registry/sources/official/mod.rs @@ -66,6 +66,9 @@ const MAX_CURSOR_WALK_PAGES: u32 = 50; /// The cache key prefix every page of the unfiltered catalog shares. const BROWSE_CACHE_PREFIX: &str = "mcp_official:search:latest::"; +/// The cache key prefix every server detail shares. +const DETAIL_CACHE_PREFIX: &str = "mcp_official:detail:"; + /// The map from page to the cursor that produced it. type CursorCache = Mutex>; @@ -226,7 +229,7 @@ impl McpOfficialRegistry { auth: &McpRegistryAuthConfig, qualified_name: &str, ) -> Result { - let cache_key = format!("mcp_official:detail:{qualified_name}"); + let cache_key = format!("{DETAIL_CACHE_PREFIX}{qualified_name}"); if let Ok(Some(cached)) = store.cached(&cache_key) && let Ok(server) = serde_json::from_str::(&cached) diff --git a/crates/tinymcp/src/registry/sources/official/mod_tests.rs b/crates/tinymcp/src/registry/sources/official/mod_tests.rs index 7dd3979..3ef9d9a 100644 --- a/crates/tinymcp/src/registry/sources/official/mod_tests.rs +++ b/crates/tinymcp/src/registry/sources/official/mod_tests.rs @@ -2078,3 +2078,176 @@ fn a_blank_query_has_no_local_matches() { assert!(super::fallback::serve_cached(&store, &cursors(), " ", 1, 20).is_none()); } + +// --------------------------------------------------------------------------- +// Matching cached server details +// --------------------------------------------------------------------------- + +/// A store holding only the cached details of `names`, as a detail lookup +/// writes them. +async fn store_with_details(names: &[&str]) -> Store { + let (base, _seen) = paged_registry(1).await; + let auth = auth_at(&base); + let store = store(); + let adapter = adapter(); + for name in names { + adapter.get(&store, &auth, name).await.unwrap(); + } + store +} + +/// Caches `server` as the detail of the server it names. +fn cache_detail(store: &Store, server: &Value) { + let name = server["name"].as_str().unwrap(); + store + .cache( + &format!("{}{name}", super::DETAIL_CACHE_PREFIX), + &server.to_string(), + ) + .unwrap(); +} + +fn names_of(page: &SourcePage) -> Vec<&str> { + page.servers + .iter() + .map(|row| row.qualified_name.as_str()) + .collect() +} + +#[tokio::test] +async fn a_stalled_search_matches_cached_server_details() { + let store = store_with_details(&["com.notion/mcp", "io.github.acme/weather"]).await; + let (base, state) = switchable_registry().await; + state.set(STALLED); + let started = std::time::Instant::now(); + + let page = adapter_with_cooldown(Duration::from_secs(60)) + .search(&store, &auth_at(&base), &cursors(), "notion", 1, 20) + .await + .expect("the cached detail stands in"); + + assert!( + started.elapsed() < Duration::from_secs(4), + "{:?}", + started.elapsed() + ); + assert_eq!(page.freshness, RegistryFreshness::LocalFallback); + assert_eq!(page.total_pages, 1); + assert_eq!(names_of(&page), ["com.notion/mcp"]); +} + +#[tokio::test] +async fn a_cooling_down_search_answers_from_cached_server_details() { + let store = store_with_details(&["com.notion/mcp"]).await; + let (base, state) = switchable_registry().await; + let auth = auth_at(&base); + let adapter = adapter_with_cooldown(Duration::from_secs(60)); + + state.set(STALLED); + adapter + .search(&store, &auth, &cursors(), "github", 1, 20) + .await + .expect_err("stalled"); + let after_first = state.requests(); + let started = std::time::Instant::now(); + + let page = adapter + .search(&store, &auth, &cursors(), "Notion", 1, 20) + .await + .expect("the cached detail stands in"); + + assert_eq!(state.requests(), after_first, "nothing was sent"); + assert!( + started.elapsed() < Duration::from_millis(100), + "{:?}", + started.elapsed() + ); + assert_eq!(page.freshness, RegistryFreshness::LocalFallback); + assert_eq!(names_of(&page), ["com.notion/mcp"]); +} + +#[tokio::test] +async fn a_search_matching_no_cached_detail_reports_the_typed_error() { + let store = store_with_details(&["com.notion/mcp"]).await; + let (base, state) = switchable_registry().await; + let auth = auth_at(&base); + let adapter = adapter_with_cooldown(Duration::from_secs(60)); + state.set(STALLED); + + let error = adapter + .search(&store, &auth, &cursors(), "slack", 1, 20) + .await + .expect_err("no cached detail matches"); + assert_eq!(error.wire_name(), tinymcp_bus::errors::REGISTRY_TIMEOUT); + + let error = adapter + .search(&store, &auth, &cursors(), "slack", 1, 20) + .await + .expect_err("still nothing matches while cooling down"); + assert_eq!(error.wire_name(), tinymcp_bus::errors::REGISTRY_TIMEOUT); +} + +#[test] +fn a_server_cached_as_a_page_row_and_a_detail_appears_once() { + let store = store(); + store + .cache( + &search_cache_key("", 1, 20), + &json!({ "servers": [envelope("com.notion/mcp")] }).to_string(), + ) + .unwrap(); + cache_detail(&store, &envelope("com.notion/mcp")["server"]); + + let page = super::fallback::serve_cached(&store, &cursors(), "notion", 1, 20).unwrap(); + + assert_eq!(names_of(&page), ["com.notion/mcp"]); +} + +#[test] +fn a_detail_matching_on_its_name_leads_a_page_row_matching_on_its_description() { + let store = store(); + store + .cache( + &search_cache_key("", 1, 20), + &json!({ "servers": [{ + "server": { + "name": "io.github.acme/pages", + "description": "Sync pages to Notion", + "packages": [{ "registryType": "npm", "identifier": "pages" }], + }, + }] }) + .to_string(), + ) + .unwrap(); + cache_detail(&store, &envelope("com.notion/mcp")["server"]); + + let page = super::fallback::serve_cached(&store, &cursors(), "notion", 1, 20).unwrap(); + + assert_eq!(names_of(&page), ["com.notion/mcp", "io.github.acme/pages"]); +} + +#[test] +fn a_detail_offering_no_way_to_connect_is_not_matched() { + let store = store(); + cache_detail(&store, &json!({ "name": "com.notion/mcp" })); + store + .cache( + &format!("{}com.notion/broken", super::DETAIL_CACHE_PREFIX), + "not json", + ) + .unwrap(); + + assert!(super::fallback::serve_cached(&store, &cursors(), "notion", 1, 20).is_none()); +} + +#[test] +fn local_matches_from_details_are_capped_at_the_page_size() { + let store = store(); + for name in ["com.notion/a", "com.notion/b", "com.notion/c"] { + cache_detail(&store, &envelope(name)["server"]); + } + + let page = super::fallback::serve_cached(&store, &cursors(), "notion", 1, 2).unwrap(); + + assert_eq!(page.servers.len(), 2); +} diff --git a/crates/tinymcp/src/registry/sources/official/types.rs b/crates/tinymcp/src/registry/sources/official/types.rs index 8c0714d..82f5aa8 100644 --- a/crates/tinymcp/src/registry/sources/official/types.rs +++ b/crates/tinymcp/src/registry/sources/official/types.rs @@ -109,7 +109,7 @@ struct OfficialServerEnvelope { impl OfficialServerEnvelope { /// Whether this row offers any way to connect at all. fn is_installable(&self) -> bool { - !self.server.remotes.is_empty() || !self.server.packages.is_empty() + self.server.is_installable() } /// Whether the registry has withdrawn this version. @@ -181,6 +181,11 @@ pub(super) struct OfficialServer { } impl OfficialServer { + /// Whether this server offers any way to connect at all. + pub(super) fn is_installable(&self) -> bool { + !self.remotes.is_empty() || !self.packages.is_empty() + } + /// The icon to show for this server. /// /// A raster image ahead of an SVG, and an SVG when it is the only one From 2e70d794e3b4bba3722066c67caf0ca14a76284a Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 20:03:20 +0530 Subject: [PATCH 09/27] docs: note that search fallback matches cached server details (#42) --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0fd3f9f..2f7c9ec 100644 --- a/README.md +++ b/README.md @@ -222,10 +222,11 @@ when a key is configured. 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, cached catalog rows matching every word of - the query (`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. `RegistrySearchPage::freshness` (contract 1.4) tells a host + for the first page of a search, cached catalog rows and cached server + details matching every word of the query (`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. ## `mcp.json` and OAuth for hosts with their own store From 18c4b3f368f7c7750c9b4d6da1ae8ba643bda33e Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 18:57:30 +0530 Subject: [PATCH 10/27] fix(oauth): discover authorization metadata on the origin when a 401 names none (#44) --- .../tinymcp/src/transport/http/discovery.rs | 348 +++++++++++ .../src/transport/http/discovery_tests.rs | 566 ++++++++++++++++++ crates/tinymcp/src/transport/http/mod.rs | 80 ++- 3 files changed, 984 insertions(+), 10 deletions(-) create mode 100644 crates/tinymcp/src/transport/http/discovery.rs create mode 100644 crates/tinymcp/src/transport/http/discovery_tests.rs diff --git a/crates/tinymcp/src/transport/http/discovery.rs b/crates/tinymcp/src/transport/http/discovery.rs new file mode 100644 index 0000000..0ca640d --- /dev/null +++ b/crates/tinymcp/src/transport/http/discovery.rs @@ -0,0 +1,348 @@ +//! Finding an authorization server when a 401 names no protected-resource +//! metadata. +//! +//! A Bearer challenge without `resource_metadata` is not proof of a static +//! token. The 2025-06-18 authorization spec also publishes protected-resource +//! metadata at `/.well-known/oauth-protected-resource[/path]`, and servers on the +//! 2025-03-26 spec are their own authorization server, publishing RFC 8414 +//! metadata on the MCP origin. This module looks in those places, in that +//! order, and nowhere else: default `/authorize` and `/token` paths are never +//! guessed. +//! +//! Every lookup is a `GET` to the MCP server's own origin over a client that +//! follows no redirects and reads at most [`MAX_DOCUMENT_BYTES`], so a server +//! cannot bounce discovery to another host or stall it with an endless body. An +//! authorization server named by protected-resource metadata is read the same +//! way the challenge path reads one. + +use std::time::Duration; + +use futures_util::StreamExt; +use reqwest::{StatusCode, Url}; +use serde_json::Value; + +use super::{McpHttpClient, fill_missing_metadata}; +use crate::transport::redact_endpoint; +use tinymcp_bus::{AuthorizationServerMetadata, ProtectedResourceMetadata}; + +/// The total time a well-known lookup may take before it is abandoned. +pub(super) const DISCOVERY_BUDGET: Duration = Duration::from_secs(5); + +/// The largest metadata document read; anything longer is ignored. +pub(super) const MAX_DOCUMENT_BYTES: usize = 64 * 1024; + +const PROTECTED_RESOURCE_PATH: &str = "/.well-known/oauth-protected-resource"; +const AUTHORIZATION_SERVER_PATH: &str = "/.well-known/oauth-authorization-server"; +const OPENID_CONFIGURATION_PATH: &str = "/.well-known/openid-configuration"; + +/// What the well-known lookup concluded. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum WellKnownOutcome { + /// Metadata naming at least one authorization server was found. + Found(WellKnownAuthorization), + /// Every place was checked and none published usable metadata. + NotFound, + /// A place could not be checked, so the answer may change on a retry. + Transient, +} + +/// Authorization metadata found on the server's origin. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct WellKnownAuthorization { + /// The well-known document that yielded the authorization server. + pub(super) metadata_url: String, + /// The protected-resource metadata, when the server published a valid one. + pub(super) protected_resource_metadata: Option, + /// Every authorization server that could be read. + pub(super) authorization_servers: Vec, +} + +impl WellKnownAuthorization { + /// Whether some authorization server names both endpoints a sign-in needs. + pub(super) fn offers_sign_in(&self) -> bool { + self.authorization_servers.iter().any(|metadata| { + metadata.authorization_endpoint.is_some() && metadata.token_endpoint.is_some() + }) + } +} + +/// One fetched discovery document. +#[derive(Debug)] +enum Fetched { + Document(Value), + Missing, + Transient, +} + +/// What the origin's own authorization-server metadata lookup found. +#[derive(Debug)] +enum OriginLookup { + Found(String, AuthorizationServerMetadata), + Missing, + Transient, +} + +impl McpHttpClient { + /// Looks for authorization metadata on this server's origin, once per + /// client. + /// + /// A definitive answer is cached; a transient one, including running out + /// of [`DISCOVERY_BUDGET`], is not, so a later 401 looks again. + pub(super) async fn well_known_authorization(&self) -> WellKnownOutcome { + if let Some(cached) = self.well_known.lock().clone() { + return cached; + } + + let outcome = tokio::time::timeout(DISCOVERY_BUDGET, self.discover_from_well_known()) + .await + .unwrap_or(WellKnownOutcome::Transient); + + if outcome != WellKnownOutcome::Transient { + *self.well_known.lock() = Some(outcome.clone()); + } + outcome + } + + async fn discover_from_well_known(&self) -> WellKnownOutcome { + let Some(origin) = origin_of(&self.endpoint) else { + return WellKnownOutcome::NotFound; + }; + let mut transient = false; + let mut protected_resource = None; + + for candidate in protected_resource_candidates(&self.endpoint, &origin) { + let document = match self.fetch_discovery_json(&candidate).await { + Fetched::Document(document) => document, + Fetched::Missing => continue, + Fetched::Transient => { + transient = true; + continue; + } + }; + + if let Some(metadata) = same_origin_protected_resource(&document, &origin) { + let (servers, unreadable) = self + .read_authorization_servers(&metadata.authorization_servers) + .await; + if !servers.is_empty() { + return WellKnownOutcome::Found(WellKnownAuthorization { + metadata_url: candidate, + protected_resource_metadata: Some(metadata), + authorization_servers: servers, + }); + } + transient |= unreadable; + protected_resource.get_or_insert((candidate, metadata)); + continue; + } + + if let Some(metadata) = origin_authorization_server(&document, &origin) { + return WellKnownOutcome::Found(WellKnownAuthorization { + metadata_url: candidate, + protected_resource_metadata: None, + authorization_servers: vec![metadata], + }); + } + + tracing::debug!( + url = %redact_endpoint(&candidate), + "[mcp] ignoring a well-known document that is neither same-origin resource nor issuer metadata" + ); + } + + match self.origin_authorization_server(&origin).await { + OriginLookup::Found(metadata_url, metadata) => { + let (metadata_url, protected_resource_metadata) = match protected_resource { + Some((url, resource)) => (url, Some(resource)), + None => (metadata_url, None), + }; + WellKnownOutcome::Found(WellKnownAuthorization { + metadata_url, + protected_resource_metadata, + authorization_servers: vec![metadata], + }) + } + OriginLookup::Transient => WellKnownOutcome::Transient, + OriginLookup::Missing if transient => WellKnownOutcome::Transient, + OriginLookup::Missing => WellKnownOutcome::NotFound, + } + } + + /// Reads each authorization server a protected resource names. + /// + /// Returns those that could be read, and whether any could not. + async fn read_authorization_servers( + &self, + issuers: &[String], + ) -> (Vec, bool) { + let mut servers = Vec::new(); + let mut unreadable = false; + for issuer in issuers { + match self.fetch_authorization_server_metadata(issuer).await { + Ok(metadata) => servers.push(metadata), + Err(error) => { + unreadable = true; + tracing::debug!( + issuer = %redact_endpoint(issuer), + "[mcp] skipping an authorization server whose metadata could not be read: {error}" + ); + } + } + } + (servers, unreadable) + } + + /// Reads RFC 8414 metadata from the origin, then `OpenID` discovery, and + /// accepts a document only when its issuer is the origin itself. + async fn origin_authorization_server(&self, origin: &Url) -> OriginLookup { + let base = origin_text(origin); + let mut transient = false; + let mut found: Option<(String, AuthorizationServerMetadata)> = None; + + for path in [AUTHORIZATION_SERVER_PATH, OPENID_CONFIGURATION_PATH] { + let url = format!("{base}{path}"); + let document = match self.fetch_discovery_json(&url).await { + Fetched::Document(document) => document, + Fetched::Missing => continue, + Fetched::Transient => { + transient = true; + continue; + } + }; + let Some(metadata) = origin_authorization_server(&document, origin) else { + continue; + }; + found = Some(match found { + None => (url, metadata), + Some((first_url, first)) => (first_url, fill_missing_metadata(first, metadata)), + }); + if found.as_ref().is_some_and(|(_, metadata)| { + metadata.authorization_endpoint.is_some() && metadata.token_endpoint.is_some() + }) { + break; + } + } + + match found { + Some((url, metadata)) => OriginLookup::Found(url, metadata), + None if transient => OriginLookup::Transient, + None => OriginLookup::Missing, + } + } + + /// Fetches one discovery document without following redirects. + /// + /// A redirect, a 401, 403, 404 or 410, a body that is not JSON and a body + /// over [`MAX_DOCUMENT_BYTES`] all mean the document is not there. A 5xx or + /// a transport failure means it could not be checked. + async fn fetch_discovery_json(&self, url: &str) -> Fetched { + let response = match self.discovery_http.get(url).send().await { + Ok(response) => response, + Err(error) => { + tracing::debug!(url = %redact_endpoint(url), "[mcp] well-known lookup failed: {error}"); + return Fetched::Transient; + } + }; + + let status = response.status(); + if status.is_server_error() { + return Fetched::Transient; + } + if !status.is_success() { + if !(status.is_redirection() || is_definitive_absence(status)) { + tracing::debug!(url = %redact_endpoint(url), %status, "[mcp] unexpected well-known status"); + } + return Fetched::Missing; + } + if response + .content_length() + .is_some_and(|length| length > MAX_DOCUMENT_BYTES as u64) + { + return Fetched::Missing; + } + + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let Ok(chunk) = chunk else { + return Fetched::Transient; + }; + if body.len() + chunk.len() > MAX_DOCUMENT_BYTES { + return Fetched::Missing; + } + body.extend_from_slice(&chunk); + } + + serde_json::from_slice(&body).map_or(Fetched::Missing, Fetched::Document) + } +} + +fn is_definitive_absence(status: StatusCode) -> bool { + matches!( + status, + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN | StatusCode::NOT_FOUND | StatusCode::GONE + ) +} + +/// The scheme, host and port of `endpoint`, with an empty path. +fn origin_of(endpoint: &str) -> Option { + let url = Url::parse(endpoint).ok()?; + if !matches!(url.scheme(), "http" | "https") || url.host().is_none() { + return None; + } + Url::parse(&url.origin().ascii_serialization()).ok() +} + +/// `origin` without its trailing slash, ready to have a path appended. +fn origin_text(origin: &Url) -> String { + origin.as_str().trim_end_matches('/').to_string() +} + +/// Where protected-resource metadata may live: under the endpoint's path +/// first, then at the origin root. +fn protected_resource_candidates(endpoint: &str, origin: &Url) -> Vec { + let base = origin_text(origin); + let root = format!("{base}{PROTECTED_RESOURCE_PATH}"); + let path = Url::parse(endpoint) + .map(|url| url.path().trim_end_matches('/').to_string()) + .unwrap_or_default(); + + if path.is_empty() { + vec![root] + } else { + vec![format!("{root}{path}"), root] + } +} + +/// `document` read as protected-resource metadata for a resource on `origin`. +fn same_origin_protected_resource( + document: &Value, + origin: &Url, +) -> Option { + let metadata: ProtectedResourceMetadata = serde_json::from_value(document.clone()).ok()?; + let resource = Url::parse(&metadata.resource).ok()?; + (resource.origin() == origin.origin()).then_some(metadata) +} + +/// `document` read as authorization-server metadata whose issuer is `origin`. +fn origin_authorization_server( + document: &Value, + origin: &Url, +) -> Option { + let metadata: AuthorizationServerMetadata = serde_json::from_value(document.clone()).ok()?; + issuer_is_origin(&metadata.issuer, origin).then_some(metadata) +} + +/// Whether `issuer` names exactly `origin`, with or without one trailing +/// slash. +/// +/// An origin URL has no path to distinguish, so `https://a.example` and +/// `https://a.example/` identify the same issuer. Anything longer does not. +pub(super) fn issuer_is_origin(issuer: &str, origin: &Url) -> bool { + let base = origin_text(origin); + issuer == base || issuer.strip_suffix('/') == Some(base.as_str()) +} + +#[cfg(test)] +#[path = "discovery_tests.rs"] +mod tests; diff --git a/crates/tinymcp/src/transport/http/discovery_tests.rs b/crates/tinymcp/src/transport/http/discovery_tests.rs new file mode 100644 index 0000000..093ac21 --- /dev/null +++ b/crates/tinymcp/src/transport/http/discovery_tests.rs @@ -0,0 +1,566 @@ +//! Tests for well-known authorization discovery. +//! +//! Each test binds a loopback listener first, so the documents it serves can +//! name the origin they are served from, then drives a real client at it. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use axum::Router; +use axum::body::Body; +use axum::http::StatusCode as AxumStatus; +use axum::response::{IntoResponse, Response as AxumResponse}; +use axum::routing::{get, post}; +use serde_json::{Value, json}; + +use super::*; +use crate::Error; + +const PRM_PATH: &str = "/.well-known/oauth-protected-resource"; +const PRM_AT_MCP_PATH: &str = "/.well-known/oauth-protected-resource/mcp"; +const AS_PATH: &str = "/.well-known/oauth-authorization-server"; +const OIDC_PATH: &str = "/.well-known/openid-configuration"; + +/// A bound listener and the origin it will serve. +struct Origin { + listener: tokio::net::TcpListener, + base: String, +} + +async fn origin() -> Origin { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + Origin { listener, base } +} + +impl Origin { + /// Serves `app` with an MCP endpoint at `/mcp` that answers every POST + /// with a 401 carrying `challenge`, and returns that endpoint. + fn serve(self, challenge: &'static str, app: Router) -> String { + let app = app.route( + "/mcp", + post(move || async move { + ( + AxumStatus::UNAUTHORIZED, + [("WWW-Authenticate", challenge)], + "", + ) + .into_response() + }), + ); + tokio::spawn(async move { axum::serve(self.listener, app).await.unwrap() }); + format!("{}/mcp", self.base) + } +} + +const ZOMATO_CHALLENGE: &str = + "Bearer error=\"invalid_token\", error_description=\"Authentication required\""; + +/// Authorization-server metadata for `issuer`, with endpoints on `base`. +fn issuer_metadata(issuer: &str, base: &str) -> Value { + json!({ + "issuer": issuer, + "authorization_endpoint": format!("{base}/authorize"), + "token_endpoint": format!("{base}/token"), + "registration_endpoint": format!("{base}/register"), + "response_types_supported": ["code"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post"], + }) +} + +fn document(body: Value) -> axum::routing::MethodRouter { + get(move || { + let body = body.clone(); + async move { axum::Json(body) } + }) +} + +fn client(endpoint: &str) -> McpHttpClient { + McpHttpClient::new(endpoint, 5).unwrap() +} + +/// The `resource_metadata` an `initialize` 401 carries. +async fn unauthorized_metadata(client: &McpHttpClient) -> Option { + match client.initialize().await.expect_err("a 401") { + Error::Unauthorized { + resource_metadata, .. + } => resource_metadata, + other => panic!("expected unauthorized, got {other:?}"), + } +} + +/// The Zomato shape: the MCP origin is its own authorization server and serves +/// that metadata at the protected-resource path, with no `resource` member. +async fn zomato_shaped() -> (String, String) { + let origin = origin().await; + let base = origin.base.clone(); + let metadata = issuer_metadata(&format!("{base}/"), &base); + let app = Router::new() + .route(PRM_PATH, document(metadata.clone())) + .route(AS_PATH, document(metadata)); + (origin.serve(ZOMATO_CHALLENGE, app), base) +} + +#[tokio::test] +async fn an_origin_serving_issuer_metadata_at_the_resource_path_is_discovered() { + let (endpoint, base) = zomato_shaped().await; + + let context = client(&endpoint) + .discover_authorization() + .await + .unwrap() + .expect("a 401"); + + assert_eq!(context.protected_resource_metadata, None); + assert_eq!(context.authorization_server_metadata.len(), 1); + let server = &context.authorization_server_metadata[0]; + assert_eq!(server.issuer, format!("{base}/")); + assert_eq!( + server.registration_endpoint.as_deref(), + Some(format!("{base}/register").as_str()) + ); +} + +#[tokio::test] +async fn a_401_from_an_origin_serving_issuer_metadata_advertises_oauth() { + let (endpoint, base) = zomato_shaped().await; + let client = client(&endpoint); + + let error = client.initialize().await.expect_err("a 401"); + + assert!(error.advertises_oauth(), "{error:?}"); + assert_eq!( + unauthorized_metadata(&client).await, + Some(format!("{base}{PRM_PATH}")) + ); +} + +#[tokio::test] +async fn protected_resource_metadata_under_the_endpoint_path_is_followed() { + let origin = origin().await; + let base = origin.base.clone(); + let app = Router::new() + .route( + PRM_AT_MCP_PATH, + document(json!({ + "resource": format!("{base}/mcp"), + "authorization_servers": [base], + })), + ) + .route(AS_PATH, document(issuer_metadata(&base, &base))); + let endpoint = origin.serve("Bearer realm=\"mcp\"", app); + let client = client(&endpoint); + + let context = client.discover_authorization().await.unwrap().unwrap(); + + let resource = context.protected_resource_metadata.expect("a resource"); + assert_eq!(resource.resource, format!("{base}/mcp")); + assert_eq!(context.authorization_server_metadata.len(), 1); + assert_eq!( + unauthorized_metadata(&client).await, + Some(format!("{base}{PRM_AT_MCP_PATH}")) + ); +} + +#[tokio::test] +async fn a_resource_naming_no_authorization_server_falls_back_to_the_origin() { + let origin = origin().await; + let base = origin.base.clone(); + let app = Router::new() + .route( + PRM_PATH, + document(json!({ "resource": format!("{base}/mcp") })), + ) + .route(AS_PATH, document(issuer_metadata(&base, &base))); + let endpoint = origin.serve("Bearer", app); + let client = client(&endpoint); + + let context = client.discover_authorization().await.unwrap().unwrap(); + + assert!(context.protected_resource_metadata.is_some()); + assert_eq!(context.authorization_server_metadata[0].issuer, base); + assert_eq!( + unauthorized_metadata(&client).await, + Some(format!("{base}{PRM_PATH}")) + ); +} + +#[tokio::test] +async fn an_unreadable_named_authorization_server_is_not_cached_as_absent() { + let origin = origin().await; + let base = origin.base.clone(); + let app = Router::new().route( + PRM_PATH, + document(json!({ + "resource": format!("{base}/mcp"), + "authorization_servers": [format!("{base}/missing")], + })), + ); + let endpoint = origin.serve("Bearer", app); + let client = client(&endpoint); + + assert_eq!(unauthorized_metadata(&client).await, None); + assert_eq!(client.well_known.lock().clone(), None); +} + +#[tokio::test] +async fn origin_rfc8414_metadata_alone_is_discovered() { + let origin = origin().await; + let base = origin.base.clone(); + let app = Router::new().route(AS_PATH, document(issuer_metadata(&base, &base))); + let endpoint = origin.serve("Bearer", app); + let client = client(&endpoint); + + let context = client.discover_authorization().await.unwrap().unwrap(); + + assert_eq!(context.protected_resource_metadata, None); + assert_eq!(context.authorization_server_metadata.len(), 1); + assert_eq!( + unauthorized_metadata(&client).await, + Some(format!("{base}{AS_PATH}")) + ); +} + +#[tokio::test] +async fn origin_openid_metadata_alone_is_discovered() { + let origin = origin().await; + let base = origin.base.clone(); + let app = Router::new().route(OIDC_PATH, document(issuer_metadata(&base, &base))); + let endpoint = origin.serve("Bearer", app); + let client = client(&endpoint); + + let context = client.discover_authorization().await.unwrap().unwrap(); + + assert_eq!(context.authorization_server_metadata.len(), 1); + assert_eq!( + unauthorized_metadata(&client).await, + Some(format!("{base}{OIDC_PATH}")) + ); +} + +#[tokio::test] +async fn incomplete_rfc8414_metadata_is_completed_from_openid_metadata() { + let origin = origin().await; + let base = origin.base.clone(); + let app = Router::new() + .route( + AS_PATH, + document( + json!({ "issuer": base, "registration_endpoint": format!("{base}/register") }), + ), + ) + .route(OIDC_PATH, document(issuer_metadata(&base, &base))); + let endpoint = origin.serve("Bearer", app); + + let context = client(&endpoint) + .discover_authorization() + .await + .unwrap() + .unwrap(); + + let server = &context.authorization_server_metadata[0]; + assert!(server.authorization_endpoint.is_some()); + assert!(server.token_endpoint.is_some()); +} + +#[tokio::test] +async fn a_bearer_401_with_no_metadata_anywhere_wants_a_static_credential() { + let origin = origin().await; + let endpoint = origin.serve("Bearer realm=\"mcp\"", Router::new()); + let client = client(&endpoint); + + let context = client.discover_authorization().await.unwrap().unwrap(); + assert_eq!(context.authorization_server_metadata.len(), 0); + assert_eq!(context.protected_resource_metadata, None); + + let error = client.initialize().await.expect_err("a 401"); + assert!(error.is_unauthorized()); + assert!(!error.advertises_oauth()); +} + +#[tokio::test] +async fn a_401_on_every_well_known_path_counts_as_absent() { + let origin = origin().await; + let app = Router::new().fallback(|| async { AxumStatus::UNAUTHORIZED }); + let endpoint = origin.serve("Bearer", app); + let client = client(&endpoint); + + assert_eq!(unauthorized_metadata(&client).await, None); + assert_eq!( + client.well_known.lock().clone(), + Some(WellKnownOutcome::NotFound) + ); +} + +#[tokio::test] +async fn a_basic_challenge_never_triggers_discovery() { + let hits = Arc::new(AtomicUsize::new(0)); + let origin = origin().await; + let base = origin.base.clone(); + let counted = Arc::clone(&hits); + let metadata = issuer_metadata(&base, &base); + let app = Router::new().route( + AS_PATH, + get(move || { + counted.fetch_add(1, Ordering::SeqCst); + let metadata = metadata.clone(); + async move { axum::Json(metadata) } + }), + ); + let endpoint = origin.serve("Basic realm=\"mcp\"", app); + let client = client(&endpoint); + + let context = client.discover_authorization().await.unwrap().unwrap(); + assert_eq!(context.authorization_server_metadata.len(), 0); + assert_eq!(unauthorized_metadata(&client).await, None); + assert_eq!(hits.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn issuer_metadata_for_another_origin_is_ignored() { + let origin = origin().await; + let base = origin.base.clone(); + let foreign = issuer_metadata("https://elsewhere.example", &base); + let app = Router::new() + .route(PRM_PATH, document(foreign.clone())) + .route(AS_PATH, document(foreign)); + let endpoint = origin.serve("Bearer", app); + let client = client(&endpoint); + + let context = client.discover_authorization().await.unwrap().unwrap(); + assert_eq!(context.authorization_server_metadata.len(), 0); + assert_eq!(unauthorized_metadata(&client).await, None); +} + +#[tokio::test] +async fn an_issuer_with_a_path_on_the_same_origin_is_not_the_origin() { + let origin = origin().await; + let base = origin.base.clone(); + let app = Router::new().route( + AS_PATH, + document(issuer_metadata(&format!("{base}/tenant"), &base)), + ); + let endpoint = origin.serve("Bearer", app); + + assert_eq!(unauthorized_metadata(&client(&endpoint)).await, None); +} + +#[tokio::test] +async fn a_resource_on_another_origin_is_ignored() { + let origin = origin().await; + let base = origin.base.clone(); + let app = Router::new().route( + PRM_PATH, + document(json!({ + "resource": "https://elsewhere.example/mcp", + "authorization_servers": [base], + })), + ); + let endpoint = origin.serve("Bearer", app); + + let context = client(&endpoint) + .discover_authorization() + .await + .unwrap() + .unwrap(); + + assert_eq!(context.protected_resource_metadata, None); + assert_eq!(context.authorization_server_metadata.len(), 0); +} + +#[tokio::test] +async fn a_redirect_is_not_followed() { + let hits = Arc::new(AtomicUsize::new(0)); + let target = origin().await; + let target_base = target.base.clone(); + let counted = Arc::clone(&hits); + let metadata = issuer_metadata(&target_base, &target_base); + let target_app = Router::new().fallback(move || { + counted.fetch_add(1, Ordering::SeqCst); + let metadata = metadata.clone(); + async move { axum::Json(metadata) } + }); + target.serve("Bearer", target_app); + + let origin = origin().await; + let app = Router::new().fallback(move |uri: axum::http::Uri| { + let location = format!("{target_base}{}", uri.path()); + async move { (AxumStatus::FOUND, [("Location", location)]).into_response() } + }); + let endpoint = origin.serve("Bearer", app); + let client = client(&endpoint); + + assert_eq!(unauthorized_metadata(&client).await, None); + let context = client.discover_authorization().await.unwrap().unwrap(); + assert_eq!(context.authorization_server_metadata.len(), 0); + assert_eq!(hits.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn an_oversized_document_is_ignored() { + let origin = origin().await; + let base = origin.base.clone(); + let mut metadata = issuer_metadata(&base, &base); + metadata["padding"] = json!("x".repeat(MAX_DOCUMENT_BYTES)); + let app = Router::new().route(AS_PATH, document(metadata)); + let endpoint = origin.serve("Bearer", app); + + assert_eq!(unauthorized_metadata(&client(&endpoint)).await, None); +} + +#[tokio::test] +async fn an_oversized_streamed_document_is_ignored() { + let origin = origin().await; + let base = origin.base.clone(); + let text = issuer_metadata(&base, &base).to_string(); + let app = Router::new().route( + AS_PATH, + get(move || { + let chunks: Vec> = + vec![Ok(" ".repeat(MAX_DOCUMENT_BYTES)), Ok(text.clone())]; + async move { AxumResponse::new(Body::from_stream(futures_util::stream::iter(chunks))) } + }), + ); + let endpoint = origin.serve("Bearer", app); + + assert_eq!(unauthorized_metadata(&client(&endpoint)).await, None); +} + +#[tokio::test] +async fn a_streamed_document_within_the_cap_is_read() { + let origin = origin().await; + let base = origin.base.clone(); + let text = issuer_metadata(&base, &base).to_string(); + let app = Router::new().route( + AS_PATH, + get(move || { + let (head, tail) = text.split_at(10); + let chunks: Vec> = + vec![Ok(head.to_string()), Ok(tail.to_string())]; + async move { AxumResponse::new(Body::from_stream(futures_util::stream::iter(chunks))) } + }), + ); + let endpoint = origin.serve("Bearer", app); + + assert_eq!( + unauthorized_metadata(&client(&endpoint)).await, + Some(format!("{base}{AS_PATH}")) + ); +} + +#[tokio::test] +async fn a_document_that_is_not_json_is_ignored() { + let origin = origin().await; + let app = Router::new().route(AS_PATH, get(|| async { "sign in" })); + let endpoint = origin.serve("Bearer", app); + + assert_eq!(unauthorized_metadata(&client(&endpoint)).await, None); +} + +#[tokio::test] +async fn an_unexpected_client_error_counts_as_absent() { + let origin = origin().await; + let app = Router::new().fallback(|| async { AxumStatus::BAD_REQUEST }); + let endpoint = origin.serve("Bearer", app); + let client = client(&endpoint); + + assert_eq!(unauthorized_metadata(&client).await, None); + assert_eq!( + client.well_known.lock().clone(), + Some(WellKnownOutcome::NotFound) + ); +} + +#[tokio::test] +async fn a_server_error_is_retried_on_the_next_401() { + let failures_left = Arc::new(AtomicUsize::new(1)); + let origin = origin().await; + let base = origin.base.clone(); + let metadata = issuer_metadata(&base, &base); + let app = Router::new().route( + AS_PATH, + get(move || { + let failures_left = Arc::clone(&failures_left); + let metadata = metadata.clone(); + async move { + if failures_left.swap(0, Ordering::SeqCst) > 0 { + return AxumStatus::SERVICE_UNAVAILABLE.into_response(); + } + axum::Json(metadata).into_response() + } + }), + ); + let endpoint = origin.serve("Bearer", app); + let client = client(&endpoint); + + assert_eq!(unauthorized_metadata(&client).await, None); + assert_eq!(client.well_known.lock().clone(), None); + assert_eq!( + unauthorized_metadata(&client).await, + Some(format!("{base}{AS_PATH}")) + ); +} + +#[tokio::test] +async fn a_definitive_answer_is_looked_up_once_per_client() { + let hits = Arc::new(AtomicUsize::new(0)); + let origin = origin().await; + let counted = Arc::clone(&hits); + let app = Router::new().fallback(move || { + counted.fetch_add(1, Ordering::SeqCst); + async { AxumStatus::NOT_FOUND } + }); + let endpoint = origin.serve("Bearer", app); + let client = client(&endpoint); + + unauthorized_metadata(&client).await; + let after_first = hits.load(Ordering::SeqCst); + unauthorized_metadata(&client).await; + + assert!(after_first > 0); + assert_eq!(hits.load(Ordering::SeqCst), after_first); +} + +#[tokio::test] +async fn an_unreachable_well_known_path_is_transient() { + let origin = origin().await; + let app = Router::new().fallback(|| async { + AxumResponse::new(Body::from_stream(futures_util::stream::iter(vec![Err::< + String, + std::io::Error, + >( + std::io::Error::other("reset"), + )]))) + }); + let endpoint = origin.serve("Bearer", app); + let client = client(&endpoint); + + assert_eq!(unauthorized_metadata(&client).await, None); + assert_eq!(client.well_known.lock().clone(), None); +} + +#[test] +fn an_origin_issuer_matches_with_or_without_one_trailing_slash() { + let origin = Url::parse("https://mcp.example").unwrap(); + + assert!(issuer_is_origin("https://mcp.example", &origin)); + assert!(issuer_is_origin("https://mcp.example/", &origin)); + assert!(!issuer_is_origin("https://mcp.example//", &origin)); + assert!(!issuer_is_origin("https://mcp.example/tenant", &origin)); + assert!(!issuer_is_origin("https://other.example", &origin)); +} + +#[test] +fn an_endpoint_without_an_http_origin_is_not_looked_up() { + let client = McpHttpClient::new("file:///tmp/mcp", 1).unwrap(); + let outcome = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(client.well_known_authorization()); + + assert_eq!(outcome, WellKnownOutcome::NotFound); +} diff --git a/crates/tinymcp/src/transport/http/mod.rs b/crates/tinymcp/src/transport/http/mod.rs index 0504f8c..9137d76 100644 --- a/crates/tinymcp/src/transport/http/mod.rs +++ b/crates/tinymcp/src/transport/http/mod.rs @@ -3,7 +3,8 @@ //! [`McpHttpClient`] speaks MCP over HTTP: the `initialize` handshake and //! protocol-version negotiation, `tools/list` and `tools/call`, server-sent //! event draining, session lifecycle through `Mcp-Session-Id`, OAuth discovery -//! from a `WWW-Authenticate` challenge, and a graceful `DELETE` on close. +//! from a `WWW-Authenticate` challenge or the server's well-known metadata, and +//! a graceful `DELETE` on close. //! //! # Three behaviors worth knowing before you read the code //! @@ -30,6 +31,7 @@ //! touches await. A synchronous mutex is the right shape; an async one held //! across a request would serialize the transport onto one in-flight call. +mod discovery; mod headers; mod sse; @@ -46,6 +48,7 @@ use std::collections::HashMap; use crate::error::{Error, Result}; use crate::transport::{redact_endpoint, render_tool_result, validate_protocol_version}; +use discovery::{DISCOVERY_BUDGET, WellKnownOutcome}; use headers::{ apply_auth, header_to_string, mcp_param_headers_from_schema, parse_www_authenticate_challenge, }; @@ -80,6 +83,8 @@ pub struct McpHttpClient { client_info: McpClientInfo, auth: McpAuthConfig, state: Mutex, + discovery_http: reqwest::Client, + well_known: Mutex>, } /// Everything about the current session, guarded together. @@ -180,15 +185,23 @@ impl McpHttpClientBuilder { // downgrades are not stripped — the policy closes that gap. .redirect(redirect_policy()); + let mut discovery_builder = reqwest::Client::builder() + .timeout(DISCOVERY_BUDGET) + .connect_timeout(CONNECT_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()); + if let Some(proxy) = self.proxy.as_ref() { builder = apply_proxy(builder, proxy); + discovery_builder = apply_proxy(discovery_builder, proxy); } - let http = builder.build().map_err(|source| Error::ClientBuild { - // Stripped for the reason on `Error::transport`: a proxy URL can - // carry credentials, and this error is printed. + // Stripped for the reason on `Error::transport`: a proxy URL can carry + // credentials, and this error is printed. + let build_error = |source: reqwest::Error| Error::ClientBuild { source: Box::new(source.without_url()), - })?; + }; + let http = builder.build().map_err(build_error)?; + let discovery_http = discovery_builder.build().map_err(build_error)?; Ok(McpHttpClient { endpoint: self.endpoint, @@ -197,6 +210,8 @@ impl McpHttpClientBuilder { client_info: McpClientInfo::from(&self.identity), auth: self.auth, state: Mutex::new(SessionState::default()), + discovery_http, + well_known: Mutex::new(None), }) } } @@ -473,6 +488,14 @@ impl McpHttpClient { /// challenge from the 401. Returns `Ok(None)` when the server answers /// anything else, which is the "no authorization needed" case. /// + /// A challenge naming `resource_metadata` is followed. A Bearer challenge + /// that names none is looked up on the server's origin instead: protected + /// resource metadata at `/.well-known/oauth-protected-resource` under the + /// endpoint's path and then at the root, and failing that the origin's own + /// RFC 8414 or `OpenID` metadata when its issuer is the origin. Neither the + /// context's shape nor its meaning changes: an empty authorization-server + /// list still means none was found. + /// /// An authorization server whose metadata cannot be fetched is omitted /// rather than failing the whole discovery: a protected resource may name /// several, and one being unreachable should not hide the others. @@ -518,6 +541,17 @@ impl McpHttpClient { None => None, }; + if protected_resource_metadata.is_none() + && is_bearer(&challenge.scheme) + && let WellKnownOutcome::Found(found) = self.well_known_authorization().await + { + return Ok(Some(McpAuthorizationContext { + challenge, + protected_resource_metadata: found.protected_resource_metadata, + authorization_server_metadata: found.authorization_servers, + })); + } + let mut authorization_server_metadata = Vec::new(); if let Some(metadata) = protected_resource_metadata.as_ref() { for issuer in &metadata.authorization_servers { @@ -873,13 +907,9 @@ impl McpHttpClient { let response_headers = response.headers().clone(); if status == StatusCode::UNAUTHORIZED { - // Typed rather than a string, so a caller decides on data. The - // presence of `resource_metadata` is what separates a server that - // wants OAuth from one that wants a static credential. return Err(Error::Unauthorized { endpoint: redact_endpoint(&self.endpoint), - resource_metadata: parse_www_authenticate_challenge(&response_headers) - .and_then(|challenge| challenge.resource_metadata), + resource_metadata: self.oauth_metadata_url(&response_headers).await, }); } @@ -920,6 +950,32 @@ impl McpHttpClient { }) } + /// The metadata URL that shows a 401 wants OAuth, or `None` for a static + /// credential. + /// + /// The challenge's own `resource_metadata` when it has one. Otherwise, for + /// a Bearer challenge, the well-known document that yielded an + /// authorization server with authorize and token endpoints. + async fn oauth_metadata_url(&self, headers: &reqwest::header::HeaderMap) -> Option { + let challenge = parse_www_authenticate_challenge(headers)?; + if challenge.resource_metadata.is_some() { + return challenge.resource_metadata; + } + if !is_bearer(&challenge.scheme) { + return None; + } + match self.well_known_authorization().await { + WellKnownOutcome::Found(found) if found.offers_sign_in() => { + tracing::debug!( + endpoint = %redact_endpoint(&self.endpoint), + "[mcp] a 401 without resource_metadata found oauth metadata on the origin" + ); + Some(found.metadata_url) + } + _ => None, + } + } + /// Reads an SSE body only as far as the first data frame. /// /// A server may hold the stream open after replying; stopping at the reply @@ -946,6 +1002,10 @@ impl McpHttpClient { } } +fn is_bearer(scheme: &str) -> bool { + scheme.eq_ignore_ascii_case("bearer") +} + fn rfc8414_metadata_url(issuer: &str) -> Result { let mut url = Url::parse(issuer).map_err(|error| { Error::malformed(format!("invalid authorization server issuer: {error}")) From f171352a96d5b508379960d43968ba4d00977e30 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 18:57:30 +0530 Subject: [PATCH 11/27] fix(oauth): document that discovered origin metadata flags a 401 as oauth (#44) --- crates/tinymcp/src/error/mod.rs | 10 ++++++++-- crates/tinymcp/src/error/mod_tests.rs | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/tinymcp/src/error/mod.rs b/crates/tinymcp/src/error/mod.rs index afcb01e..10e644a 100644 --- a/crates/tinymcp/src/error/mod.rs +++ b/crates/tinymcp/src/error/mod.rs @@ -60,7 +60,12 @@ pub enum Error { Unauthorized { /// The redacted endpoint the 401 came from. endpoint: String, - /// The `resource_metadata` URL the challenge advertised, when it did. + /// The metadata URL that shows the server wants OAuth. + /// + /// The challenge's `resource_metadata` when it named one. For a Bearer + /// challenge that named none, the well-known document on the server's + /// origin that yielded an authorization server with authorize and token + /// endpoints. `None` when neither exists. /// /// Its presence is what distinguishes a server that wants OAuth from /// one that wants a static credential, so it drives which affordance a @@ -553,7 +558,8 @@ impl Error { } } - /// Whether the 401 advertised OAuth. + /// Whether the 401 advertised OAuth, in its challenge or through + /// authorization metadata published on the server's origin. /// /// `false` for every error that is not a 401. A server that advertises /// OAuth will refuse a pasted static token however valid it looks, so this diff --git a/crates/tinymcp/src/error/mod_tests.rs b/crates/tinymcp/src/error/mod_tests.rs index 8c1c0a6..616ddb7 100644 --- a/crates/tinymcp/src/error/mod_tests.rs +++ b/crates/tinymcp/src/error/mod_tests.rs @@ -121,7 +121,7 @@ fn no_other_variant_is_reported_as_unauthorized() { } #[test] -fn only_a_401_advertising_resource_metadata_is_flagged_as_oauth() { +fn only_a_401_with_discovered_oauth_metadata_is_flagged_as_oauth() { // This is what decides between offering a sign-in and offering a token // field. A server that only accepts OAuth refuses a pasted token however // valid it looks. From e5f3dd2d3f60598c934f58022a13439fd928dc52 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 18:57:31 +0530 Subject: [PATCH 12/27] fix(oauth): sign in through a server that is its own authorization server (#44) --- crates/tinymcp/src/registry/oauth/flow.rs | 24 ++- .../tinymcp/src/registry/oauth/mod_tests.rs | 160 ++++++++++++++++++ 2 files changed, 175 insertions(+), 9 deletions(-) diff --git a/crates/tinymcp/src/registry/oauth/flow.rs b/crates/tinymcp/src/registry/oauth/flow.rs index 86e7638..4747eab 100644 --- a/crates/tinymcp/src/registry/oauth/flow.rs +++ b/crates/tinymcp/src/registry/oauth/flow.rs @@ -140,10 +140,12 @@ impl OAuthFlow { /// /// Decided by probing, not by reading registry metadata, which is often /// wrong about this. A server that answers without a challenge is open; one - /// that challenges with an authorization server [`Self::begin`] can drive - /// (authorize, token and dynamic-registration endpoints, and the - /// authorization-code grant) wants a browser sign-in; anything else wants a - /// static token. + /// whose authorization server [`Self::begin`] can drive (authorize, token + /// and dynamic-registration endpoints, and the authorization-code grant) + /// wants a browser sign-in; anything else wants a static token. The + /// authorization server is found from the challenge's `resource_metadata`, + /// or, for a Bearer challenge without one, from the well-known metadata on + /// the server's origin. /// /// A discovery failure reports a static token rather than an error. The /// user can paste one and find out, which beats being blocked by a probe @@ -199,8 +201,8 @@ impl OAuthFlow { /// /// # Errors /// - /// Returns [`Error::AuthDiscovery`] when no advertised authorization server - /// offers everything the flow needs, [`Error::MalformedResponse`] when + /// Returns [`Error::AuthDiscovery`] when the server advertises no + /// authorization server, or none offers everything the flow needs, [`Error::MalformedResponse`] when /// registration answers with something unusable, plus whatever the /// transport returns. pub async fn begin(&self, store: &S, server_id: &str, redirect_uri: &str) -> Result @@ -229,9 +231,13 @@ impl OAuthFlow { .iter() .find(|metadata| can_drive_sign_in(metadata)) .ok_or_else(|| Error::AuthDiscovery { - detail: "no advertised authorization server offers an authorize endpoint, a token \ - endpoint, and dynamic client registration together" - .to_string(), + detail: if context.authorization_server_metadata.is_empty() { + "the server advertises no authorization server".to_string() + } else { + "no authorization server offers an authorize endpoint, a token endpoint, and \ + dynamic client registration together" + .to_string() + }, challenge: Box::new(context.challenge.clone()), })?; diff --git a/crates/tinymcp/src/registry/oauth/mod_tests.rs b/crates/tinymcp/src/registry/oauth/mod_tests.rs index 6ffb6da..7dbc841 100644 --- a/crates/tinymcp/src/registry/oauth/mod_tests.rs +++ b/crates/tinymcp/src/registry/oauth/mod_tests.rs @@ -1035,6 +1035,166 @@ async fn a_server_that_does_not_want_authorization_is_refused() { ); } +// --------------------------------------------------------------------------- +// A server that is its own authorization server +// --------------------------------------------------------------------------- + +/// An MCP server shaped like Zomato's: its 401 names no `resource_metadata`, +/// and its origin serves its own authorization-server metadata at both the +/// protected-resource and the RFC 8414 well-known paths. +async fn origin_authority(with_registration: bool) -> (String, Arc) { + let state = Arc::new(Authority::default()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + + let mut metadata = json!({ + "issuer": format!("{origin}/"), + "authorization_endpoint": format!("{origin}/authorize"), + "token_endpoint": format!("{origin}/token"), + "registration_endpoint": format!("{origin}/register"), + "response_types_supported": ["code"], + "code_challenge_methods_supported": ["S256"], + }); + if !with_registration { + metadata + .as_object_mut() + .unwrap() + .remove("registration_endpoint"); + } + let document = get(move || { + let metadata = metadata.clone(); + async move { axum::Json(metadata) } + }); + + let app = Router::new() + .route("/.well-known/oauth-protected-resource", document.clone()) + .route("/.well-known/oauth-authorization-server", document) + .route( + "/mcp", + post(|| async { + ( + AxumStatus::UNAUTHORIZED, + [( + "WWW-Authenticate", + "Bearer error=\"invalid_token\", error_description=\"Authentication required\"", + )], + "", + ) + .into_response() + }), + ) + .merge(endpoints()) + .with_state(Arc::clone(&state)); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + (format!("{origin}/mcp"), state) +} + +#[tokio::test] +async fn a_server_that_is_its_own_authorization_server_is_detected_as_oauth() { + let (endpoint, _state) = origin_authority(true).await; + let store = store_with_remote(&endpoint); + + let detection = flow().detect(&store, "srv-1").await.unwrap(); + + assert_eq!(detection.kind, AuthKind::Oauth); + let origin = endpoint.trim_end_matches("/mcp"); + assert_eq!( + detection.authorization_endpoint, + Some(format!("{origin}/authorize")) + ); +} + +#[tokio::test] +async fn a_server_that_is_its_own_authorization_server_signs_in_through_its_origin() { + let (endpoint, state) = origin_authority(true).await; + let store = store_with_remote(&endpoint); + + let url = flow() + .begin(&store, "srv-1", "http://127.0.0.1:7788/callback") + .await + .expect("begin"); + + let origin = endpoint.trim_end_matches("/mcp"); + assert!(url.starts_with(&format!("{origin}/authorize?")), "{url}"); + assert_eq!(state.registrations.load(Ordering::SeqCst), 1); + assert_eq!( + authorize_param(&url, "code_challenge_method").as_deref(), + Some("S256") + ); + assert!(authorize_param(&url, "code_challenge").is_some()); + assert!(authorize_param(&url, "state").is_some()); + assert_eq!( + authorize_param(&url, "resource").as_deref(), + Some(endpoint.as_str()) + ); +} + +#[tokio::test] +async fn an_origin_authorization_server_without_dynamic_registration_wants_a_static_token() { + let (endpoint, state) = origin_authority(false).await; + let store = store_with_remote(&endpoint); + + let detection = flow().detect(&store, "srv-1").await.unwrap(); + assert_eq!(detection.kind, AuthKind::Token); + + let error = flow() + .begin(&store, "srv-1", "http://127.0.0.1:7788/callback") + .await + .expect_err("no registration endpoint"); + match error { + Error::AuthDiscovery { detail, .. } => { + assert!(detail.contains("dynamic client registration"), "{detail}"); + } + other => panic!("expected auth discovery, got {other:?}"), + } + assert_eq!(state.registrations.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn beginning_against_a_server_advertising_no_authorization_server_says_so() { + let app = Router::new().fallback(|| async { + ( + AxumStatus::UNAUTHORIZED, + [("WWW-Authenticate", "Bearer realm=\"mcp\"")], + "", + ) + .into_response() + }); + let base = serve(app).await; + let store = store_with_remote(&format!("{base}/mcp")); + + let error = flow() + .begin(&store, "srv-1", "http://127.0.0.1:7788/callback") + .await + .expect_err("no authorization server"); + + match error { + Error::AuthDiscovery { detail, .. } => { + assert!( + detail.contains("advertises no authorization server"), + "{detail}" + ); + } + other => panic!("expected auth discovery, got {other:?}"), + } +} + +#[tokio::test] +async fn public_endpoints_only_refuses_an_origin_authorization_server_on_loopback() { + let (endpoint, state) = origin_authority(true).await; + let store = store_with_remote(&endpoint); + + let error = flow() + .require_public_endpoints() + .begin(&store, "srv-1", "http://127.0.0.1:7788/callback") + .await + .expect_err("a loopback authorization server"); + + assert!(error.to_string().contains("endpoint refused"), "{error}"); + assert_eq!(state.registrations.load(Ordering::SeqCst), 0); +} + // --------------------------------------------------------------------------- // Completing // --------------------------------------------------------------------------- From 5105865c9c61a8101f5ceb57cabeabb8f24c4937 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 18:57:31 +0530 Subject: [PATCH 13/27] test(oauth): pin bridge outcome and status for origin-published metadata (#44) --- .../src/registry/connections/mod_tests.rs | 68 ++++++++++++++++- .../tinymcp/src/tools/bridge_outcome_tests.rs | 76 ++++++++++++++----- 2 files changed, 126 insertions(+), 18 deletions(-) diff --git a/crates/tinymcp/src/registry/connections/mod_tests.rs b/crates/tinymcp/src/registry/connections/mod_tests.rs index ece4a21..3487fe3 100644 --- a/crates/tinymcp/src/registry/connections/mod_tests.rs +++ b/crates/tinymcp/src/registry/connections/mod_tests.rs @@ -10,7 +10,8 @@ use std::collections::BTreeMap; use std::time::Duration; -use axum::routing::post; +use axum::response::IntoResponse; +use axum::routing::{get, post}; use axum::{Json, Router}; use serde_json::{Value, json}; @@ -405,6 +406,71 @@ fn a_transport_failure_is_not_classified_as_an_authentication_one() { assert!(failure.message.contains("500")); } +/// A server whose 401 names no `resource_metadata`, optionally publishing its +/// own authorization-server metadata on its origin. +fn bearer_gated_server(publishes_metadata: bool) -> Router { + let mut app = Router::new().route( + "/", + post(|| async { + ( + axum::http::StatusCode::UNAUTHORIZED, + [("WWW-Authenticate", "Bearer error=\"invalid_token\"")], + "", + ) + .into_response() + }), + ); + if publishes_metadata { + app = app.route( + "/.well-known/oauth-authorization-server", + get(|headers: axum::http::HeaderMap| async move { + let host = headers + .get("host") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + Json(json!({ + "issuer": format!("http://{host}/"), + "authorization_endpoint": format!("http://{host}/authorize"), + "token_endpoint": format!("http://{host}/token"), + "registration_endpoint": format!("http://{host}/register"), + })) + }), + ); + } + app +} + +async fn auth_hint_after_connecting(app: Router) -> Option { + let url = serve(app).await; + let server = install("srv-1", Transport::HttpRemote { url }); + let store = store_with(&server); + let connections = Connections::new(); + let oauth = OAuthFlow::new(None).unwrap(); + + connections + .connect(&store, &oauth, &identity(), None, &server) + .await + .expect_err("a 401"); + connections.auth_hint("srv-1").await +} + +#[tokio::test] +async fn a_401_whose_origin_publishes_authorization_metadata_needs_a_sign_in() { + assert_eq!( + auth_hint_after_connecting(bearer_gated_server(true)).await, + Some(McpAuthHint::OauthRequired) + ); +} + +#[tokio::test] +async fn a_401_with_no_authorization_metadata_anywhere_needs_a_credential() { + assert_eq!( + auth_hint_after_connecting(bearer_gated_server(false)).await, + Some(McpAuthHint::CredentialRequired) + ); +} + // --------------------------------------------------------------------------- // The map itself // --------------------------------------------------------------------------- diff --git a/crates/tinymcp/src/tools/bridge_outcome_tests.rs b/crates/tinymcp/src/tools/bridge_outcome_tests.rs index ce4a277..d228b3e 100644 --- a/crates/tinymcp/src/tools/bridge_outcome_tests.rs +++ b/crates/tinymcp/src/tools/bridge_outcome_tests.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use axum::http::{HeaderMap, HeaderValue, StatusCode}; use axum::response::IntoResponse as _; -use axum::routing::post; +use axum::routing::{get, post}; use axum::{Json, Router}; use serde_json::{Value, json}; use tinymcp_bus::{ @@ -72,21 +72,50 @@ async fn answering_server() -> String { } async fn unauthorized_server(resource_metadata: Option<&'static str>) -> String { - let app = Router::new().route( - "/mcp", - post(move || async move { - let mut headers = HeaderMap::new(); - let challenge = resource_metadata.map_or_else( - || "Bearer realm=\"mcp\"".to_string(), - |url| format!("Bearer resource_metadata=\"{url}\""), - ); - headers.insert( - "www-authenticate", - HeaderValue::from_str(&challenge).unwrap(), - ); - (StatusCode::UNAUTHORIZED, headers, "").into_response() - }), - ); + let app = Router::new().fallback(move || async move { + let mut headers = HeaderMap::new(); + let challenge = resource_metadata.map_or_else( + || "Bearer realm=\"mcp\"".to_string(), + |url| format!("Bearer resource_metadata=\"{url}\""), + ); + headers.insert( + "www-authenticate", + HeaderValue::from_str(&challenge).unwrap(), + ); + (StatusCode::UNAUTHORIZED, headers, "").into_response() + }); + serve(app).await +} + +async fn self_authorizing_server() -> String { + let app = Router::new() + .route( + "/.well-known/oauth-authorization-server", + get(|headers: HeaderMap| async move { + let host = headers + .get("host") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + Json(json!({ + "issuer": format!("http://{host}/"), + "authorization_endpoint": format!("http://{host}/authorize"), + "token_endpoint": format!("http://{host}/token"), + "registration_endpoint": format!("http://{host}/register"), + })) + }), + ) + .route( + "/mcp", + post(|| async { + ( + StatusCode::UNAUTHORIZED, + [("www-authenticate", "Bearer error=\"invalid_token\"")], + "", + ) + .into_response() + }), + ); serve(app).await } @@ -168,7 +197,7 @@ async fn a_401_advertising_oauth_reports_both_flags() { } #[tokio::test] -async fn a_401_without_resource_metadata_is_unauthorized_without_oauth() { +async fn a_401_with_no_oauth_metadata_anywhere_is_unauthorized_without_oauth() { let endpoint = unauthorized_server(None).await; let result = call_tool(registry(&endpoint, McpAuthConfig::None, &[])) .execute(args("whoami")) @@ -180,6 +209,19 @@ async fn a_401_without_resource_metadata_is_unauthorized_without_oauth() { assert!(!error.advertises_oauth); } +#[tokio::test] +async fn a_401_whose_origin_publishes_authorization_server_metadata_advertises_oauth() { + let endpoint = self_authorizing_server().await; + let result = call_tool(registry(&endpoint, McpAuthConfig::None, &[])) + .execute(args("whoami")) + .await + .unwrap(); + let error = outcome_of(&result).error.unwrap(); + assert_eq!(error.code, errors::UNAUTHORIZED); + assert!(error.unauthorized); + assert!(error.advertises_oauth); +} + #[tokio::test] async fn an_unknown_server_reports_unknown_server() { let registry = registry(&closed_endpoint().await, McpAuthConfig::None, &[]); From e734c7b858854c25b341b595dfd16af0b001b2e3 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 18:57:31 +0530 Subject: [PATCH 14/27] docs(oauth): describe well-known discovery for a 401 without resource_metadata (#44) --- README.md | 24 ++++++++++++++++++++++++ ROADMAP.md | 2 ++ 2 files changed, 26 insertions(+) diff --git a/README.md b/README.md index 2f7c9ec..52f7698 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,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 diff --git a/ROADMAP.md b/ROADMAP.md index c80509f..2e6a6ec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,6 +21,8 @@ out of scope. A roadmap that lists everything is a roadmap nobody trusts. - 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 ## Next From 67db8726ed2708aa1dc07cc66781ad2459363f64 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 22:47:03 +0530 Subject: [PATCH 15/27] feat(registry): curate official servers with endpoint, transport and auth (#42) --- crates/tinymcp/src/registry/curation/mod.rs | 12 +- .../src/registry/curation/mod_tests.rs | 111 +++++++++++- .../tinymcp/src/registry/curation/servers.rs | 145 ++++++++++++++++ crates/tinymcp/src/registry/curation/types.rs | 158 +++++++++++++++--- 4 files changed, 404 insertions(+), 22 deletions(-) create mode 100644 crates/tinymcp/src/registry/curation/servers.rs diff --git a/crates/tinymcp/src/registry/curation/mod.rs b/crates/tinymcp/src/registry/curation/mod.rs index 382be12..488ee40 100644 --- a/crates/tinymcp/src/registry/curation/mod.rs +++ b/crates/tinymcp/src/registry/curation/mod.rs @@ -6,6 +6,14 @@ //! catalog it removes barely any of them. So the full deduplicated catalog stays //! browsable and the known canonical vendor server is simply *marked*. //! +//! # Curated entries say how to reach the server +//! +//! Each entry in [`CURATED_SERVERS`] carries the vendor-hosted endpoint, its +//! transport and how it authenticates, not just a name. That is what lets a +//! local search show a curated server the registry does not list, and an +//! install reach it when the registry cannot be asked. [`OFFICIAL_SERVERS`] is +//! the same list as bare names. +//! //! # Matching is exact, never a substring //! //! A term like `stripe` or `github` appears in the name of plenty of unrelated @@ -21,10 +29,12 @@ //! emitting those keys could otherwise filter itself into a catalog that //! promises the user a server is safely installable. +mod servers; mod types; pub use types::{ - OFFICIAL_SERVERS, float_official_first, is_perfect_server, retain_perfect_servers, tag_official, + CURATED_SERVERS, CuratedAuth, CuratedServer, CuratedTransport, OFFICIAL_SERVERS, + curated_server, float_official_first, is_perfect_server, retain_perfect_servers, tag_official, }; #[cfg(test)] diff --git a/crates/tinymcp/src/registry/curation/mod_tests.rs b/crates/tinymcp/src/registry/curation/mod_tests.rs index 40de1aa..d4eb7b1 100644 --- a/crates/tinymcp/src/registry/curation/mod_tests.rs +++ b/crates/tinymcp/src/registry/curation/mod_tests.rs @@ -7,7 +7,8 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use super::{ - OFFICIAL_SERVERS, float_official_first, is_perfect_server, retain_perfect_servers, tag_official, + CURATED_SERVERS, CuratedAuth, CuratedTransport, OFFICIAL_SERVERS, curated_server, + float_official_first, is_perfect_server, retain_perfect_servers, tag_official, }; use tinymcp_bus::RegistryServerSummary; @@ -264,3 +265,111 @@ fn floating_a_catalog_with_nothing_badged_changes_nothing() { .collect(); assert_eq!(names, ["a/one", "b/two", "c/three"]); } + +// --------------------------------------------------------------------------- +// Structured entries +// --------------------------------------------------------------------------- + +#[test] +fn the_name_list_is_the_curated_entries_in_order() { + let names: Vec<&str> = CURATED_SERVERS + .iter() + .map(|server| server.qualified_name) + .collect(); + + assert_eq!(OFFICIAL_SERVERS, names.as_slice()); +} + +#[test] +fn the_name_list_still_reads_as_a_slice_of_names() { + let names: &[&str] = OFFICIAL_SERVERS; + + assert!(names.contains(&"com.notion/mcp")); + assert!(names.contains(&"io.github.github/github-mcp-server")); +} + +#[test] +fn every_curated_entry_names_a_hosted_https_endpoint() { + for server in CURATED_SERVERS { + assert!( + server.remote_url.starts_with("https://"), + "{} has {}", + server.qualified_name, + server.remote_url + ); + assert!(!server.display_name.trim().is_empty()); + assert!(!server.description.trim().is_empty()); + } +} + +#[test] +fn a_curated_entry_is_found_by_its_exact_name_only() { + assert_eq!( + curated_server("com.notion/mcp").map(|server| server.remote_url), + Some("https://mcp.notion.com/mcp") + ); + assert!(curated_server("com.notion").is_none()); + assert!(curated_server("ai.smithery/notion").is_none()); +} + +#[test] +fn slack_is_curated_as_oauth_for_preregistered_clients() { + let slack = curated_server("com.slack/mcp").expect("curated"); + + assert_eq!(slack.remote_url, "https://mcp.slack.com/mcp"); + assert_eq!(slack.transport, CuratedTransport::StreamableHttp); + assert_eq!(slack.auth, CuratedAuth::OauthPreregistered); +} + +#[test] +fn a_curated_row_is_attributed_to_the_official_registry() { + let row = curated_server("com.supabase/mcp").unwrap().to_summary(); + + assert_eq!(row.qualified_name, "com.supabase/mcp"); + assert_eq!(row.display_name, "Supabase"); + assert_eq!(row.source, "mcp_official"); + assert!(row.is_deployed); + assert!(!row.official, "badging is curation's call, not the row's"); + assert!(row.icon_url.is_some()); + assert_eq!(row.auth_kind, None); +} + +#[test] +fn a_token_entry_declares_a_static_credential() { + let row = curated_server("com.paypal.mcp/mcp").unwrap().to_summary(); + + assert_eq!(row.auth_kind.as_deref(), Some("api_key")); +} + +#[test] +fn a_curated_detail_offers_its_hosted_endpoint() { + let detail = curated_server("com.notion/mcp").unwrap().to_detail(); + + assert_eq!(detail.source, "mcp_official"); + assert_eq!(detail.connections.len(), 1); + let connection = &detail.connections[0]; + assert_eq!(connection.r#type, "http"); + assert_eq!( + connection.deployment_url.as_deref(), + Some("https://mcp.notion.com/mcp") + ); + assert!(connection.published); + assert!(connection.config_schema.is_none()); +} + +#[test] +fn a_token_detail_asks_for_the_authorization_header() { + let detail = curated_server("io.github.github/github-mcp-server") + .unwrap() + .to_detail(); + + let schema = detail.connections[0].config_schema.clone().unwrap(); + assert_eq!(schema["properties"]["Authorization"]["x-secret"], true); + assert_eq!(schema["required"][0], "Authorization"); +} + +#[test] +fn a_server_sent_events_entry_is_an_sse_connection() { + assert_eq!(CuratedTransport::Sse.connection_type(), "sse"); + assert_eq!(CuratedTransport::StreamableHttp.connection_type(), "http"); +} diff --git a/crates/tinymcp/src/registry/curation/servers.rs b/crates/tinymcp/src/registry/curation/servers.rs new file mode 100644 index 0000000..fd1629b --- /dev/null +++ b/crates/tinymcp/src/registry/curation/servers.rs @@ -0,0 +1,145 @@ +//! The curated first-party servers. +//! +//! Every entry but Slack was checked against what the official registry +//! publishes for that exact name: the hosted endpoint, its transport, and the +//! headers it declares. Slack publishes no registry entry; its endpoint and +//! client rules come from Slack's own developer documentation. An entry here is +//! a claim made to the user, so extend the list only from a vendor's own +//! publication. + +use super::types::{CuratedAuth, CuratedServer, CuratedTransport}; + +/// Canonical first-party servers, with how each is reached. +pub const CURATED_SERVERS: &[CuratedServer] = &[ + CuratedServer { + qualified_name: "io.github.github/github-mcp-server", + display_name: "GitHub", + description: "Connect AI assistants to GitHub - manage repos, issues, PRs, and workflows \ + through natural language.", + remote_url: "https://api.githubcopilot.com/mcp/", + transport: CuratedTransport::StreamableHttp, + auth: CuratedAuth::Token, + icon_url: None, + }, + CuratedServer { + qualified_name: "com.notion/mcp", + display_name: "Notion", + description: "Official Notion MCP server", + remote_url: "https://mcp.notion.com/mcp", + transport: CuratedTransport::StreamableHttp, + auth: CuratedAuth::Oauth, + icon_url: None, + }, + CuratedServer { + qualified_name: "com.stripe/mcp", + display_name: "Stripe", + description: "MCP server integrating with Stripe - tools for customers, products, \ + payments, and more.", + remote_url: "https://mcp.stripe.com", + transport: CuratedTransport::StreamableHttp, + auth: CuratedAuth::Oauth, + icon_url: None, + }, + CuratedServer { + qualified_name: "com.atlassian/atlassian-mcp-server", + display_name: "Atlassian Rovo MCP Server", + description: "Connect to Atlassian Jira, Confluence, Loom, and more to search, create, \ + and manage your work.", + remote_url: "https://mcp.atlassian.com/v2/mcp", + transport: CuratedTransport::StreamableHttp, + auth: CuratedAuth::Oauth, + icon_url: None, + }, + CuratedServer { + qualified_name: "app.linear/linear", + display_name: "Linear", + description: "MCP server for Linear project management and issue tracking", + remote_url: "https://mcp.linear.app/mcp", + transport: CuratedTransport::StreamableHttp, + auth: CuratedAuth::Oauth, + icon_url: None, + }, + CuratedServer { + qualified_name: "com.gitlab/mcp", + display_name: "GitLab", + description: "Official GitLab MCP Server", + remote_url: "https://gitlab.com/api/v4/mcp", + transport: CuratedTransport::StreamableHttp, + auth: CuratedAuth::Oauth, + icon_url: None, + }, + CuratedServer { + qualified_name: "com.paypal.mcp/mcp", + display_name: "PayPal", + description: "PayPal MCP server provides access to PayPal services and operations for \ + AI assistants", + remote_url: "https://mcp.paypal.com/mcp", + transport: CuratedTransport::StreamableHttp, + auth: CuratedAuth::Token, + icon_url: None, + }, + CuratedServer { + qualified_name: "com.cloudflare.mcp/mcp", + display_name: "Cloudflare", + description: "Cloudflare MCP servers", + remote_url: "https://docs.mcp.cloudflare.com/mcp", + transport: CuratedTransport::StreamableHttp, + auth: CuratedAuth::None, + icon_url: None, + }, + CuratedServer { + qualified_name: "com.airtable/mcp", + display_name: "Airtable", + description: "Official Airtable MCP server — database and operations layer for agents.", + remote_url: "https://mcp.airtable.com/mcp", + transport: CuratedTransport::StreamableHttp, + auth: CuratedAuth::Oauth, + icon_url: None, + }, + CuratedServer { + qualified_name: "com.supabase/mcp", + display_name: "Supabase", + description: "MCP server for interacting with the Supabase platform", + remote_url: "https://mcp.supabase.com/mcp", + transport: CuratedTransport::StreamableHttp, + auth: CuratedAuth::Oauth, + icon_url: Some("https://supabase.com/favicon/favicon-196x196.png"), + }, + CuratedServer { + qualified_name: "com.vercel/vercel-mcp", + display_name: "Vercel", + description: "An MCP server for Vercel", + remote_url: "https://mcp.vercel.com", + transport: CuratedTransport::StreamableHttp, + auth: CuratedAuth::Oauth, + icon_url: None, + }, + CuratedServer { + qualified_name: "com.webflow/mcp", + display_name: "Webflow", + description: "AI-powered design and management for Webflow Sites", + remote_url: "https://mcp.webflow.com/mcp", + transport: CuratedTransport::StreamableHttp, + auth: CuratedAuth::Oauth, + icon_url: None, + }, + CuratedServer { + qualified_name: "com.wix/mcp", + display_name: "Wix", + description: "A Model Context Protocol server for Wix AI tools", + remote_url: "https://mcp.wix.com/mcp", + transport: CuratedTransport::StreamableHttp, + auth: CuratedAuth::Oauth, + icon_url: None, + }, + CuratedServer { + qualified_name: "com.slack/mcp", + display_name: "Slack", + description: "Official Slack MCP server for searching and acting on workspace messages, \ + channels, and users", + remote_url: "https://mcp.slack.com/mcp", + transport: CuratedTransport::StreamableHttp, + auth: CuratedAuth::OauthPreregistered, + icon_url: None, + }, +]; diff --git a/crates/tinymcp/src/registry/curation/types.rs b/crates/tinymcp/src/registry/curation/types.rs index 56608f3..4316799 100644 --- a/crates/tinymcp/src/registry/curation/types.rs +++ b/crates/tinymcp/src/registry/curation/types.rs @@ -1,28 +1,146 @@ //! The canonical-server list and the catalog filters. -use tinymcp_bus::RegistryServerSummary; +use serde_json::json; + +pub use super::servers::CURATED_SERVERS; +use crate::registry::sources::SOURCE_MCP_OFFICIAL; +use tinymcp_bus::{ExtraFields, RegistryConnection, RegistryServerDetail, RegistryServerSummary}; + +/// The value `auth_kind` takes for a server declaring a static credential. +const AUTH_KIND_API_KEY: &str = "api_key"; + +/// How a curated server's endpoint speaks MCP. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum CuratedTransport { + /// Streamable HTTP. + StreamableHttp, + /// Server-sent events. + Sse, +} + +impl CuratedTransport { + /// The connection type a catalog detail record uses for this transport. + #[must_use] + pub const fn connection_type(self) -> &'static str { + match self { + Self::StreamableHttp => "http", + Self::Sse => "sse", + } + } +} + +/// How a curated server's endpoint authenticates a client. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum CuratedAuth { + /// OAuth, with dynamic client registration. + Oauth, + /// OAuth for clients the vendor has registered in advance only; dynamic + /// client registration is refused. + OauthPreregistered, + /// A static token sent in the `Authorization` header. + Token, + /// No authentication. + None, +} + +/// A canonical first-party server, with what it takes to reach it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub struct CuratedServer { + /// The exact registry qualified name. + pub qualified_name: &'static str, + /// The vendor's name for it. + pub display_name: &'static str, + /// A short description. + pub description: &'static str, + /// The vendor-hosted endpoint. + pub remote_url: &'static str, + /// How the endpoint speaks MCP. + pub transport: CuratedTransport, + /// How the endpoint authenticates a client. + pub auth: CuratedAuth, + /// The icon the registry publishes for it, when it publishes one. + pub icon_url: Option<&'static str>, +} + +impl CuratedServer { + /// This server as a catalog row from the official registry. + #[must_use] + pub fn to_summary(&self) -> RegistryServerSummary { + RegistryServerSummary { + qualified_name: self.qualified_name.to_string(), + display_name: self.display_name.to_string(), + description: Some(self.description.to_string()), + icon_url: self.icon_url.map(ToString::to_string), + use_count: 0, + is_deployed: true, + source: SOURCE_MCP_OFFICIAL.to_string(), + official: false, + website_url: None, + auth_kind: (self.auth == CuratedAuth::Token).then(|| AUTH_KIND_API_KEY.to_string()), + extra: ExtraFields::new(), + } + } + + /// This server as a catalog detail record with its one hosted connection. + #[must_use] + pub fn to_detail(&self) -> RegistryServerDetail { + let config_schema = (self.auth == CuratedAuth::Token).then(|| { + json!({ + "properties": { "Authorization": { "x-secret": true } }, + "required": ["Authorization"], + }) + }); + + RegistryServerDetail { + qualified_name: self.qualified_name.to_string(), + display_name: self.display_name.to_string(), + description: Some(self.description.to_string()), + icon_url: self.icon_url.map(ToString::to_string), + connections: vec![RegistryConnection { + r#type: self.transport.connection_type().to_string(), + deployment_url: Some(self.remote_url.to_string()), + config_schema, + example_config: None, + published: true, + extra: ExtraFields::new(), + }], + source: SOURCE_MCP_OFFICIAL.to_string(), + extra: ExtraFields::new(), + } + } +} + +/// The curated entry for `qualified_name`, by exact match. +#[must_use] +pub fn curated_server(qualified_name: &str) -> Option<&'static CuratedServer> { + CURATED_SERVERS + .iter() + .find(|server| server.qualified_name == qualified_name) +} + +/// The qualified names of [`CURATED_SERVERS`], in the same order. +const fn curated_names() -> [&'static str; CURATED_SERVERS.len()] { + let mut names = [""; CURATED_SERVERS.len()]; + let mut index = 0; + while index < CURATED_SERVERS.len() { + names[index] = CURATED_SERVERS[index].qualified_name; + index += 1; + } + names +} + +/// The qualified names of [`CURATED_SERVERS`], held so a slice of them can be +/// borrowed for the whole program. +const CURATED_NAMES: [&str; CURATED_SERVERS.len()] = curated_names(); /// Canonical first-party servers, by exact registry qualified name. /// -/// Each was confirmed present in the official registry export. These get the -/// badge; every other server is shown without one. Extend the list as vendors -/// publish official servers — and only ever with a name checked against the -/// registry, since an entry here is a claim made to the user. -pub const OFFICIAL_SERVERS: &[&str] = &[ - "io.github.github/github-mcp-server", - "com.notion/mcp", - "com.stripe/mcp", - "com.atlassian/atlassian-mcp-server", - "app.linear/linear", - "com.gitlab/mcp", - "com.paypal.mcp/mcp", - "com.cloudflare.mcp/mcp", - "com.airtable/mcp", - "com.supabase/mcp", - "com.vercel/vercel-mcp", - "com.webflow/mcp", - "com.wix/mcp", -]; +/// The names of [`CURATED_SERVERS`], in the same order. These get the badge; +/// every other server is shown without one. +pub const OFFICIAL_SERVERS: &[&str] = &CURATED_NAMES; /// Marks the canonical first-party server for each known service. /// From b70387cbd1d9450c713b41201967509375e897b6 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 22:47:26 +0530 Subject: [PATCH 16/27] feat(registry): report answers from the local catalog index as indexed (#42) --- crates/tinymcp-bus/src/method/mod_tests.rs | 8 +++++++- crates/tinymcp-bus/src/method/types.rs | 6 ++++++ crates/tinymcp-bus/src/version/mod.rs | 3 ++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/tinymcp-bus/src/method/mod_tests.rs b/crates/tinymcp-bus/src/method/mod_tests.rs index ac9a2a7..6e75913 100644 --- a/crates/tinymcp-bus/src/method/mod_tests.rs +++ b/crates/tinymcp-bus/src/method/mod_tests.rs @@ -109,6 +109,7 @@ fn a_curation_request_serializes_both_switches() { fn freshness_travels_in_snake_case() { for (freshness, wire) in [ (RegistryFreshness::Live, "live"), + (RegistryFreshness::Indexed, "indexed"), (RegistryFreshness::Cached, "cached"), (RegistryFreshness::LocalFallback, "local_fallback"), ] { @@ -122,8 +123,13 @@ fn freshness_travels_in_snake_case() { #[test] fn freshness_orders_from_freshest_to_least_fresh() { - assert!(RegistryFreshness::Live < RegistryFreshness::Cached); + 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 diff --git a/crates/tinymcp-bus/src/method/types.rs b/crates/tinymcp-bus/src/method/types.rs index 0243f5b..7eba707 100644 --- a/crates/tinymcp-bus/src/method/types.rs +++ b/crates/tinymcp-bus/src/method/types.rs @@ -44,6 +44,12 @@ 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, diff --git a/crates/tinymcp-bus/src/version/mod.rs b/crates/tinymcp-bus/src/version/mod.rs index 7f3cfee..b2c35c0 100644 --- a/crates/tinymcp-bus/src/version/mod.rs +++ b/crates/tinymcp-bus/src/version/mod.rs @@ -11,7 +11,8 @@ /// /// 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; 1.4 added -/// the search page's freshness and the registry-timeout error name. +/// 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 From 3e3c0f9dceb744cefc176aae6e61300c916e9340 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 22:56:43 +0530 Subject: [PATCH 17/27] test(registry): derive curated names at run time in a test (#42) --- crates/tinymcp/src/registry/curation/mod_tests.rs | 9 +++++++-- crates/tinymcp/src/registry/curation/types.rs | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/tinymcp/src/registry/curation/mod_tests.rs b/crates/tinymcp/src/registry/curation/mod_tests.rs index d4eb7b1..c9b355e 100644 --- a/crates/tinymcp/src/registry/curation/mod_tests.rs +++ b/crates/tinymcp/src/registry/curation/mod_tests.rs @@ -280,6 +280,11 @@ fn the_name_list_is_the_curated_entries_in_order() { assert_eq!(OFFICIAL_SERVERS, names.as_slice()); } +#[test] +fn the_names_are_derived_from_the_entries_at_run_time_too() { + assert_eq!(super::types::curated_names().as_slice(), OFFICIAL_SERVERS); +} + #[test] fn the_name_list_still_reads_as_a_slice_of_names() { let names: &[&str] = OFFICIAL_SERVERS; @@ -297,8 +302,8 @@ fn every_curated_entry_names_a_hosted_https_endpoint() { server.qualified_name, server.remote_url ); - assert!(!server.display_name.trim().is_empty()); - assert!(!server.description.trim().is_empty()); + assert_ne!(server.display_name.trim(), ""); + assert_ne!(server.description.trim(), ""); } } diff --git a/crates/tinymcp/src/registry/curation/types.rs b/crates/tinymcp/src/registry/curation/types.rs index 4316799..37aae3e 100644 --- a/crates/tinymcp/src/registry/curation/types.rs +++ b/crates/tinymcp/src/registry/curation/types.rs @@ -122,7 +122,7 @@ pub fn curated_server(qualified_name: &str) -> Option<&'static CuratedServer> { } /// The qualified names of [`CURATED_SERVERS`], in the same order. -const fn curated_names() -> [&'static str; CURATED_SERVERS.len()] { +pub(super) const fn curated_names() -> [&'static str; CURATED_SERVERS.len()] { let mut names = [""; CURATED_SERVERS.len()]; let mut index = 0; while index < CURATED_SERVERS.len() { From 45dcd5c92b29d3117fe5991355168565cdda8fed Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 22:56:43 +0530 Subject: [PATCH 18/27] feat(registry): search a background-synced local index of the official catalog (#42) The registry's search= takes 20-49 s while its cursor listing is fast, so a search or browse now starts a background sync that pages /v0/servers?version=latest into the store (one row per server). Once a sync has finished, a query is answered from it instantly with freshness Indexed, ranked curated, then name/title, then description, then alphabetical; curated servers match even when the registry lacks them. Without a finished index the old live/cached/local path is unchanged. The sync is resumable per page, capped, single-flight, cooled down after a failure, and re-runs after six hours (RegistryIndexSettings). A curated server the registry cannot describe gets its detail from the curated entry. --- crates/tinymcp/src/registry/mod.rs | 4 +- crates/tinymcp/src/registry/ops/mod_tests.rs | 23 + crates/tinymcp/src/registry/ops/types.rs | 24 +- crates/tinymcp/src/registry/sources/mod.rs | 4 +- .../src/registry/sources/official/index.rs | 385 +++++++++ .../registry/sources/official/index_tests.rs | 808 ++++++++++++++++++ .../src/registry/sources/official/mod.rs | 64 +- .../src/registry/sources/official/types.rs | 50 ++ crates/tinymcp/src/registry/sources/types.rs | 53 +- crates/tinymcp/src/registry/store/index.rs | 278 ++++++ .../tinymcp/src/registry/store/index_tests.rs | 194 +++++ crates/tinymcp/src/registry/store/mod.rs | 6 +- crates/tinymcp/src/registry/store/schema.rs | 19 + crates/tinymcp/src/registry/store/types.rs | 2 +- 14 files changed, 1901 insertions(+), 13 deletions(-) create mode 100644 crates/tinymcp/src/registry/sources/official/index.rs create mode 100644 crates/tinymcp/src/registry/sources/official/index_tests.rs create mode 100644 crates/tinymcp/src/registry/store/index.rs create mode 100644 crates/tinymcp/src/registry/store/index_tests.rs diff --git a/crates/tinymcp/src/registry/mod.rs b/crates/tinymcp/src/registry/mod.rs index 6696ef3..41b3685 100644 --- a/crates/tinymcp/src/registry/mod.rs +++ b/crates/tinymcp/src/registry/mod.rs @@ -28,7 +28,9 @@ pub use oauth::{ }; pub use ops::McpRegistry; pub use setup::{SecretRef, SecretVault}; -pub use sources::{Registries, RegistryOperation, RegistrySource, RegistryTimeouts}; +pub use sources::{ + Registries, RegistryIndexSettings, RegistryOperation, RegistrySource, RegistryTimeouts, +}; pub use store::Store; pub use supervisor::{ ServerRef, SupervisedHost, Supervisor, SupervisorConfig, SupervisorEvent, TickReport, diff --git a/crates/tinymcp/src/registry/ops/mod_tests.rs b/crates/tinymcp/src/registry/ops/mod_tests.rs index bde0f93..8e18fdf 100644 --- a/crates/tinymcp/src/registry/ops/mod_tests.rs +++ b/crates/tinymcp/src/registry/ops/mod_tests.rs @@ -1657,6 +1657,29 @@ async fn floating_without_tagging_leaves_untagged_rows_in_order() { assert_eq!(names(&page), ["io.example/other", "com.notion/mcp"]); } +#[tokio::test] +async fn a_search_answers_from_the_local_index_once_the_background_sync_finishes() { + let registry = registry_over_catalog(&["io.example/other", "com.notion/mcp"]).await; + + let mut page = registry + .registry_search(Some("notion"), 1, 20) + .await + .unwrap(); + for _ in 0..500 { + if page.freshness == tinymcp_bus::RegistryFreshness::Indexed { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + page = registry + .registry_search(Some("notion"), 1, 20) + .await + .unwrap(); + } + + assert_eq!(page.freshness, tinymcp_bus::RegistryFreshness::Indexed); + assert_eq!(names(&page)[0], "com.notion/mcp"); +} + // --------------------------------------------------------------------------- // The background-work seams // --------------------------------------------------------------------------- diff --git a/crates/tinymcp/src/registry/ops/types.rs b/crates/tinymcp/src/registry/ops/types.rs index bb40031..fd09a00 100644 --- a/crates/tinymcp/src/registry/ops/types.rs +++ b/crates/tinymcp/src/registry/ops/types.rs @@ -1,6 +1,7 @@ //! The registry facade and its operations. use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::Value; @@ -34,7 +35,7 @@ const TEST_CONNECTION_TIMEOUT_SECS: u64 = 30; /// Everything a host can ask the dynamic registry to do. #[derive(Debug)] pub struct McpRegistry { - store: Store, + store: Arc, connections: Connections, registries: Registries, oauth: OAuthFlow, @@ -56,7 +57,7 @@ impl McpRegistry { proxy: Option, ) -> Result { Ok(Self { - store, + store: Arc::new(store), connections: Connections::new(), registries: Registries::new(registry_auth)?, oauth: OAuthFlow::new(proxy.clone())?, @@ -120,7 +121,11 @@ impl McpRegistry { /// Searches every catalog taking part in search, merged. /// - /// The official catalog leads. Badging and the strict filter are applied by + /// The official catalog leads. A search or browse also starts a background + /// sync of the official catalog's local index when one is due; a query is + /// answered from that index once it has finished one. + /// + /// Badging and the strict filter are applied by /// [`crate::registry::curation`] on top of this, by a caller that wants /// them — they are presentation choices, and a caller assembling its own /// view should not have to undo them. @@ -138,6 +143,8 @@ impl McpRegistry { page: u32, page_size: u32, ) -> Result { + self.registries.refresh_index(&self.store); + let mut servers = Vec::new(); let mut total_pages = page.max(1); let mut freshness = RegistryFreshness::Live; @@ -627,7 +634,7 @@ impl McpRegistry { /// no install, plus [`Error::Store`] when it cannot be read. pub async fn detect_auth(&self, server_id: &str) -> Result { let server_id = require_non_empty(server_id, "server_id")?; - self.oauth.detect(&self.store, server_id).await + self.oauth.detect(self.store.as_ref(), server_id).await } /// Starts a browser sign-in and returns the URL to open. @@ -640,7 +647,9 @@ impl McpRegistry { /// whatever discovery and registration return. pub async fn oauth_begin(&self, server_id: &str, redirect_uri: &str) -> Result { let server_id = require_non_empty(server_id, "server_id")?; - self.oauth.begin(&self.store, server_id, redirect_uri).await + self.oauth + .begin(self.store.as_ref(), server_id, redirect_uri) + .await } /// Finishes a browser sign-in and connects the server. @@ -655,7 +664,10 @@ impl McpRegistry { /// a failed connect is *not* an error: the sign-in worked, and the outcome /// carries no tools. pub async fn oauth_complete(&self, state: &str, code: &str) -> Result { - let server_id = self.oauth.complete(&self.store, state, code).await?; + let server_id = self + .oauth + .complete(self.store.as_ref(), state, code) + .await?; self.store.forget_cached_tools(&server_id)?; match self.connect(&server_id).await { diff --git a/crates/tinymcp/src/registry/sources/mod.rs b/crates/tinymcp/src/registry/sources/mod.rs index 24aedab..6814c55 100644 --- a/crates/tinymcp/src/registry/sources/mod.rs +++ b/crates/tinymcp/src/registry/sources/mod.rs @@ -38,8 +38,8 @@ pub use encode::encode_path_segment; pub use official::McpOfficialRegistry; pub use smithery::SmitheryRegistry; pub use types::{ - Registries, RegistryOperation, RegistrySource, RegistryTimeouts, SOURCE_MCP_OFFICIAL, - SOURCE_SMITHERY, SourcePage, + Registries, RegistryIndexSettings, RegistryOperation, RegistrySource, RegistryTimeouts, + SOURCE_MCP_OFFICIAL, SOURCE_SMITHERY, SourcePage, }; #[cfg(test)] diff --git a/crates/tinymcp/src/registry/sources/official/index.rs b/crates/tinymcp/src/registry/sources/official/index.rs new file mode 100644 index 0000000..0bf5ad7 --- /dev/null +++ b/crates/tinymcp/src/registry/sources/official/index.rs @@ -0,0 +1,385 @@ +//! The local index of the official catalog, and searching it. +//! +//! The registry's `search=` can take tens of seconds, while paging its plain +//! listing by cursor answers quickly. So the adapter pages the whole listing +//! into the store in the background and answers a search from that copy +//! instantly, reporting [`RegistryFreshness::Indexed`]. +//! +//! # Syncing +//! +//! A sync walks `/v0/servers?version=latest` by cursor until the cursor runs +//! out or [`RegistryIndexSettings::max_pages`] is reached, writing each page as +//! it arrives. Each page has the browse budget. A page that fails stops the +//! sync with what it wrote kept, and the next sync resumes from the cursor it +//! stopped at; until then the next one waits out the registry cooldown. +//! +//! A search or a browse starts a sync when none has finished for the +//! configured catalog, when one was left unfinished, or when the last one is +//! older than [`RegistryIndexSettings::refresh`]. It runs on its own task so +//! the request that started it never waits for it, and at most one runs per +//! adapter. +//! +//! # Searching +//! +//! Until a sync has finished the index is not used, so a half-filled copy is +//! never presented as the whole catalog. After that, a row matches when every +//! word of the query appears in its name, title or description. Curated +//! servers match too even when the index lacks them. Matches are ranked +//! curated first, then name or title matches, then description matches, each +//! group alphabetical. + +use std::cmp::Ordering as Order; +use std::collections::HashSet; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Instant; + +use parking_lot::Mutex; +use serde_json::Value; + +use super::types::{OfficialServer, latest_server_records}; +use super::{auth_token, base_url, list_url}; +use crate::error::{Error, Result}; +use crate::registry::Store; +use crate::registry::curation::{CURATED_SERVERS, curated_server}; +use crate::registry::sources::shared::read_body; +use crate::registry::sources::types::{ + RegistryIndexSettings, RegistryOperation, RegistryTimeouts, SOURCE_MCP_OFFICIAL, SourcePage, +}; +use crate::registry::store::index::IndexRow; +use crate::registry::store::now_ms; +use tinymcp_bus::{McpRegistryAuthConfig, RegistryFreshness, RegistryServerSummary}; + +/// The source the index rows are stored under. +const INDEX_SOURCE: &str = SOURCE_MCP_OFFICIAL; + +/// The official catalog's index: how it syncs, and whether a sync is running. +#[derive(Debug)] +pub(super) struct OfficialIndex { + http: reqwest::Client, + timeouts: RegistryTimeouts, + settings: RegistryIndexSettings, + in_flight: AtomicBool, + retry_at: Mutex>, +} + +/// Clears the in-flight flag when the sync holding it ends, however it ends. +struct InFlight(Arc); + +impl Drop for InFlight { + fn drop(&mut self) { + self.0.in_flight.store(false, Ordering::Release); + } +} + +impl OfficialIndex { + /// An index syncing over `http` within `timeouts`. + pub(super) const fn new( + http: reqwest::Client, + timeouts: RegistryTimeouts, + settings: RegistryIndexSettings, + ) -> Self { + Self { + http, + timeouts, + settings, + in_flight: AtomicBool::new(false), + retry_at: Mutex::new(None), + } + } + + /// Starts a background sync when one is due, returning whether it did. + /// + /// Nothing starts while another sync of this index is running, within the + /// cooldown after one failed, or outside a Tokio runtime. + pub(super) fn refresh( + self: &Arc, + store: &Arc, + auth: &McpRegistryAuthConfig, + ) -> bool { + if !self.is_due(store, &base_url(auth)) { + return false; + } + if self + .retry_at + .lock() + .is_some_and(|retry_at| Instant::now() < retry_at) + { + tracing::debug!("official catalog index sync is cooling down after a failure"); + return false; + } + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + return false; + }; + if self + .in_flight + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return false; + } + + let guard = InFlight(Arc::clone(self)); + let store = Arc::clone(store); + let auth = auth.clone(); + tracing::debug!("starting an official catalog index sync in the background"); + runtime.spawn(async move { + let index = &guard.0; + match index.sync(&store, &auth).await { + Ok(pages) => { + *index.retry_at.lock() = None; + tracing::debug!(pages, "official catalog index synced"); + } + Err(error) => { + *index.retry_at.lock() = Some(Instant::now() + index.timeouts.cooldown); + tracing::debug!( + code = error.wire_name(), + "official catalog index sync stopped; a later request resumes it: {error}" + ); + } + } + drop(guard); + }); + true + } + + /// Whether a sync should start for the catalog at `base`. + fn is_due(&self, store: &Store, base: &str) -> bool { + let state = match store.index_state(INDEX_SOURCE) { + Ok(state) => state, + Err(error) => { + tracing::debug!("could not read the official catalog index state: {error}"); + return false; + } + }; + let Some(state) = state else { + return true; + }; + let refresh_ms = i64::try_from(self.settings.refresh.as_millis()).unwrap_or(i64::MAX); + + state.base_url != base + || state.in_progress() + || state + .synced_at + .is_none_or(|synced_at| now_ms().saturating_sub(synced_at) >= refresh_ms) + } + + /// Syncs the index, resuming an unfinished sync of the same catalog. + /// + /// Returns how many pages the finished sync holds. + /// + /// # Errors + /// + /// Returns the upstream's error for a page that fails, after keeping every + /// page before it; [`Error::MalformedResponse`] for a page that is not a + /// list; and [`Error::Store`] when the index cannot be written. + pub(super) async fn sync(&self, store: &Store, auth: &McpRegistryAuthConfig) -> Result { + let base = base_url(auth); + let resume = store + .index_state(INDEX_SOURCE)? + .filter(|state| state.in_progress() && state.base_url == base); + + let (mut cursor, mut pages) = if let Some(state) = resume { + tracing::debug!( + pages = state.pages, + "resuming the official catalog index sync" + ); + (state.cursor, state.pages) + } else { + store.begin_index_sync(INDEX_SOURCE, &base)?; + (None, 0) + }; + let mut finished = pages > 0 && cursor.is_none(); + + while !finished { + if pages >= self.settings.max_pages { + tracing::warn!( + pages, + "official catalog index sync reached its page limit; keeping what it has" + ); + break; + } + + let body = self.fetch(auth, cursor.as_deref()).await?; + let document: Value = serde_json::from_str(&body) + .map_err(|error| Error::malformed(format!("official list response: {error}")))?; + if !document.get("servers").is_some_and(Value::is_array) { + return Err(Error::malformed( + "official list response has no server list", + )); + } + + let next = document + .pointer("/metadata/nextCursor") + .and_then(Value::as_str) + .filter(|next| !next.is_empty()) + .map(ToString::to_string); + store.store_index_page(INDEX_SOURCE, &index_rows(&document), next.as_deref())?; + + pages += 1; + finished = next.is_none(); + cursor = next; + } + + store.finish_index_sync(INDEX_SOURCE)?; + Ok(pages) + } + + /// Fetches one listing page for the sync, within the browse budget. + async fn fetch(&self, auth: &McpRegistryAuthConfig, cursor: Option<&str>) -> Result { + let url = list_url(auth); + let mut request = self + .http + .get(&url) + .header("Accept", "application/json") + .query(&[("limit", self.settings.page_size.to_string())]) + .query(&[("version", "latest")]); + if let Some(token) = auth_token(auth) { + request = request.bearer_auth(token); + } + if let Some(cursor) = cursor { + request = request.query(&[("cursor", cursor)]); + } + + let timeout = self.timeouts.browse; + read_body( + request.timeout(timeout), + &url, + RegistryOperation::Browse, + timeout, + ) + .await + } +} + +/// Whether a finished sync of the catalog at `base` is in the store. +pub(super) fn is_ready(store: &Store, base: &str) -> bool { + matches!( + store.index_state(INDEX_SOURCE), + Ok(Some(state)) if state.base_url == base && state.synced_at.is_some() + ) +} + +/// One page of `query`'s matches from the index of the catalog at `base`, or +/// `None` when that index is not ready or the query has no words. +pub(super) fn search( + store: &Store, + base: &str, + query: &str, + page: u32, + page_size: u32, +) -> Option { + let terms: Vec = query.split_whitespace().map(str::to_lowercase).collect(); + if terms.is_empty() || !is_ready(store, base) { + return None; + } + + let hits = match store.search_index(INDEX_SOURCE, &terms) { + Ok(hits) => hits, + Err(error) => { + tracing::debug!("could not search the official catalog index: {error}"); + return None; + } + }; + + let mut matches: Vec<(bool, RegistryServerSummary)> = hits + .into_iter() + .filter_map(|hit| { + let server: OfficialServer = serde_json::from_str(&hit.record_json).ok()?; + Some((hit.in_label, server.into_summary())) + }) + .collect(); + + let indexed: HashSet = matches + .iter() + .map(|(_, row)| row.qualified_name.clone()) + .collect(); + matches.extend( + CURATED_SERVERS + .iter() + .filter(|curated| !indexed.contains(curated.qualified_name)) + .filter_map(|curated| { + let row = curated.to_summary(); + 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())); + let anywhere = terms.iter().all(|term| { + label.contains(term.as_str()) || description.contains(term.as_str()) + }); + anywhere.then_some((in_label, row)) + }), + ); + matches.sort_by(|(left_label, left), (right_label, right)| { + rank(*left_label, left, *right_label, right) + }); + + let size = usize::try_from(page_size.max(1)).unwrap_or(usize::MAX); + let total_pages = u32::try_from(matches.len().div_ceil(size)) + .unwrap_or(u32::MAX) + .max(1); + let skip = usize::try_from(page.saturating_sub(1)) + .unwrap_or(usize::MAX) + .saturating_mul(size); + + tracing::debug!( + query_length = query.len(), + matches = matches.len(), + "official search answered from the local index" + ); + Some(SourcePage { + servers: matches + .into_iter() + .skip(skip) + .take(size) + .map(|(_, row)| row) + .collect(), + total_pages, + freshness: RegistryFreshness::Indexed, + }) +} + +/// The order of two matches: curated first, then name or title matches, then +/// alphabetical by display name and qualified name. +fn rank( + left_label: bool, + left: &RegistryServerSummary, + right_label: bool, + right: &RegistryServerSummary, +) -> Order { + let curated = |row: &RegistryServerSummary| curated_server(&row.qualified_name).is_none(); + + curated(left) + .cmp(&curated(right)) + .then_with(|| right_label.cmp(&left_label)) + .then_with(|| { + left.display_name + .to_lowercase() + .cmp(&right.display_name.to_lowercase()) + }) + .then_with(|| left.qualified_name.cmp(&right.qualified_name)) +} + +/// The lowercased text a query's words are matched against as the name. +fn search_label(qualified_name: &str, display_name: &str) -> String { + format!("{qualified_name} {display_name}").to_lowercase() +} + +/// The rows a list page adds to the index. +fn index_rows(document: &Value) -> Vec { + latest_server_records(document) + .into_iter() + .filter_map(|record| { + let server: OfficialServer = serde_json::from_value(record.clone()).ok()?; + Some(IndexRow { + qualified_name: server.name.clone(), + label: search_label(&server.name, &server.display_name()), + description: server.description().unwrap_or_default().to_lowercase(), + record_json: record.to_string(), + }) + }) + .collect() +} + +#[cfg(test)] +#[path = "index_tests.rs"] +mod tests; diff --git a/crates/tinymcp/src/registry/sources/official/index_tests.rs b/crates/tinymcp/src/registry/sources/official/index_tests.rs new file mode 100644 index 0000000..60fbfd3 --- /dev/null +++ b/crates/tinymcp/src/registry/sources/official/index_tests.rs @@ -0,0 +1,808 @@ +//! Unit tests for the official catalog index. +//! +//! Every registry here is a loopback server serving fixed pages, so a sync is +//! deterministic and nothing reaches the network. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::atomic::AtomicUsize; +use std::time::Duration; + +use axum::Router; +use axum::extract::State; +use axum::http::Uri; +use axum::response::IntoResponse as _; +use axum::routing::get; +use serde_json::json; + +use super::*; +use crate::registry::Registries; +use crate::registry::sources::official::McpOfficialRegistry; + +/// A loopback registry serving fixed pages, with switches for failure. +#[derive(Debug, Default)] +struct Upstream { + pages: Vec>, + /// The one-based page that answers 503, or zero for none. + failing_page: AtomicUsize, + /// Answer every page with a body that is not a list. + garbled: AtomicBool, + /// How long each listing waits before answering. + delay: Duration, + requests: AtomicUsize, + cursors: Mutex>>, + searches: AtomicUsize, + limits: Mutex>>, +} + +impl Upstream { + fn requests(&self) -> usize { + self.requests.load(Ordering::SeqCst) + } + + fn cursors(&self) -> Vec> { + self.cursors.lock().clone() + } +} + +fn param(uri: &Uri, name: &str) -> Option { + uri.query()?.split('&').find_map(|pair| { + let (key, value) = pair.split_once('=')?; + (key == name).then(|| value.replace('+', " ")) + }) +} + +/// An installable envelope for `name`. +fn envelope(name: &str, title: &str, description: &str) -> Value { + json!({ + "server": { + "name": name, + "title": title, + "description": description, + "remotes": [{ "url": format!("https://{}.test/mcp", title.to_lowercase()) }], + }, + }) +} + +/// Pages of one plain server each, named `io.example/server-N`. +fn numbered_pages(count: usize) -> Vec> { + (1..=count) + .map(|page| { + vec![envelope( + &format!("io.example/server-{page}"), + &format!("Server {page}"), + "a server", + )] + }) + .collect() +} + +async fn serve(upstream: Upstream) -> (String, Arc) { + let upstream = Arc::new(upstream); + let app = Router::new() + .route( + "/v0/servers", + get( + |State(upstream): State>, uri: Uri| async move { + upstream.requests.fetch_add(1, Ordering::SeqCst); + if param(&uri, "search").is_some() { + upstream.searches.fetch_add(1, Ordering::SeqCst); + } + let cursor = param(&uri, "cursor"); + upstream.cursors.lock().push(cursor.clone()); + upstream.limits.lock().push(param(&uri, "limit")); + tokio::time::sleep(upstream.delay).await; + + if upstream.garbled.load(Ordering::SeqCst) { + return "[1, 2, 3]".into_response(); + } + + let page: usize = cursor + .as_deref() + .and_then(|cursor| cursor.parse().ok()) + .unwrap_or(1); + if upstream.failing_page.load(Ordering::SeqCst) == page { + return (axum::http::StatusCode::SERVICE_UNAVAILABLE, "down") + .into_response(); + } + + let servers = upstream.pages.get(page - 1).cloned().unwrap_or_default(); + let mut body = json!({ "servers": servers }); + if page < upstream.pages.len() { + body["metadata"] = json!({ "nextCursor": (page + 1).to_string() }); + } + axum::Json(body).into_response() + }, + ), + ) + .fallback(|| async { (axum::http::StatusCode::NOT_FOUND, "no such server") }) + .with_state(Arc::clone(&upstream)); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + (base, upstream) +} + +fn auth_at(base: &str) -> McpRegistryAuthConfig { + McpRegistryAuthConfig { + mcp_official_base: Some(base.to_string()), + ..McpRegistryAuthConfig::default() + } +} + +fn timeouts(cooldown: Duration) -> RegistryTimeouts { + RegistryTimeouts { + browse: Duration::from_secs(5), + cooldown, + ..RegistryTimeouts::default() + } +} + +fn index_with(settings: RegistryIndexSettings, cooldown: Duration) -> Arc { + Arc::new(OfficialIndex::new( + reqwest::Client::new(), + timeouts(cooldown), + settings, + )) +} + +fn index() -> Arc { + index_with(RegistryIndexSettings::default(), Duration::from_secs(60)) +} + +fn store() -> Arc { + Arc::new(Store::open_in_memory().unwrap()) +} + +fn indexed_names(store: &Store) -> Vec { + let mut names: Vec = store + .search_index(INDEX_SOURCE, &[]) + .unwrap() + .into_iter() + .map(|hit| hit.qualified_name) + .collect(); + names.sort(); + names +} + +fn page_names(page: &SourcePage) -> Vec<&str> { + page.servers + .iter() + .map(|server| server.qualified_name.as_str()) + .collect() +} + +/// Waits until no sync of `index` is in flight. +async fn settle(index: &OfficialIndex) { + for _ in 0..500 { + if !index.in_flight.load(Ordering::Acquire) { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("the background sync did not finish"); +} + +/// Moves the last finished sync back by `hours`. +fn age_sync(store: &Store, hours: i64) { + store.with_connection(|connection| { + connection + .execute( + "UPDATE mcp_registry_index_state SET synced_at = synced_at - ?1", + rusqlite::params![hours * 60 * 60 * 1_000], + ) + .unwrap(); + }); +} + +/// A store whose index holds `pages`, synced from `base`. +async fn synced(base: &str, store: &Store) { + index().sync(store, &auth_at(base)).await.unwrap(); +} + +// --------------------------------------------------------------------------- +// Syncing +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn a_sync_walks_every_page_by_cursor() { + let (base, upstream) = serve(Upstream { + pages: numbered_pages(3), + ..Upstream::default() + }) + .await; + let store = store(); + + let pages = index().sync(&store, &auth_at(&base)).await.unwrap(); + + assert_eq!(pages, 3); + assert_eq!( + upstream.cursors(), + [None, Some("2".to_string()), Some("3".to_string())] + ); + assert!( + upstream + .limits + .lock() + .iter() + .all(|limit| limit.as_deref() == Some("100")) + ); + assert_eq!(upstream.searches.load(Ordering::SeqCst), 0); + assert_eq!( + indexed_names(&store), + [ + "io.example/server-1", + "io.example/server-2", + "io.example/server-3" + ] + ); + assert!(is_ready(&store, &base)); +} + +#[tokio::test] +async fn a_sync_keeps_one_row_per_server_preferring_the_latest_version() { + let mut old = envelope("io.example/versioned", "Old", "old"); + old["_meta"] = json!({ + "io.modelcontextprotocol.registry/official": { "isLatest": false }, + }); + let mut new = envelope("io.example/versioned", "New", "new"); + new["_meta"] = json!({ + "io.modelcontextprotocol.registry/official": { "isLatest": true }, + }); + let mut deprecated = envelope("io.example/deprecated", "Gone", "gone"); + deprecated["_meta"] = json!({ + "io.modelcontextprotocol.registry/official": { "status": "deprecated" }, + }); + let not_installable = json!({ "server": { "name": "io.example/nothing" } }); + let (base, _upstream) = serve(Upstream { + pages: vec![vec![new, old, deprecated, not_installable, json!({})]], + ..Upstream::default() + }) + .await; + let store = store(); + + synced(&base, &store).await; + + assert_eq!(indexed_names(&store), ["io.example/versioned"]); + let page = search(&store, &base, "versioned", 1, 20).unwrap(); + assert_eq!(page.servers[0].display_name, "New"); +} + +#[tokio::test] +async fn a_failed_page_keeps_what_was_stored_and_the_next_sync_resumes_there() { + let (base, upstream) = serve(Upstream { + pages: numbered_pages(3), + failing_page: AtomicUsize::new(2), + ..Upstream::default() + }) + .await; + let store = store(); + let index = index(); + + let error = index.sync(&store, &auth_at(&base)).await.unwrap_err(); + assert!(error.is_registry_unavailable(), "{error:?}"); + assert_eq!(indexed_names(&store), ["io.example/server-1"]); + assert!(!is_ready(&store, &base), "a partial copy is not served"); + let state = store.index_state(INDEX_SOURCE).unwrap().unwrap(); + assert_eq!(state.cursor.as_deref(), Some("2")); + assert_eq!(state.pages, 1); + + upstream.failing_page.store(0, Ordering::SeqCst); + upstream.cursors.lock().clear(); + let pages = index.sync(&store, &auth_at(&base)).await.unwrap(); + + assert_eq!(pages, 3); + assert_eq!( + upstream.cursors(), + [Some("2".to_string()), Some("3".to_string())], + "the resumed sync does not start over" + ); + assert_eq!(indexed_names(&store).len(), 3); + assert!(is_ready(&store, &base)); +} + +#[tokio::test] +async fn a_sync_resumed_after_its_last_page_was_written_just_finishes() { + let (base, upstream) = serve(Upstream { + pages: numbered_pages(1), + ..Upstream::default() + }) + .await; + let store = store(); + store.begin_index_sync(INDEX_SOURCE, &base).unwrap(); + store.store_index_page(INDEX_SOURCE, &[], None).unwrap(); + + let pages = index().sync(&store, &auth_at(&base)).await.unwrap(); + + assert_eq!(pages, 1); + assert_eq!(upstream.requests(), 0); + assert!(is_ready(&store, &base)); +} + +#[tokio::test] +async fn a_sync_stops_at_the_page_limit_and_keeps_what_it_has() { + let (base, upstream) = serve(Upstream { + pages: numbered_pages(5), + ..Upstream::default() + }) + .await; + let store = store(); + let index = index_with( + RegistryIndexSettings { + max_pages: 2, + ..RegistryIndexSettings::default() + }, + Duration::from_secs(60), + ); + + let pages = index.sync(&store, &auth_at(&base)).await.unwrap(); + + assert_eq!(pages, 2); + assert_eq!(upstream.requests(), 2); + assert_eq!(indexed_names(&store).len(), 2); + assert!(is_ready(&store, &base)); +} + +#[tokio::test] +async fn a_page_that_is_not_a_list_is_malformed_and_writes_nothing() { + let (base, upstream) = serve(Upstream { + pages: numbered_pages(1), + ..Upstream::default() + }) + .await; + let store = store(); + synced(&base, &store).await; + upstream.garbled.store(true, Ordering::SeqCst); + + let error = index().sync(&store, &auth_at(&base)).await.unwrap_err(); + + assert!( + matches!(error, Error::MalformedResponse { .. }), + "{error:?}" + ); + assert_eq!(indexed_names(&store), ["io.example/server-1"]); +} + +#[tokio::test] +async fn a_body_that_is_not_json_is_malformed() { + let app = Router::new().fallback(|| async { "not json" }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let error = index().sync(&store(), &auth_at(&base)).await.unwrap_err(); + + assert!( + matches!(error, Error::MalformedResponse { .. }), + "{error:?}" + ); +} + +#[tokio::test] +async fn a_configured_token_is_sent_with_every_sync_page() { + let seen = Arc::new(Mutex::new(Vec::>::new())); + let app = Router::new() + .route( + "/v0/servers", + get( + |State(seen): State>>>>, + headers: axum::http::HeaderMap| async move { + seen.lock().push( + headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .map(ToString::to_string), + ); + axum::Json(json!({ "servers": [] })) + }, + ), + ) + .with_state(Arc::clone(&seen)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let auth = McpRegistryAuthConfig { + mcp_official_token: Some("secret".to_string()), + ..auth_at(&base) + }; + + index().sync(&store(), &auth).await.unwrap(); + + assert_eq!(seen.lock().as_slice(), [Some("Bearer secret".to_string())]); +} + +// --------------------------------------------------------------------------- +// When a sync is due +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn a_sync_is_due_until_one_finishes_and_again_after_the_refresh_interval() { + let (base, _upstream) = serve(Upstream { + pages: numbered_pages(1), + ..Upstream::default() + }) + .await; + let store = store(); + let index = index(); + + assert!(index.is_due(&store, &base), "no index yet"); + index.sync(&store, &auth_at(&base)).await.unwrap(); + assert!(!index.is_due(&store, &base), "just synced"); + + age_sync(&store, 5); + assert!(!index.is_due(&store, &base), "inside the six hours"); + age_sync(&store, 2); + assert!(index.is_due(&store, &base), "past the six hours"); +} + +#[tokio::test] +async fn a_sync_is_due_for_a_different_catalog_or_an_unfinished_one() { + let (base, _upstream) = serve(Upstream { + pages: numbered_pages(1), + ..Upstream::default() + }) + .await; + let store = store(); + let index = index(); + index.sync(&store, &auth_at(&base)).await.unwrap(); + + assert!(index.is_due(&store, "https://elsewhere.test")); + + store.begin_index_sync(INDEX_SOURCE, &base).unwrap(); + assert!(index.is_due(&store, &base)); +} + +#[tokio::test] +async fn a_refresh_syncs_in_the_background_and_then_is_not_due() { + let (base, upstream) = serve(Upstream { + pages: numbered_pages(2), + ..Upstream::default() + }) + .await; + let store = store(); + let index = index(); + + assert!(index.refresh(&store, &auth_at(&base))); + settle(&index).await; + + assert!(is_ready(&store, &base)); + assert_eq!(upstream.requests(), 2); + assert!(!index.refresh(&store, &auth_at(&base))); + assert_eq!(upstream.requests(), 2); +} + +#[tokio::test] +async fn only_one_sync_is_ever_in_flight() { + let (base, upstream) = serve(Upstream { + pages: numbered_pages(2), + delay: Duration::from_millis(200), + ..Upstream::default() + }) + .await; + let store = store(); + let index = index(); + + let started: Vec = (0..5) + .map(|_| index.refresh(&store, &auth_at(&base))) + .collect(); + assert_eq!(started, [true, false, false, false, false]); + + settle(&index).await; + assert_eq!(upstream.requests(), 2, "one walk of two pages"); + assert!(is_ready(&store, &base)); +} + +#[tokio::test] +async fn a_failed_background_sync_waits_out_the_cooldown_before_resuming() { + let (base, upstream) = serve(Upstream { + pages: numbered_pages(2), + failing_page: AtomicUsize::new(2), + ..Upstream::default() + }) + .await; + let store = store(); + let index = index(); + + assert!(index.refresh(&store, &auth_at(&base))); + settle(&index).await; + assert!(!is_ready(&store, &base)); + + upstream.failing_page.store(0, Ordering::SeqCst); + assert!( + !index.refresh(&store, &auth_at(&base)), + "still cooling down" + ); + *index.retry_at.lock() = Some(Instant::now()); + + assert!(index.refresh(&store, &auth_at(&base))); + settle(&index).await; + assert!(is_ready(&store, &base)); + assert_eq!(index.retry_at.lock().as_ref(), None); +} + +#[test] +fn a_refresh_outside_a_runtime_starts_nothing() { + let store = store(); + let index = index(); + + assert!(!index.refresh(&store, &auth_at("http://127.0.0.1:9"))); + assert!(!index.in_flight.load(Ordering::Acquire)); +} + +#[tokio::test] +async fn a_refresh_through_the_dispatcher_syncs_the_configured_catalog() { + let (base, _upstream) = serve(Upstream { + pages: numbered_pages(1), + ..Upstream::default() + }) + .await; + let store = store(); + let registries = Registries::with_official( + auth_at(&base), + McpOfficialRegistry::with_settings( + timeouts(Duration::from_secs(60)), + RegistryIndexSettings::default(), + ) + .unwrap(), + ) + .unwrap(); + + assert!(registries.refresh_index(&store)); + for _ in 0..500 { + if is_ready(&store, &base) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + assert!(is_ready(&store, &base)); +} + +// --------------------------------------------------------------------------- +// Searching +// --------------------------------------------------------------------------- + +/// A store whose index holds the given envelopes on one page. +async fn indexed(servers: Vec) -> (String, Arc, Arc) { + let (base, upstream) = serve(Upstream { + pages: vec![servers], + ..Upstream::default() + }) + .await; + let store = store(); + synced(&base, &store).await; + (base, upstream, store) +} + +#[tokio::test] +async fn an_index_that_has_not_finished_a_sync_is_not_searched() { + let store = store(); + store + .begin_index_sync(INDEX_SOURCE, "https://registry.test") + .unwrap(); + + assert!(search(&store, "https://registry.test", "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()); +} + +#[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()); +} + +#[tokio::test] +async fn a_search_ranks_curated_then_name_then_description_then_alphabetical() { + let (base, _upstream, store) = indexed(vec![ + envelope("io.example/zeta", "Zeta Slack", "posts messages"), + envelope("io.example/alpha", "Alpha Chat", "bridges slack and email"), + envelope("io.example/beta", "Beta Slack", "reads channels"), + envelope("io.example/mail", "Mail", "sends email"), + ]) + .await; + + let page = search(&store, &base, "Slack", 1, 20).unwrap(); + + assert_eq!(page.freshness, RegistryFreshness::Indexed); + assert_eq!( + page_names(&page), + [ + "com.slack/mcp", + "io.example/beta", + "io.example/zeta", + "io.example/alpha" + ] + ); + assert_eq!(page.total_pages, 1); +} + +#[tokio::test] +async fn every_word_of_the_query_must_match() { + let (base, _upstream, store) = indexed(vec![ + envelope("io.example/zeta", "Zeta Slack", "posts messages"), + envelope("io.example/beta", "Beta Slack", "reads channels"), + ]) + .await; + + let page = search(&store, &base, "slack posts", 1, 20).unwrap(); + + assert_eq!(page_names(&page), ["io.example/zeta"]); +} + +#[tokio::test] +async fn a_curated_server_the_index_holds_appears_once_with_the_registry_record() { + let (base, _upstream, store) = indexed(vec![ + envelope("io.example/notion-sync", "Notion Sync", "a notion helper"), + envelope("com.notion/mcp", "Notion", "from the registry"), + ]) + .await; + + let page = search(&store, &base, "notion", 1, 20).unwrap(); + + assert_eq!( + page_names(&page), + ["com.notion/mcp", "io.example/notion-sync"] + ); + assert_eq!( + page.servers[0].description.as_deref(), + Some("from the registry") + ); +} + +#[tokio::test] +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(); + + assert_eq!(page_names(&page), ["com.atlassian/atlassian-mcp-server"]); + assert_eq!(page.servers[0].source, SOURCE_MCP_OFFICIAL); +} + +#[tokio::test] +async fn a_search_pages_through_its_matches() { + let servers = (1..=5) + .map(|n| { + envelope( + &format!("io.example/widget-{n}"), + &format!("Widget {n}"), + "", + ) + }) + .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(); + + assert_eq!( + page_names(&first), + ["io.example/widget-1", "io.example/widget-2"] + ); + assert_eq!(first.total_pages, 3); + assert_eq!(page_names(&last), ["io.example/widget-5"]); + assert_eq!(page_names(&beyond), Vec::<&str>::new()); + assert_eq!(beyond.total_pages, 3); +} + +#[tokio::test] +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(); + + assert_eq!(page_names(&page), Vec::<&str>::new()); + assert_eq!(page.total_pages, 1); + assert_eq!(page.freshness, RegistryFreshness::Indexed); +} + +// --------------------------------------------------------------------------- +// Through the adapter +// --------------------------------------------------------------------------- + +fn adapter() -> McpOfficialRegistry { + McpOfficialRegistry::with_settings( + timeouts(Duration::from_secs(60)), + RegistryIndexSettings::default(), + ) + .unwrap() +} + +fn cursors() -> Mutex> { + Mutex::new(std::collections::HashMap::new()) +} + +#[tokio::test] +async fn the_adapter_answers_a_query_from_the_index_without_asking_the_registry() { + let (base, upstream, store) = + indexed(vec![envelope("io.example/slack-bot", "Slack Bot", "")]).await; + let before = upstream.requests(); + + let page = adapter() + .search(&store, &auth_at(&base), &cursors(), "slack", 1, 20) + .await + .unwrap(); + + assert_eq!(page.freshness, RegistryFreshness::Indexed); + assert_eq!(page_names(&page), ["com.slack/mcp", "io.example/slack-bot"]); + assert_eq!(upstream.requests(), before); + assert_eq!(upstream.searches.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn the_adapter_still_browses_the_registry_with_an_index() { + let (base, upstream, store) = indexed(vec![envelope("io.example/a", "A", "")]).await; + let before = upstream.requests(); + + let page = adapter() + .search(&store, &auth_at(&base), &cursors(), "", 1, 20) + .await + .unwrap(); + + assert_eq!(page.freshness, RegistryFreshness::Live); + assert_eq!(upstream.requests(), before + 1); +} + +#[tokio::test] +async fn without_an_index_the_adapter_asks_the_registry_to_search() { + let (base, upstream) = serve(Upstream { + pages: numbered_pages(1), + ..Upstream::default() + }) + .await; + + let page = adapter() + .search(&store(), &auth_at(&base), &cursors(), "server", 1, 20) + .await + .unwrap(); + + assert_eq!(page.freshness, RegistryFreshness::Live); + assert_eq!(upstream.searches.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn a_curated_server_the_registry_does_not_list_is_described_from_its_entry() { + let (base, _upstream) = serve(Upstream::default()).await; + + let detail = adapter() + .get(&store(), &auth_at(&base), "com.slack/mcp") + .await + .unwrap(); + + assert_eq!(detail.qualified_name, "com.slack/mcp"); + assert_eq!( + detail.connections[0].deployment_url.as_deref(), + Some("https://mcp.slack.com/mcp") + ); +} + +#[tokio::test] +async fn an_uncurated_server_the_registry_does_not_list_is_still_an_error() { + let (base, _upstream) = serve(Upstream::default()).await; + + let error = adapter() + .get(&store(), &auth_at(&base), "io.example/nowhere") + .await + .unwrap_err(); + + assert!( + matches!(error, Error::Http { status: 404, .. }), + "{error:?}" + ); +} diff --git a/crates/tinymcp/src/registry/sources/official/mod.rs b/crates/tinymcp/src/registry/sources/official/mod.rs index c7a5fc9..12867d6 100644 --- a/crates/tinymcp/src/registry/sources/official/mod.rs +++ b/crates/tinymcp/src/registry/sources/official/mod.rs @@ -29,6 +29,12 @@ //! it is; a timed-out listing also skips the network for a cooldown. The //! `fallback` module holds the order. //! +//! # Searching a local index +//! +//! The registry's search is far slower than its plain listing, so the adapter +//! keeps a local copy of the listing and answers queries from it once a sync +//! has finished. The `index` module holds how it syncs and ranks. +//! //! # The page count is a bound, not a total //! //! Knowing the true total would mean walking the whole cursor chain, which is @@ -37,20 +43,26 @@ //! decide whether to offer a "next" control. mod fallback; +mod index; mod types; use std::collections::HashMap; +use std::sync::Arc; use parking_lot::Mutex; use serde_json::Value; use self::fallback::{Cooldown, serve_cached}; +use self::index::OfficialIndex; use self::types::{OfficialListResponse, OfficialServer, latest_version}; use super::encode::encode_path_segment; use super::shared::{cache, read_body}; -use super::types::{RegistryOperation, RegistryTimeouts, SourcePage, non_blank_env}; +use super::types::{ + RegistryIndexSettings, RegistryOperation, RegistryTimeouts, SourcePage, non_blank_env, +}; use crate::error::{Error, Result}; use crate::registry::Store; +use crate::registry::curation::curated_server; use tinymcp_bus::{McpRegistryAuthConfig, RegistryFreshness, RegistryServerDetail}; /// Where the registry lives when nothing overrides it. @@ -78,6 +90,7 @@ pub struct McpOfficialRegistry { http: reqwest::Client, timeouts: RegistryTimeouts, cooldown: Cooldown, + index: Arc, } impl McpOfficialRegistry { @@ -96,6 +109,15 @@ impl McpOfficialRegistry { /// /// Returns [`Error::ClientBuild`] when the HTTP client cannot be built. pub fn with_timeouts(timeouts: RegistryTimeouts) -> Result { + Self::with_settings(timeouts, RegistryIndexSettings::default()) + } + + /// Builds the adapter with its own time budgets and index settings. + /// + /// # Errors + /// + /// Returns [`Error::ClientBuild`] when the HTTP client cannot be built. + pub fn with_settings(timeouts: RegistryTimeouts, index: RegistryIndexSettings) -> Result { let http = reqwest::Client::builder() .connect_timeout(timeouts.connect) .build() @@ -104,14 +126,24 @@ impl McpOfficialRegistry { })?; Ok(Self { + index: Arc::new(OfficialIndex::new(http.clone(), timeouts, index)), http, timeouts, cooldown: Cooldown::default(), }) } + /// Starts a background sync of the local catalog index when one is due, + /// returning whether it did. See the `index` module. + pub(super) fn refresh_index(&self, store: &Arc, auth: &McpRegistryAuthConfig) -> bool { + self.index.refresh(store, auth) + } + /// Searches the catalog. /// + /// Once the local index has finished a sync of the configured catalog, a + /// query is answered from it without asking the registry. + /// /// When the registry cannot answer — it timed out, was unreachable, or /// answered 408, 429 or 5xx — the page is served from the cache instead, /// with its freshness saying so. The fallback module sets out the order. @@ -130,6 +162,10 @@ impl McpOfficialRegistry { page: u32, page_size: u32, ) -> Result { + if let Some(found) = index::search(store, &base_url(auth), query, page, page_size) { + return Ok(found); + } + let cache_key = search_cache_key(query, page, page_size); if let Ok(Some(cached)) = store.cached(&cache_key) @@ -219,6 +255,9 @@ impl McpOfficialRegistry { /// Fetches one server's detail. /// + /// A curated server the registry cannot describe — it does not list it, or + /// cannot be reached — is described from its curated entry instead. + /// /// # Errors /// /// Returns [`Error::UnknownServer`] when the registry lists no version of @@ -228,6 +267,29 @@ impl McpOfficialRegistry { store: &Store, auth: &McpRegistryAuthConfig, qualified_name: &str, + ) -> Result { + match self.get_listed(store, auth, qualified_name).await { + Err(error) => match curated_server(qualified_name) { + Some(curated) => { + tracing::debug!( + qualified_name, + code = error.wire_name(), + "official detail answered from the curated entry" + ); + Ok(curated.to_detail()) + } + None => Err(error), + }, + found => found, + } + } + + /// Fetches one server's detail from the registry or its cache. + async fn get_listed( + &self, + store: &Store, + auth: &McpRegistryAuthConfig, + qualified_name: &str, ) -> Result { let cache_key = format!("{DETAIL_CACHE_PREFIX}{qualified_name}"); diff --git a/crates/tinymcp/src/registry/sources/official/types.rs b/crates/tinymcp/src/registry/sources/official/types.rs index 82f5aa8..37909fd 100644 --- a/crates/tinymcp/src/registry/sources/official/types.rs +++ b/crates/tinymcp/src/registry/sources/official/types.rs @@ -156,6 +156,51 @@ pub(super) fn latest_version(document: &Value) -> Option<&Value> { .and_then(|envelope| envelope.get("server")) } +/// The server records on a list page worth keeping, one per server. +/// +/// The same choice as [`OfficialListResponse::into_summaries`], over the raw +/// records: installable, not deprecated, and the version marked latest. A row +/// that does not decode as an envelope is skipped. +pub(super) fn latest_server_records(document: &Value) -> Vec<&Value> { + let Some(rows) = document.get("servers").and_then(Value::as_array) else { + return Vec::new(); + }; + + let mut order: Vec = Vec::new(); + let mut chosen: HashMap = HashMap::new(); + + for row in rows { + let Ok(envelope) = OfficialServerEnvelope::deserialize(row) else { + continue; + }; + let Some(record) = row.get("server") else { + continue; + }; + if !envelope.is_installable() || envelope.is_deprecated() { + continue; + } + + let latest = envelope.is_latest(); + let name = envelope.server.name; + match chosen.get(&name) { + Some((true, _)) => {} + Some(_) => { + chosen.insert(name, (latest, record)); + } + None => { + order.push(name.clone()); + chosen.insert(name, (latest, record)); + } + } + } + + order + .iter() + .filter_map(|name| chosen.remove(name)) + .map(|(_, record)| record) + .collect() +} + /// One server, as the official registry describes it. #[derive(Debug, Clone, Default, Deserialize)] pub(super) struct OfficialServer { @@ -186,6 +231,11 @@ impl OfficialServer { !self.remotes.is_empty() || !self.packages.is_empty() } + /// The declared description. + pub(super) fn description(&self) -> Option<&str> { + self.description.as_deref() + } + /// The icon to show for this server. /// /// A raster image ahead of an SVG, and an SVG when it is the only one diff --git a/crates/tinymcp/src/registry/sources/types.rs b/crates/tinymcp/src/registry/sources/types.rs index 69530ef..0a73909 100644 --- a/crates/tinymcp/src/registry/sources/types.rs +++ b/crates/tinymcp/src/registry/sources/types.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::fmt; +use std::sync::Arc; use std::time::Duration; use parking_lot::Mutex; @@ -108,6 +109,31 @@ impl Default for RegistryTimeouts { } } +/// How the official catalog adapter keeps its local index of the catalog. +/// +/// The index is what a search answers from once it exists, because the +/// registry's own search is far slower than paging its plain listing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RegistryIndexSettings { + /// How old the last finished sync may be before a search or browse starts + /// another in the background. + pub refresh: Duration, + /// The most pages one sync reads before it stops and keeps what it has. + pub max_pages: u32, + /// How many servers each page asks for. + pub page_size: u32, +} + +impl Default for RegistryIndexSettings { + fn default() -> Self { + Self { + refresh: Duration::from_secs(6 * 60 * 60), + max_pages: 200, + page_size: 100, + } + } +} + /// One page from one upstream catalog. #[derive(Debug, Clone, Default, PartialEq)] pub struct SourcePage { @@ -172,14 +198,39 @@ impl Registries { /// /// Returns [`Error::ClientBuild`] when an HTTP client cannot be built. pub fn new(auth: McpRegistryAuthConfig) -> Result { + Self::with_official(auth, McpOfficialRegistry::new()?) + } + + /// Builds the dispatcher over an official catalog adapter built with its + /// own settings. + /// + /// # Errors + /// + /// Returns [`Error::ClientBuild`] when the Smithery HTTP client cannot be + /// built. + pub fn with_official( + auth: McpRegistryAuthConfig, + official: McpOfficialRegistry, + ) -> Result { Ok(Self { - official: McpOfficialRegistry::new()?, + official, smithery: SmitheryRegistry::new()?, auth: Mutex::new(auth), cursors: Mutex::new(HashMap::new()), }) } + /// Starts a background sync of the official catalog's local index when + /// one is due, returning whether it did. + /// + /// A search answers from the index once a sync has finished; until then it + /// asks the registry. The sync never blocks the caller, and at most one + /// runs at a time. + pub fn refresh_index(&self, store: &Arc) -> bool { + let auth = self.auth.lock().clone(); + self.official.refresh_index(store, &auth) + } + /// The sources that take part in a search. /// /// The official registry is always in and always first, so its rows lead a diff --git a/crates/tinymcp/src/registry/store/index.rs b/crates/tinymcp/src/registry/store/index.rs new file mode 100644 index 0000000..cf9767a --- /dev/null +++ b/crates/tinymcp/src/registry/store/index.rs @@ -0,0 +1,278 @@ +//! The local copy of an upstream catalog, for searching without asking it. +//! +//! Two tables. `mcp_registry_index` holds one row per server: the upstream's +//! own record, plus a lowercased label and description to match a query +//! against. `mcp_registry_index_state` holds one row per catalog: which base URL +//! the rows came from, where an unfinished sync stopped, and when the last sync +//! finished. +//! +//! # A sync is resumable +//! +//! Each page is written together with the cursor for the next one, in one +//! transaction. A sync that stops part way — a timeout, a restart — leaves the +//! rows it already wrote and a cursor to resume from. Rows from an earlier sync +//! stay until a sync finishes, at which point any server it did not see again +//! is removed. + +use std::fmt::Write as _; + +use rusqlite::{OptionalExtension as _, params, params_from_iter}; + +use super::types::{Store, now_ms}; +use crate::error::{Error, Result}; + +/// One server, ready to be written to the index. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct IndexRow { + /// The registry's qualified name. + pub(crate) qualified_name: String, + /// The name and title, lowercased, for matching. + pub(crate) label: String, + /// The description, lowercased, for matching. + pub(crate) description: String, + /// The upstream's record for the server, as JSON. + pub(crate) record_json: String, +} + +/// One server an index search matched. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct IndexHit { + /// The registry's qualified name. + pub(crate) qualified_name: String, + /// Whether every word matched the name or title. + pub(crate) in_label: bool, + /// The upstream's record for the server, as JSON. + pub(crate) record_json: String, +} + +/// Where one catalog's index stands. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct IndexState { + /// The base URL the rows came from. + pub(crate) base_url: String, + /// The cursor an unfinished sync resumes from; `None` for its first page. + pub(crate) cursor: Option, + /// How many pages the unfinished sync has written. + pub(crate) pages: u32, + /// When the unfinished sync started, in Unix epoch milliseconds; `None` + /// when no sync is unfinished. + pub(crate) started_at: Option, + /// When the last sync finished, in Unix epoch milliseconds; `None` when + /// none has. + pub(crate) synced_at: Option, +} + +impl IndexState { + /// Whether a sync started and has not finished. + pub(crate) const fn in_progress(&self) -> bool { + self.started_at.is_some() + } +} + +impl Store { + /// Where `source`'s index stands, or `None` when no sync has begun. + /// + /// # Errors + /// + /// Returns [`Error::Store`] when the query fails. + pub(crate) fn index_state(&self, source: &str) -> Result> { + self.connection + .lock() + .query_row( + "SELECT base_url, cursor, pages, started_at, synced_at + FROM mcp_registry_index_state WHERE source = ?1", + params![source], + |row| { + Ok(IndexState { + base_url: row.get(0)?, + cursor: row.get(1)?, + pages: row.get(2)?, + started_at: row.get(3)?, + synced_at: row.get(4)?, + }) + }, + ) + .optional() + .map_err(|source| Error::store("reading the index state", source)) + } + + /// Starts a sync of `source` from its first page. + /// + /// Rows from a different base URL are dropped, since they describe a + /// different catalog; rows from the same one stay searchable until the + /// sync finishes. + /// + /// # Errors + /// + /// Returns [`Error::Store`] when a statement fails. + pub(crate) fn begin_index_sync(&self, source: &str, base_url: &str) -> Result<()> { + let mut connection = self.connection.lock(); + let transaction = connection + .transaction() + .map_err(|source| Error::store("beginning an index sync", source))?; + + let previous_base: Option = transaction + .query_row( + "SELECT base_url FROM mcp_registry_index_state WHERE source = ?1", + params![source], + |row| row.get(0), + ) + .optional() + .map_err(|source| Error::store("reading the index state", source))?; + let same_catalog = previous_base.as_deref() == Some(base_url); + + if !same_catalog { + transaction + .execute( + "DELETE FROM mcp_registry_index WHERE source = ?1", + params![source], + ) + .map_err(|source| Error::store("clearing the index", source))?; + } + + transaction + .execute( + "INSERT INTO mcp_registry_index_state + (source, base_url, cursor, pages, started_at, synced_at) + VALUES (?1, ?2, NULL, 0, ?3, NULL) + ON CONFLICT(source) DO UPDATE SET + base_url = excluded.base_url, + cursor = NULL, + pages = 0, + started_at = excluded.started_at, + synced_at = CASE WHEN ?4 THEN synced_at ELSE NULL END", + params![source, base_url, now_ms(), same_catalog], + ) + .map_err(|source| Error::store("recording an index sync", source))?; + + transaction + .commit() + .map_err(|source| Error::store("committing an index sync start", source)) + } + + /// Writes one synced page of `source` and the cursor for the next. + /// + /// # Errors + /// + /// Returns [`Error::Store`] when a statement fails; nothing from the page + /// is written then. + pub(crate) fn store_index_page( + &self, + source: &str, + rows: &[IndexRow], + next_cursor: Option<&str>, + ) -> Result<()> { + let now = now_ms(); + let mut connection = self.connection.lock(); + let transaction = connection + .transaction() + .map_err(|source| Error::store("beginning an index page", source))?; + + for row in rows { + transaction + .execute( + "INSERT OR REPLACE INTO mcp_registry_index + (source, qualified_name, label, description, record_json, synced_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + source, + row.qualified_name, + row.label, + row.description, + row.record_json, + now + ], + ) + .map_err(|source| Error::store("writing an index row", source))?; + } + + transaction + .execute( + "UPDATE mcp_registry_index_state SET cursor = ?2, pages = pages + 1 + WHERE source = ?1", + params![source, next_cursor], + ) + .map_err(|source| Error::store("recording an index page", source))?; + + transaction + .commit() + .map_err(|source| Error::store("committing an index page", source)) + } + + /// Finishes the sync of `source`, dropping every server it did not see. + /// + /// # Errors + /// + /// Returns [`Error::Store`] when a statement fails. + pub(crate) fn finish_index_sync(&self, source: &str) -> Result<()> { + let mut connection = self.connection.lock(); + let transaction = connection + .transaction() + .map_err(|source| Error::store("finishing an index sync", source))?; + + transaction + .execute( + "DELETE FROM mcp_registry_index WHERE source = ?1 AND synced_at < ( + SELECT started_at FROM mcp_registry_index_state WHERE source = ?1 + )", + params![source], + ) + .map_err(|source| Error::store("pruning the index", source))?; + transaction + .execute( + "UPDATE mcp_registry_index_state + SET cursor = NULL, pages = 0, started_at = NULL, synced_at = ?2 + WHERE source = ?1", + params![source, now_ms()], + ) + .map_err(|source| Error::store("recording a finished index sync", source))?; + + transaction + .commit() + .map_err(|source| Error::store("committing a finished index sync", source)) + } + + /// Every indexed server of `source` matching each of `terms` in its label + /// or description. + /// + /// The terms are matched as given, so a caller lowercases them to match + /// the stored columns. + /// + /// # Errors + /// + /// Returns [`Error::Store`] when the query fails. + pub(crate) fn search_index(&self, source: &str, terms: &[String]) -> Result> { + let mut sql = String::from( + "SELECT qualified_name, record_json, label FROM mcp_registry_index WHERE source = ?1", + ); + for position in 2..terms.len() + 2 { + // Writing into the buffer cannot fail. + let _ = write!( + sql, + " AND (instr(label, ?{position}) > 0 OR instr(description, ?{position}) > 0)" + ); + } + + let connection = self.connection.lock(); + let mut statement = connection + .prepare(&sql) + .map_err(|source| Error::store("preparing an index search", source))?; + let values = std::iter::once(source).chain(terms.iter().map(String::as_str)); + + statement + .query_map(params_from_iter(values), |row| { + let label: String = row.get(2)?; + Ok(IndexHit { + qualified_name: row.get(0)?, + in_label: terms.iter().all(|term| label.contains(term.as_str())), + record_json: row.get(1)?, + }) + }) + .and_then(Iterator::collect) + .map_err(|source| Error::store("searching the index", source)) + } +} + +#[cfg(test)] +#[path = "index_tests.rs"] +mod tests; diff --git a/crates/tinymcp/src/registry/store/index_tests.rs b/crates/tinymcp/src/registry/store/index_tests.rs new file mode 100644 index 0000000..0e303a8 --- /dev/null +++ b/crates/tinymcp/src/registry/store/index_tests.rs @@ -0,0 +1,194 @@ +//! Unit tests for the catalog index tables. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +const SOURCE: &str = "mcp_official"; +const BASE: &str = "https://registry.test"; + +fn store() -> Store { + Store::open_in_memory().unwrap() +} + +fn row(name: &str, label: &str, description: &str) -> IndexRow { + IndexRow { + qualified_name: name.to_string(), + label: label.to_string(), + description: description.to_string(), + record_json: format!("{{\"name\":\"{name}\"}}"), + } +} + +fn terms(words: &[&str]) -> Vec { + words.iter().map(ToString::to_string).collect() +} + +fn names(hits: &[IndexHit]) -> Vec<&str> { + let mut names: Vec<&str> = hits.iter().map(|hit| hit.qualified_name.as_str()).collect(); + names.sort_unstable(); + names +} + +/// Moves every timestamp of the index back by a day. +fn age(store: &Store) { + store.with_connection(|connection| { + connection + .execute_batch( + "UPDATE mcp_registry_index SET synced_at = synced_at - 86400000; + UPDATE mcp_registry_index_state SET synced_at = synced_at - 86400000;", + ) + .unwrap(); + }); +} + +#[test] +fn a_store_with_no_sync_has_no_index_state() { + assert_eq!(store().index_state(SOURCE).unwrap(), None); +} + +#[test] +fn a_sync_records_each_page_and_its_cursor() { + let store = store(); + store.begin_index_sync(SOURCE, BASE).unwrap(); + store + .store_index_page(SOURCE, &[row("a/one", "a/one one", "first")], Some("2")) + .unwrap(); + + let state = store.index_state(SOURCE).unwrap().unwrap(); + assert_eq!(state.base_url, BASE); + assert_eq!(state.cursor.as_deref(), Some("2")); + assert_eq!(state.pages, 1); + assert!(state.in_progress()); + assert_eq!(state.synced_at, None); +} + +#[test] +fn finishing_a_sync_records_when_and_clears_the_resume_point() { + let store = store(); + store.begin_index_sync(SOURCE, BASE).unwrap(); + store + .store_index_page(SOURCE, &[row("a/one", "a/one", "")], None) + .unwrap(); + store.finish_index_sync(SOURCE).unwrap(); + + let state = store.index_state(SOURCE).unwrap().unwrap(); + assert!(!state.in_progress()); + assert!(state.synced_at.is_some()); + assert_eq!(state.cursor, None); + assert_eq!(state.pages, 0); +} + +#[test] +fn finishing_a_sync_drops_servers_it_did_not_see_again() { + let store = store(); + store.begin_index_sync(SOURCE, BASE).unwrap(); + store + .store_index_page( + SOURCE, + &[row("a/kept", "kept", ""), row("a/gone", "gone", "")], + None, + ) + .unwrap(); + store.finish_index_sync(SOURCE).unwrap(); + age(&store); + + store.begin_index_sync(SOURCE, BASE).unwrap(); + store + .store_index_page(SOURCE, &[row("a/kept", "kept", "")], None) + .unwrap(); + assert_eq!( + names(&store.search_index(SOURCE, &[]).unwrap()), + ["a/gone", "a/kept"], + "an unfinished sync keeps the earlier rows searchable" + ); + + store.finish_index_sync(SOURCE).unwrap(); + assert_eq!(names(&store.search_index(SOURCE, &[]).unwrap()), ["a/kept"]); +} + +#[test] +fn restarting_on_the_same_catalog_keeps_the_last_finished_sync() { + let store = store(); + store.begin_index_sync(SOURCE, BASE).unwrap(); + store.finish_index_sync(SOURCE).unwrap(); + + store.begin_index_sync(SOURCE, BASE).unwrap(); + + assert!( + store + .index_state(SOURCE) + .unwrap() + .unwrap() + .synced_at + .is_some() + ); +} + +#[test] +fn a_sync_of_a_different_catalog_drops_the_old_rows() { + let store = store(); + store.begin_index_sync(SOURCE, BASE).unwrap(); + store + .store_index_page(SOURCE, &[row("a/one", "one", "")], None) + .unwrap(); + store.finish_index_sync(SOURCE).unwrap(); + + store + .begin_index_sync(SOURCE, "https://elsewhere.test") + .unwrap(); + + let state = store.index_state(SOURCE).unwrap().unwrap(); + assert_eq!(state.base_url, "https://elsewhere.test"); + assert_eq!(state.synced_at, None); + assert_eq!( + names(&store.search_index(SOURCE, &[]).unwrap()), + Vec::<&str>::new() + ); +} + +#[test] +fn a_search_needs_every_term_in_the_label_or_the_description() { + let store = store(); + store.begin_index_sync(SOURCE, BASE).unwrap(); + store + .store_index_page( + SOURCE, + &[ + row("a/slack", "a/slack slack", "post messages"), + row("a/chat", "a/chat chat", "a slack bridge"), + row("a/mail", "a/mail mail", "send email"), + ], + None, + ) + .unwrap(); + + let hits = store.search_index(SOURCE, &terms(&["slack"])).unwrap(); + assert_eq!(names(&hits), ["a/chat", "a/slack"]); + + let in_label: Vec = { + let mut hits = hits; + hits.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name)); + hits.iter().map(|hit| hit.in_label).collect() + }; + assert_eq!(in_label, [false, true]); + + let both = store + .search_index(SOURCE, &terms(&["slack", "messages"])) + .unwrap(); + assert_eq!(names(&both), ["a/slack"]); +} + +#[test] +fn a_search_is_scoped_to_its_source() { + let store = store(); + store.begin_index_sync("other", BASE).unwrap(); + store + .store_index_page("other", &[row("a/one", "one", "")], None) + .unwrap(); + + assert_eq!( + names(&store.search_index(SOURCE, &terms(&["one"])).unwrap()), + Vec::<&str>::new() + ); +} diff --git a/crates/tinymcp/src/registry/store/mod.rs b/crates/tinymcp/src/registry/store/mod.rs index 01e820a..33d4818 100644 --- a/crates/tinymcp/src/registry/store/mod.rs +++ b/crates/tinymcp/src/registry/store/mod.rs @@ -1,6 +1,6 @@ //! Persistence for installed servers, their credentials, and the browse cache. //! -//! Four tables in one `SQLite` file, `mcp_clients/mcp_clients.db` under the data +//! Six tables in one `SQLite` file, `mcp_clients/mcp_clients.db` under the data //! directory the host supplies: //! //! | Table | Holds | @@ -9,6 +9,8 @@ //! | `mcp_client_env` | the credential values, keyed by server and name | //! | `mcp_registry_cache` | upstream browse responses, with a timestamp | //! | `mcp_tool_cache` | each server's last advertised tool list, keyed by a definition fingerprint | +//! | `mcp_registry_index` | the local copy of the official catalog, one row per server | +//! | `mcp_registry_index_state` | where that copy's sync stands | //! //! The filename and schema are unchanged from the code this was extracted //! from, so a user upgrading across the move keeps every server they installed. @@ -33,6 +35,7 @@ //! //! [`InstalledServer`]: tinymcp_bus::InstalledServer +pub(crate) mod index; pub(crate) mod schema; mod tool_cache; mod types; @@ -42,6 +45,7 @@ pub(crate) use tool_cache::fingerprint; pub(crate) use tool_cache::hex; pub use tool_cache::{CachedTools, installed_fingerprint, static_cache_key}; pub use types::Store; +pub(crate) use types::now_ms; #[cfg(test)] #[path = "mod_tests.rs"] diff --git a/crates/tinymcp/src/registry/store/schema.rs b/crates/tinymcp/src/registry/store/schema.rs index e983bcc..1ec6dc9 100644 --- a/crates/tinymcp/src/registry/store/schema.rs +++ b/crates/tinymcp/src/registry/store/schema.rs @@ -49,6 +49,25 @@ pub(super) fn initialize(connection: &Connection) -> Result<()> { fingerprint TEXT NOT NULL, tools_json TEXT NOT NULL, cached_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS mcp_registry_index ( + source TEXT NOT NULL, + qualified_name TEXT NOT NULL, + label TEXT NOT NULL, + description TEXT NOT NULL, + record_json TEXT NOT NULL, + synced_at INTEGER NOT NULL, + PRIMARY KEY (source, qualified_name) + ); + + CREATE TABLE IF NOT EXISTS mcp_registry_index_state ( + source TEXT PRIMARY KEY, + base_url TEXT NOT NULL, + cursor TEXT, + pages INTEGER NOT NULL DEFAULT 0, + started_at INTEGER, + synced_at INTEGER );", ) .map_err(|source| Error::store("creating the schema", source))?; diff --git a/crates/tinymcp/src/registry/store/types.rs b/crates/tinymcp/src/registry/store/types.rs index 953d97b..052b94e 100644 --- a/crates/tinymcp/src/registry/store/types.rs +++ b/crates/tinymcp/src/registry/store/types.rs @@ -586,7 +586,7 @@ fn decode_column( /// A clock set before the epoch reads as zero rather than failing. Nothing here /// makes a decision that a wrong timestamp could make unsafe: the worst case is /// a cache entry that looks stale. -pub(super) fn now_ms() -> i64 { +pub(crate) fn now_ms() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .ok() From f9f3820e71bb378e3af1f4f7615ef732eb0825b2 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 22:56:43 +0530 Subject: [PATCH 19/27] docs: describe the local registry index and curated server entries (#42) --- README.md | 20 ++++++++++++++++++++ ROADMAP.md | 3 +++ 2 files changed, 23 insertions(+) diff --git a/README.md b/README.md index 52f7698..a55b598 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,26 @@ when a key is configured. 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. 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, 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`. ## `mcp.json` and OAuth for hosts with their own store diff --git a/ROADMAP.md b/ROADMAP.md index 2e6a6ec..8eb66a9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,6 +23,9 @@ out of scope. A roadmap that lists everything is a roadmap nobody trusts. 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 From 718449547420b4bd5342b32a3311eb3e8cea1350 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 23:17:28 +0530 Subject: [PATCH 20/27] fix(registry): match curated servers when search falls back (#42) --- .../src/registry/sources/official/fallback.rs | 7 +- .../registry/sources/official/mod_tests.rs | 87 +++++++++++++++---- 2 files changed, 76 insertions(+), 18 deletions(-) diff --git a/crates/tinymcp/src/registry/sources/official/fallback.rs b/crates/tinymcp/src/registry/sources/official/fallback.rs index abe38f2..48eab07 100644 --- a/crates/tinymcp/src/registry/sources/official/fallback.rs +++ b/crates/tinymcp/src/registry/sources/official/fallback.rs @@ -18,6 +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::sources::types::{RegistryOperation, SourcePage}; use tinymcp_bus::{RegistryFreshness, RegistryServerSummary}; @@ -154,9 +155,12 @@ fn local_matches(store: &Store, query: &str, limit: u32) -> Vec = page_rows .chain(detail_rows) + .chain(curated_rows) .filter(|row| seen.insert(row.qualified_name.clone())) .filter_map(|row| { let label = format!("{} {}", row.qualified_name, row.display_name).to_lowercase(); @@ -173,7 +177,8 @@ fn local_matches(store: &Store, query: &str, limit: u32) -> Vec Date: Wed, 7 Oct 2026 23:17:28 +0530 Subject: [PATCH 21/27] docs: note that search fallback matches curated servers (#42) --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a55b598..1a92588 100644 --- a/README.md +++ b/README.md @@ -222,8 +222,9 @@ when a key is configured. 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, cached catalog rows and cached server - details matching every word of the query (`RegistryFreshness::LocalFallback`). + 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 From 0c2a70a15874d00250c622ced86ebe20a41da8b4 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 23:23:20 +0530 Subject: [PATCH 22/27] fix(registry): pause an index sync at its page limit instead of finishing it (#42) --- .../src/registry/sources/official/index.rs | 28 +++++--- .../registry/sources/official/index_tests.rs | 65 +++++++++++++++++-- crates/tinymcp/src/registry/sources/types.rs | 3 +- 3 files changed, 80 insertions(+), 16 deletions(-) diff --git a/crates/tinymcp/src/registry/sources/official/index.rs b/crates/tinymcp/src/registry/sources/official/index.rs index 0bf5ad7..240c0a0 100644 --- a/crates/tinymcp/src/registry/sources/official/index.rs +++ b/crates/tinymcp/src/registry/sources/official/index.rs @@ -8,10 +8,12 @@ //! # Syncing //! //! A sync walks `/v0/servers?version=latest` by cursor until the cursor runs -//! out or [`RegistryIndexSettings::max_pages`] is reached, writing each page as -//! it arrives. Each page has the browse budget. A page that fails stops the -//! sync with what it wrote kept, and the next sync resumes from the cursor it -//! stopped at; until then the next one waits out the registry cooldown. +//! out, writing each page as it arrives. Each page has the browse budget. One +//! run reads at most [`RegistryIndexSettings::max_pages`] pages and then +//! pauses; the next search or browse resumes it from the stored cursor. A page +//! that fails stops the sync with what it wrote kept, and the next sync resumes +//! from the cursor it stopped at once the registry cooldown has passed. Only a +//! sync whose cursor ran out is finished. //! //! A search or a browse starts a sync when none has finished for the //! configured catalog, when one was left unfinished, or when the last one is @@ -166,7 +168,8 @@ impl OfficialIndex { /// Syncs the index, resuming an unfinished sync of the same catalog. /// - /// Returns how many pages the finished sync holds. + /// Returns how many pages the sync holds so far, finished or paused at the + /// page limit. /// /// # Errors /// @@ -190,14 +193,15 @@ impl OfficialIndex { (None, 0) }; let mut finished = pages > 0 && cursor.is_none(); + let mut fetched: u32 = 0; while !finished { - if pages >= self.settings.max_pages { - tracing::warn!( + if fetched >= self.settings.max_pages { + tracing::debug!( pages, - "official catalog index sync reached its page limit; keeping what it has" + "official catalog index sync paused at its page limit; a later request resumes it" ); - break; + return Ok(pages); } let body = self.fetch(auth, cursor.as_deref()).await?; @@ -214,9 +218,15 @@ impl OfficialIndex { .and_then(Value::as_str) .filter(|next| !next.is_empty()) .map(ToString::to_string); + if next.is_some() && next == cursor { + return Err(Error::malformed( + "official list response repeats the cursor it was asked for", + )); + } store.store_index_page(INDEX_SOURCE, &index_rows(&document), next.as_deref())?; pages += 1; + fetched += 1; finished = next.is_none(); cursor = next; } diff --git a/crates/tinymcp/src/registry/sources/official/index_tests.rs b/crates/tinymcp/src/registry/sources/official/index_tests.rs index 60fbfd3..b91599e 100644 --- a/crates/tinymcp/src/registry/sources/official/index_tests.rs +++ b/crates/tinymcp/src/registry/sources/official/index_tests.rs @@ -27,6 +27,8 @@ struct Upstream { failing_page: AtomicUsize, /// Answer every page with a body that is not a list. garbled: AtomicBool, + /// Answer every page with a next cursor naming that same page. + repeat_cursor: AtomicBool, /// How long each listing waits before answering. delay: Duration, requests: AtomicUsize, @@ -108,7 +110,9 @@ async fn serve(upstream: Upstream) -> (String, Arc) { let servers = upstream.pages.get(page - 1).cloned().unwrap_or_default(); let mut body = json!({ "servers": servers }); - if page < upstream.pages.len() { + if upstream.repeat_cursor.load(Ordering::SeqCst) { + body["metadata"] = json!({ "nextCursor": page.to_string() }); + } else if page < upstream.pages.len() { body["metadata"] = json!({ "nextCursor": (page + 1).to_string() }); } axum::Json(body).into_response() @@ -324,7 +328,7 @@ async fn a_sync_resumed_after_its_last_page_was_written_just_finishes() { } #[tokio::test] -async fn a_sync_stops_at_the_page_limit_and_keeps_what_it_has() { +async fn a_sync_pauses_at_the_page_limit_and_the_next_run_resumes_it() { let (base, upstream) = serve(Upstream { pages: numbered_pages(5), ..Upstream::default() @@ -339,14 +343,63 @@ async fn a_sync_stops_at_the_page_limit_and_keeps_what_it_has() { Duration::from_secs(60), ); - let pages = index.sync(&store, &auth_at(&base)).await.unwrap(); - - assert_eq!(pages, 2); - assert_eq!(upstream.requests(), 2); + assert_eq!(index.sync(&store, &auth_at(&base)).await.unwrap(), 2); assert_eq!(indexed_names(&store).len(), 2); + assert!(!is_ready(&store, &base), "a paused sync is not finished"); + + assert_eq!(index.sync(&store, &auth_at(&base)).await.unwrap(), 4); + assert!(!is_ready(&store, &base)); + + assert_eq!(index.sync(&store, &auth_at(&base)).await.unwrap(), 5); + assert_eq!(upstream.requests(), 5, "no page is read twice"); + assert_eq!(indexed_names(&store).len(), 5); assert!(is_ready(&store, &base)); } +#[tokio::test] +async fn a_cursor_that_repeats_stops_the_sync_as_malformed() { + let (base, upstream) = serve(Upstream { + pages: numbered_pages(3), + repeat_cursor: AtomicBool::new(true), + ..Upstream::default() + }) + .await; + let store = store(); + + let error = index().sync(&store, &auth_at(&base)).await.unwrap_err(); + + assert!( + matches!(error, Error::MalformedResponse { .. }), + "{error:?}" + ); + assert_eq!(upstream.requests(), 2); + assert!(!is_ready(&store, &base)); +} + +#[tokio::test] +async fn a_refresh_paused_at_the_page_limit_prunes_nothing() { + let (base, _upstream) = serve(Upstream { + pages: numbered_pages(3), + ..Upstream::default() + }) + .await; + let store = store(); + synced(&base, &store).await; + let before = indexed_names(&store).len(); + store.begin_index_sync(INDEX_SOURCE, &base).unwrap(); + let index = index_with( + RegistryIndexSettings { + max_pages: 1, + ..RegistryIndexSettings::default() + }, + Duration::from_secs(60), + ); + + index.sync(&store, &auth_at(&base)).await.unwrap(); + + assert_eq!(indexed_names(&store).len(), before); +} + #[tokio::test] async fn a_page_that_is_not_a_list_is_malformed_and_writes_nothing() { let (base, upstream) = serve(Upstream { diff --git a/crates/tinymcp/src/registry/sources/types.rs b/crates/tinymcp/src/registry/sources/types.rs index 0a73909..8507c51 100644 --- a/crates/tinymcp/src/registry/sources/types.rs +++ b/crates/tinymcp/src/registry/sources/types.rs @@ -118,7 +118,8 @@ pub struct RegistryIndexSettings { /// How old the last finished sync may be before a search or browse starts /// another in the background. pub refresh: Duration, - /// The most pages one sync reads before it stops and keeps what it has. + /// The most pages one background run reads before it pauses. The next + /// search or browse resumes the sync from where it paused. pub max_pages: u32, /// How many servers each page asks for. pub page_size: u32, From 343ff51354408081c2edf7a8902165e03dd90330 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Wed, 7 Oct 2026 23:23:20 +0530 Subject: [PATCH 23/27] docs: describe how an index sync pauses and resumes at its page limit (#42) --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1a92588..eaa0934 100644 --- a/README.md +++ b/README.md @@ -237,8 +237,11 @@ when a key is configured. (`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. 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 + 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. From 12451a69d2a89fd8d4ec7925c22ce82694a62798 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Thu, 8 Oct 2026 00:49:17 +0530 Subject: [PATCH 24/27] fix(registry): lead a first search page with the curated servers it matches (#42) A live or cached search answered by the registry left out curated servers the registry does not list, so searching "slack" before the local index finished never showed Slack's own server. The first page now leads with every curated server matching the query, keeping the registry's row when it has one and staying within the page size. --- .../src/registry/sources/official/mod.rs | 57 ++++++++++++++- .../registry/sources/official/mod_tests.rs | 69 ++++++++++++++++++- 2 files changed, 121 insertions(+), 5 deletions(-) diff --git a/crates/tinymcp/src/registry/sources/official/mod.rs b/crates/tinymcp/src/registry/sources/official/mod.rs index 12867d6..5ad5191 100644 --- a/crates/tinymcp/src/registry/sources/official/mod.rs +++ b/crates/tinymcp/src/registry/sources/official/mod.rs @@ -62,8 +62,10 @@ use super::types::{ }; use crate::error::{Error, Result}; use crate::registry::Store; -use crate::registry::curation::curated_server; -use tinymcp_bus::{McpRegistryAuthConfig, RegistryFreshness, RegistryServerDetail}; +use crate::registry::curation::{CURATED_SERVERS, curated_server}; +use tinymcp_bus::{ + McpRegistryAuthConfig, RegistryFreshness, RegistryServerDetail, RegistryServerSummary, +}; /// Where the registry lives when nothing overrides it. const DEFAULT_BASE: &str = "https://registry.modelcontextprotocol.io"; @@ -166,6 +168,26 @@ impl McpOfficialRegistry { return Ok(found); } + let mut found = self + .search_registry(store, auth, cursors, query, page, page_size) + .await?; + if page == 1 { + lead_with_curated(&mut found.servers, query, page_size); + } + Ok(found) + } + + /// Searches without the index: the cache, then the registry, then the + /// fallback. + async fn search_registry( + &self, + store: &Store, + auth: &McpRegistryAuthConfig, + cursors: &CursorCache, + query: &str, + page: u32, + page_size: u32, + ) -> Result { let cache_key = search_cache_key(query, page, page_size); if let Ok(Some(cached)) = store.cached(&cache_key) @@ -459,6 +481,37 @@ 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) { + let terms: Vec = query.split_whitespace().map(str::to_lowercase).collect(); + if terms.is_empty() { + return; + } + + let mut leading: Vec = CURATED_SERVERS + .iter() + .filter(|curated| { + let text = format!( + "{} {} {}", + curated.qualified_name, curated.display_name, curated.description + ) + .to_lowercase(); + terms.iter().all(|term| text.contains(term.as_str())) + }) + .map(|curated| { + servers + .iter() + .position(|row| row.qualified_name == curated.qualified_name) + .map_or_else(|| curated.to_summary(), |at| servers.remove(at)) + }) + .collect(); + leading.append(servers); + leading.truncate(usize::try_from(page_size.max(1)).unwrap_or(usize::MAX)); + *servers = leading; +} + /// The list endpoint. fn list_url(auth: &McpRegistryAuthConfig) -> String { format!("{}/v0/servers", base_url(auth)) diff --git a/crates/tinymcp/src/registry/sources/official/mod_tests.rs b/crates/tinymcp/src/registry/sources/official/mod_tests.rs index 675e553..12e60ea 100644 --- a/crates/tinymcp/src/registry/sources/official/mod_tests.rs +++ b/crates/tinymcp/src/registry/sources/official/mod_tests.rs @@ -11,7 +11,7 @@ use serde_json::{Value, json}; use super::types::{OfficialListResponse, OfficialServer}; -use super::{page_bound, search_cache_key}; +use super::{lead_with_curated, page_bound, search_cache_key}; /// A list response wrapping `servers`. fn list_response(servers: &Value, next_cursor: Option<&str>) -> OfficialListResponse { @@ -1989,12 +1989,12 @@ async fn the_network_is_tried_again_once_the_cooldown_ends() { state.set(UP); let page = adapter - .search(&store, &auth, &cursors(), "notion", 1, 20) + .search(&store, &auth, &cursors(), "acme", 1, 20) .await .expect("the registry answers again"); assert_eq!(page.freshness, RegistryFreshness::Live); - assert_eq!(page.servers[0].qualified_name, "@acme/notion"); + assert_eq!(page.servers[0].qualified_name, "@acme/acme"); } #[tokio::test] @@ -2304,3 +2304,66 @@ fn local_matches_from_details_are_capped_at_the_page_size() { assert_eq!(page.servers.len(), 2); } + +fn summaries(names: &[&str]) -> Vec { + list_response( + &Value::Array(names.iter().map(|name| envelope(name)).collect()), + None, + ) + .into_summaries() +} + +fn names(rows: &[tinymcp_bus::RegistryServerSummary]) -> Vec<&str> { + rows.iter().map(|row| row.qualified_name.as_str()).collect() +} + +#[tokio::test] +async fn a_live_search_leads_with_the_curated_server_the_registry_left_out() { + let (base, _state) = switchable_registry().await; + + let page = adapter() + .search(&store(), &auth_at(&base), &cursors(), "slack", 1, 20) + .await + .expect("the registry answers"); + + assert_eq!(page.freshness, RegistryFreshness::Live); + assert_eq!(names_of(&page), ["com.slack/mcp", "@acme/slack"]); +} + +#[test] +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); + + assert_eq!(names(&rows), ["com.notion/mcp", "@acme/notion"]); + assert_eq!(rows[0].display_name, "From the registry"); +} + +#[test] +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); + + assert_eq!(names(&rows), ["com.slack/mcp", "@acme/slack"]); +} + +#[test] +fn a_blank_query_leaves_the_page_alone() { + let mut rows = summaries(&["@acme/one", "@acme/two"]); + + lead_with_curated(&mut rows, " ", 20); + + assert_eq!(names(&rows), ["@acme/one", "@acme/two"]); +} + +#[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); + + assert_eq!(names(&rows), ["@acme/one"]); +} From a09cc02ffad2339bb0cb03de58d84a6f45adda7f Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Thu, 8 Oct 2026 00:49:17 +0530 Subject: [PATCH 25/27] docs: note that curated servers lead every first search page (#42) --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index eaa0934..115baf9 100644 --- a/README.md +++ b/README.md @@ -248,8 +248,9 @@ when a key is configured. - **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, and its detail comes from the entry when the registry - cannot describe it. Slack's server (`com.slack/mcp`) is not in the registry + 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`. From b124c88a0525dd07f704f0e1bb8d5294c4080e6a Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Fri, 9 Oct 2026 13:12:58 +0530 Subject: [PATCH 26/27] feat(registry): curate Swiggy's Food, Instamart, Dineout and Scenes servers --- .../src/registry/curation/mod_tests.rs | 24 ++++++++++ .../tinymcp/src/registry/curation/servers.rs | 47 +++++++++++++++++-- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/crates/tinymcp/src/registry/curation/mod_tests.rs b/crates/tinymcp/src/registry/curation/mod_tests.rs index c9b355e..c55b1e4 100644 --- a/crates/tinymcp/src/registry/curation/mod_tests.rs +++ b/crates/tinymcp/src/registry/curation/mod_tests.rs @@ -326,6 +326,30 @@ fn slack_is_curated_as_oauth_for_preregistered_clients() { assert_eq!(slack.auth, CuratedAuth::OauthPreregistered); } +#[test] +fn swiggy_curates_its_four_servers_as_oauth() { + let swiggy: Vec<(&str, &str)> = CURATED_SERVERS + .iter() + .filter(|server| server.qualified_name.starts_with("com.swiggy/")) + .map(|server| (server.qualified_name, server.remote_url)) + .collect(); + + assert_eq!( + swiggy, + [ + ("com.swiggy/food", "https://mcp.swiggy.com/food"), + ("com.swiggy/instamart", "https://mcp.swiggy.com/im"), + ("com.swiggy/dineout", "https://mcp.swiggy.com/dineout"), + ("com.swiggy/scenes", "https://mcp.swiggy.com/scenes"), + ] + ); + for (name, _) in swiggy { + let server = curated_server(name).expect("curated"); + assert_eq!(server.transport, CuratedTransport::StreamableHttp); + assert_eq!(server.auth, CuratedAuth::Oauth); + } +} + #[test] fn a_curated_row_is_attributed_to_the_official_registry() { let row = curated_server("com.supabase/mcp").unwrap().to_summary(); diff --git a/crates/tinymcp/src/registry/curation/servers.rs b/crates/tinymcp/src/registry/curation/servers.rs index fd1629b..2b78804 100644 --- a/crates/tinymcp/src/registry/curation/servers.rs +++ b/crates/tinymcp/src/registry/curation/servers.rs @@ -1,9 +1,10 @@ //! The curated first-party servers. //! -//! Every entry but Slack was checked against what the official registry -//! publishes for that exact name: the hosted endpoint, its transport, and the -//! headers it declares. Slack publishes no registry entry; its endpoint and -//! client rules come from Slack's own developer documentation. An entry here is +//! Every entry but Slack and Swiggy was checked against what the official +//! registry publishes for that exact name: the hosted endpoint, its transport, +//! and the headers it declares. Slack and Swiggy publish no registry entry; +//! their endpoints and client rules come from each vendor's own developer +//! documentation. An entry here is //! a claim made to the user, so extend the list only from a vendor's own //! publication. @@ -142,4 +143,42 @@ pub const CURATED_SERVERS: &[CuratedServer] = &[ auth: CuratedAuth::OauthPreregistered, icon_url: None, }, + CuratedServer { + qualified_name: "com.swiggy/food", + display_name: "Swiggy Food", + description: "Official Swiggy MCP server for restaurant discovery, menus, food ordering \ + and order tracking", + remote_url: "https://mcp.swiggy.com/food", + transport: CuratedTransport::StreamableHttp, + auth: CuratedAuth::Oauth, + icon_url: None, + }, + CuratedServer { + qualified_name: "com.swiggy/instamart", + display_name: "Swiggy Instamart", + description: "Official Swiggy MCP server for Instamart quick-commerce grocery shopping", + remote_url: "https://mcp.swiggy.com/im", + transport: CuratedTransport::StreamableHttp, + auth: CuratedAuth::Oauth, + icon_url: None, + }, + CuratedServer { + qualified_name: "com.swiggy/dineout", + display_name: "Swiggy Dineout", + description: "Official Swiggy MCP server for restaurant table reservations", + remote_url: "https://mcp.swiggy.com/dineout", + transport: CuratedTransport::StreamableHttp, + auth: CuratedAuth::Oauth, + icon_url: None, + }, + CuratedServer { + qualified_name: "com.swiggy/scenes", + display_name: "Swiggy Scenes", + description: "Official Swiggy MCP server for discovering events and shows and booking \ + tickets", + remote_url: "https://mcp.swiggy.com/scenes", + transport: CuratedTransport::StreamableHttp, + auth: CuratedAuth::Oauth, + icon_url: None, + }, ]; From efa2a6d3e2cec7902cf1b1a2a0d3092eb08ec40e Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Fri, 9 Oct 2026 13:12:58 +0530 Subject: [PATCH 27/27] docs: note Swiggy's curated servers and their redirect allowlist --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 115baf9..2ed83d5 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,11 @@ when a key is configured. 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`. + 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