From 312d38bdc11c486a100a630a4e01ea9a2f65b51f Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 2 Oct 2026 16:54:14 +0300 Subject: [PATCH 1/5] fix(catalog): rank by match tier and search the live registry's owner and name-prefix queries (Spec fix-catalog-rank) The official registry's search matches server.name only, in byte order, 100 per page, so a typed query never reached the real server (audit C1). Rank now puts a name-match tier first, a typed query fetches the owner and name-prefix queries too and ranks before truncating, verified means the publisher owns the repository, GitHub stars count only for the publisher's own repo, and a fetch that outlives the 5 s budget warms the listing cache in the background. --- cmd/mcpproxy/catalog_cmd.go | 4 +- cmd/mcpproxy/catalog_order_parity_test.go | 20 +- .../httpapi/spec109_catalog_order_test.go | 57 +- internal/registries/catalog.go | 351 +- internal/registries/catalog_bench_test.go | 5 +- .../registries/catalog_hit_signals_test.go | 149 + internal/registries/catalog_rank_tier_test.go | 108 + internal/registries/catalog_typed_test.go | 274 + internal/registries/catalog_warm_behind.go | 148 + .../registries/catalog_warm_behind_test.go | 200 + internal/registries/listing_cache.go | 3 +- internal/registries/official.go | 246 +- internal/registries/official_catalog_test.go | 255 + internal/registries/rank_test.go | 23 - internal/registries/recorded_registry_test.go | 371 ++ internal/registries/search.go | 112 +- internal/registries/search_catalog_test.go | 113 + .../testdata/catalog_github_order.json | 4630 ++++++++++++++++- internal/registries/testhooks.go | 95 + internal/registries/types.go | 12 + internal/server/spec109_catalog_order_test.go | 26 +- 21 files changed, 6995 insertions(+), 207 deletions(-) create mode 100644 internal/registries/catalog_hit_signals_test.go create mode 100644 internal/registries/catalog_rank_tier_test.go create mode 100644 internal/registries/catalog_typed_test.go create mode 100644 internal/registries/catalog_warm_behind.go create mode 100644 internal/registries/catalog_warm_behind_test.go create mode 100644 internal/registries/official_catalog_test.go create mode 100644 internal/registries/recorded_registry_test.go create mode 100644 internal/registries/search_catalog_test.go diff --git a/cmd/mcpproxy/catalog_cmd.go b/cmd/mcpproxy/catalog_cmd.go index a35e0c31b..95214c741 100644 --- a/cmd/mcpproxy/catalog_cmd.go +++ b/cmd/mcpproxy/catalog_cmd.go @@ -82,8 +82,8 @@ func newCatalogSearchCmd() *cobra.Command { cmd := &cobra.Command{ Use: "search [query]", Short: "Search the catalog across every enabled source", - Long: `Search every enabled catalog source at once (FR-060), ranked official-first, -then verified, then popularity, then text relevance. Omit the query to browse + Long: `Search every enabled catalog source at once (FR-060), ranked by how well the name +matches, then official source, verified publisher and popularity. Omit the query to browse the curated "official" and "popular" sections instead.`, Args: cobra.MaximumNArgs(1), RunE: func(_ *cobra.Command, args []string) error { diff --git a/cmd/mcpproxy/catalog_order_parity_test.go b/cmd/mcpproxy/catalog_order_parity_test.go index f9427f895..aa06476cf 100644 --- a/cmd/mcpproxy/catalog_order_parity_test.go +++ b/cmd/mcpproxy/catalog_order_parity_test.go @@ -25,6 +25,8 @@ type p109CatalogOrderFile struct { ID string `json:"id"` Name string `json:"name"` Provenance string `json:"provenance"` + Protocol string `json:"protocol"` + Corpus []json.RawMessage `json:"corpus"` Servers []json.RawMessage `json:"servers"` } `json:"sources"` IDs []string `json:"ids"` @@ -45,13 +47,19 @@ func TestCatalogOrderParityCLI(t *testing.T) { var entries []registries.RegistryEntry for _, src := range f.Sources { - body, _ := json.Marshal(src.Servers) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(body) - })) + var h http.Handler + if src.Protocol == "modelcontextprotocol/registry" { + h = registries.RecordedRegistryHandlerForTest(src.Corpus) + } else { + body, _ := json.Marshal(src.Servers) + h = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }) + } + srv := httptest.NewServer(h) t.Cleanup(srv.Close) - entries = append(entries, registries.RegistryEntry{ID: src.ID, Name: src.Name, ServersURL: srv.URL, Provenance: src.Provenance}) + entries = append(entries, registries.RegistryEntry{ID: src.ID, Name: src.Name, ServersURL: srv.URL + "/v0.1/servers", Protocol: src.Protocol, Provenance: src.Provenance}) } t.Cleanup(registries.AllowPrivateRegistryFetchForTest()) t.Cleanup(registries.SetRegistriesForTest(entries)) diff --git a/internal/httpapi/spec109_catalog_order_test.go b/internal/httpapi/spec109_catalog_order_test.go index 81717ac66..ae2f740c1 100644 --- a/internal/httpapi/spec109_catalog_order_test.go +++ b/internal/httpapi/spec109_catalog_order_test.go @@ -22,19 +22,25 @@ import ( const p109CatalogOrderFixture = "internal/registries/testdata/catalog_github_order.json" +// A source is either flat (servers, the community fork) or registry-shaped +// (protocol + corpus: wrapped {server,_meta} items recorded from the live +// official registry and served by registries.RecordedRegistryHandlerForTest). type p109CatalogOrderSource struct { ID string `json:"id"` Name string `json:"name"` Provenance string `json:"provenance"` - Servers []json.RawMessage `json:"servers"` + Protocol string `json:"protocol,omitempty"` + Corpus []json.RawMessage `json:"corpus,omitempty"` + Servers []json.RawMessage `json:"servers,omitempty"` } type p109CatalogOrderFile struct { - Comment string `json:"_comment"` - Query string `json:"query"` - Sources []p109CatalogOrderSource `json:"sources"` - IDs []string `json:"ids"` - Results []registries.CatalogResult `json:"results"` + Comment string `json:"_comment"` + Recorded json.RawMessage `json:"recorded"` + Query string `json:"query"` + Sources []p109CatalogOrderSource `json:"sources"` + IDs []string `json:"ids"` + Results []registries.CatalogResult `json:"results"` } // p109InstallCatalogFixture serves each source from httptest and installs the @@ -43,14 +49,20 @@ func p109InstallCatalogFixture(t *testing.T, f p109CatalogOrderFile) { t.Helper() var entries []registries.RegistryEntry for _, src := range f.Sources { - body, err := json.Marshal(src.Servers) - require.NoError(t, err) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(body) - })) + var h http.Handler + if src.Protocol == "modelcontextprotocol/registry" { + h = registries.RecordedRegistryHandlerForTest(src.Corpus) + } else { + body, err := json.Marshal(src.Servers) + require.NoError(t, err) + h = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }) + } + srv := httptest.NewServer(h) t.Cleanup(srv.Close) - entries = append(entries, registries.RegistryEntry{ID: src.ID, Name: src.Name, ServersURL: srv.URL, Provenance: src.Provenance}) + entries = append(entries, registries.RegistryEntry{ID: src.ID, Name: src.Name, ServersURL: srv.URL + "/v0.1/servers", Protocol: src.Protocol, Provenance: src.Provenance}) } t.Cleanup(registries.AllowPrivateRegistryFetchForTest()) t.Cleanup(registries.SetRegistriesForTest(entries)) @@ -71,7 +83,7 @@ func TestCatalogOrderParity_RESTWritesTheGolden(t *testing.T) { ctrl := &scopeController{cfg: scopeFixtureConfig(false), servers: nil, withManagement: true} srv, _ := scopedAgentServer(t, ctrl, []string{"alpha"}) - rec := scopeGet(t, srv, "/api/v1/catalog/search?q="+f.Query, scopeAdminAPIKey) + rec := scopeGet(t, srv, "/api/v1/catalog/search?limit=20&q="+f.Query, scopeAdminAPIKey) require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) var body struct { Data struct { @@ -82,10 +94,25 @@ func TestCatalogOrderParity_RESTWritesTheGolden(t *testing.T) { got := body.Data.Results require.NotEmpty(t, got) - // SC-008: the official, verified GitHub server is first. + // SC-008: GitHub's own server, from the official registry, is first. It + // is Official (a built-in source, D36.6) and Verified (its publisher + // owns the repository, D36.5), titled by its own server.json title. assert.Equal(t, "official:io.github.github/github-mcp-server", got[0].Source+":"+got[0].ID) assert.True(t, got[0].Official) assert.True(t, got[0].Verified) + assert.Equal(t, "GitHub", got[0].Title) + assert.Equal(t, "github", got[0].Publisher) + + // Every id once, and no namespace-only match (io.github.06ketan/slideshot) + // anywhere in the 20: those are tier 0 and rank last. + seen := map[string]bool{} + for _, r := range got { + key := r.Source + ":" + r.ID + assert.False(t, seen[key], "%s repeats", key) + seen[key] = true + assert.NotContains(t, r.ID, "slideshot") + assert.NotEqual(t, "No description available", r.Description) + } golden := filepath.Join(p109Root(t), p109CatalogOrderFixture) if os.Getenv("UPDATE_GOLDEN") == "1" { diff --git a/internal/registries/catalog.go b/internal/registries/catalog.go index 343871b73..d9a94c59e 100644 --- a/internal/registries/catalog.go +++ b/internal/registries/catalog.go @@ -46,6 +46,14 @@ type CatalogHit struct { // the source's live fetch failed (Spec 109 D35); see listing_cache.go. FromCache bool Popularity *Popularity + + // starsBorrowed marks a hit whose GitHub stars must not be attributed to it + // (Spec 109 D36.7: "stars eligible" is its negation, so the zero value stays + // eligible for hand-built hits). An official-protocol entry may name ANY + // GitHub repo as its source, so stars only count when the publisher owns + // that repo (Verified); a hit that borrows another project's repo keeps only + // its source-native signal. Set by BuildCatalogHit; never marshalled. + starsBorrowed bool } // CatalogInstall is the REST/MCP install target: either a remote URL or a @@ -152,12 +160,19 @@ const ( defaultPopularityWait = 800 * time.Millisecond ) -// SearchAll fans SearchServers out to every enabled registry in parallel +// SearchAll fans the catalog fetch out to every enabled registry in parallel // (FR-060), each bounded by opts.SourceTimeout (default 5s), merges the // results, de-duplicates by (source, id), ranks them with Rank, and returns // unavailable[] for sources that failed or timed out. An empty q additionally // populates sections (official + popular, ≤ 12 each); a non-empty q leaves // sections nil. +// +// A typed q is fetched in full (searchCatalogSource, ≤ typedFetchCap per +// source) and ranked BEFORE it is truncated to `limit` (Spec 109 D36.3): the +// official registry returns names in byte order, so truncating first would let +// its alphabet decide what the user can see. A fetch that outlives the budget +// finishes in the background and refreshes the listing cache (D36.11, see +// catalog_warm_behind.go). func SearchAll(ctx context.Context, q, tag string, limit int, opts SearchOptions) ([]CatalogHit, *CatalogSections, []SourceError) { timeout := opts.SourceTimeout if timeout <= 0 { @@ -177,12 +192,12 @@ func SearchAll(ctx context.Context, q, tag string, limit int, opts SearchOptions // section pool (Official/Popular) is as wide as each source will give — // otherwise a `limit` of 10 would starve Popular of anything beyond the // first 10 official hits before popularity ever gets a say. The final - // `results` list is still truncated to `limit` below. A non-empty q keeps - // fetching exactly `limit` per source (unchanged), since it has no - // sections to populate. - fetchLimit := limit - if empty { - fetchLimit = maxCatalogLimit + // `results` list is still truncated to `limit` below. A typed q has no + // sections: it fetches every match (capped) and ranks before truncating. + fetchLimit := maxCatalogLimit + cachedCap := fetchLimit + if !empty { + cachedCap = typedFetchCap } sources := ListRegistries() @@ -199,48 +214,59 @@ func SearchAll(ctx context.Context, q, tag string, limit int, opts SearchOptions go func(i int) { defer wg.Done() reg := sources[i] - sctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - entries, err := SearchServers(sctx, reg.ID, tag, q, fetchLimit, nil) - if err != nil { - reason := err.Error() - if sctx.Err() != nil { + fetch := func(c context.Context, onPartial func([]ServerEntry)) ([]ServerEntry, error) { + if empty { + return searchRegistry(c, ®, tag, "", fetchLimit, nil) + } + return searchCatalogSourceProgress(c, ®, q, onPartial) + } + res := fetchSourceWithinBudget(ctx, reg, timeout, fetch) + if res.err != nil { + reason := res.err.Error() + if res.timedOut { reason = fmt.Sprintf("timeout after %s", timeout) } failure := &SourceError{Source: reg.ID, Reason: reason} + // Live hits that arrived before the failure (the official + // protocol's owner and name-prefix queries) still count, ahead + // of the cached listing's matches. + matches := make([]CatalogHit, 0, len(res.entries)) + live := make(map[string]bool, len(res.entries)) + for _, e := range res.entries { + live[e.ID] = true + matches = append(matches, BuildCatalogHit(®, e)) + } // Answer from the source's cached listing when the live fetch // failed for any reason except a missing API key (that source // never fetched, so nothing is cached for it). The source stays - // in unavailable[]; the hits are marked FromCache. - if !errors.Is(err, ErrRegistryKeyMissing) { + // in unavailable[]; the cached hits are marked FromCache. + if !errors.Is(res.err, ErrRegistryKeyMissing) { if cached, at, ok := cachedListing(®); ok { - matches := make([]CatalogHit, 0, len(cached)) + cachedMatches := 0 for i := range cached { - if len(matches) >= fetchLimit { + if cachedMatches >= cachedCap { break } - if !matchCachedEntry(&cached[i], q) { + if live[cached[i].ID] || !matchCachedEntry(&cached[i], q) { continue } cached[i].Registry = reg.Name hit := BuildCatalogHit(®, cached[i]) hit.FromCache = true matches = append(matches, hit) + cachedMatches++ } failure.Fallback = FallbackCachedListing failure.CachedAt = &at - outcomes[i] = sourceOutcome{hits: matches, unavailable: failure} - return } } - outcomes[i] = sourceOutcome{unavailable: failure} + outcomes[i] = sourceOutcome{hits: matches, unavailable: failure} return } - cacheListing(®, entries) - hits := make([]CatalogHit, 0, len(entries)) - for _, e := range entries { + hits := make([]CatalogHit, 0, len(res.entries)) + for _, e := range res.entries { hits = append(hits, BuildCatalogHit(®, e)) } outcomes[i] = sourceOutcome{hits: hits} @@ -278,8 +304,14 @@ func SearchAll(ctx context.Context, q, tag string, limit int, opts SearchOptions // Spec 110 FR-002/007: resolve popularity (cache hits immediately, misses // queued for a bounded background wait) BEFORE ranking, so both the // Rank tiebreak (US2) and the Popular section see up-to-date signal. This - // mutates each hit's Popularity in place but does not reorder `all`. - resolvePopularity(ctx, all, q, popularityWait(opts.PopularityWait)) + // mutates each hit's Popularity in place but does not reorder `all`. A + // typed q enqueues at most `limit` keys (D36.7): the fetch is wide now, and + // GitHub's unauthenticated budget is 50 requests an hour. + maxKeys := 0 + if !empty { + maxKeys = limit + } + resolvePopularity(ctx, all, q, popularityWait(opts.PopularityWait), maxKeys) var sections *CatalogSections if empty { @@ -317,8 +349,10 @@ func popularityWait(configured time.Duration) time.Duration { // PopularityProvider (if any) to resolve every hit's GitHub repo key, waits // up to `wait` for the background fetch to land, and re-applies whatever is // now cached. A nil provider (no popularity wiring — e.g. most tests) is a -// fast no-op. -func resolvePopularity(ctx context.Context, hits []CatalogHit, q string, wait time.Duration) { +// fast no-op. maxKeys > 0 caps how many distinct repos are considered: only +// the top maxKeys eligible hits in Rank order are enqueued (Spec 109 D36.7); +// 0 means no cap (the empty-query landing, which needs stars across its pool). +func resolvePopularity(ctx context.Context, hits []CatalogHit, q string, wait time.Duration, maxKeys int) { provider := getPopularityProvider() if provider == nil { return @@ -334,10 +368,16 @@ func resolvePopularity(ctx context.Context, hits []CatalogHit, q string, wait ti seen := make(map[string]bool, len(priority)) keys := make([]string, 0, len(priority)) for _, h := range priority { + if h.starsBorrowed { + continue // borrowed repo: its stars are not this server's (D36.7) + } key, ok := GitHubRepoKey(h.Entry.SourceCodeURL) if !ok || seen[key] { continue } + if maxKeys > 0 && len(seen) >= maxKeys { + break + } seen[key] = true // Only Stale/Absent need a fetch (FR-008): a Fresh positive or a // still-fresh Negative (confirmed 404/451, or an error entry backing @@ -369,26 +409,36 @@ func filterHitsBySource(hits []CatalogHit, source string) []CatalogHit { } // BuildCatalogHit derives the catalog-only fields (Title, Publisher, -// Verified, Official, Popularity) from a registry entry. Verified currently -// tracks Official — no registry in this spec supplies an independent -// publisher-verification signal yet, so a trusted (built-in) source's -// namespace is the only verification evidence available (data-model §9, -// research D12). Exported so a single-entry lookup (CLI `catalog show`) can -// build the same CatalogHit shape SearchAll uses internally. +// Verified, Official, Popularity) from a registry entry. Official means the +// hit came from a built-in (trusted) source and feeds Rank and the Official +// section (Spec 109 D36.6). Verified is narrower (D36.5): for a trusted +// official-protocol entry it means the publisher's namespace owns the source +// repository; for a trusted reference or Docker entry it stays "trusted +// source"; an untrusted source never verifies. Exported so a single-entry +// lookup (CLI `catalog show`) can build the same CatalogHit shape SearchAll +// uses internally. func BuildCatalogHit(reg *RegistryEntry, entry ServerEntry) CatalogHit { official := reg.IsTrusted() - title := entry.Name - if title == "" { - title = entry.ID + verified := official + starsBorrowed := false + if reg.Protocol == protocolOfficial { + verified = official && publisherOwnsRepo(entry.ID, entry.SourceCodeURL) + starsBorrowed = !verified + } + // A parser's "No description available" is a placeholder, not a + // description: surfaces print nothing for an empty one (D36.9). + if entry.Description == noDescAvailable { + entry.Description = "" } hit := CatalogHit{ - Entry: entry, - Source: reg.ID, - Title: title, - Publisher: derivePublisher(entry.ID, reg.Name), - Verified: official, - Official: official, - Curated: reg.Protocol == protocolReference, + Entry: entry, + Source: reg.ID, + Title: catalogHitTitle(reg, entry), + Publisher: derivePublisher(entry.ID, reg.Name), + Verified: verified, + Official: official, + Curated: reg.Protocol == protocolReference, + starsBorrowed: starsBorrowed, } // FR-001/FR-002: copy the source-native signal (e.g. Docker pull_count) // first, then layer in GitHub stars from the provider's cache only — no @@ -401,12 +451,62 @@ func BuildCatalogHit(reg *RegistryEntry, entry ServerEntry) CatalogHit { return hit } +// catalogHitTitle picks the display title (Spec 109 D36.10): the source's own +// title, then for an official-protocol entry the name segment after the +// namespace ("github", not "io.github.github/github-mcp-server"), then the +// entry's name, then its id. +func catalogHitTitle(reg *RegistryEntry, entry ServerEntry) string { + if entry.Title != "" { + return entry.Title + } + if reg.Protocol == protocolOfficial { + if i := strings.IndexByte(entry.ID, '/'); i >= 0 && i+1 < len(entry.ID) { + return entry.ID[i+1:] + } + } + if entry.Name != "" { + return entry.Name + } + return entry.ID +} + +// publisherOwnsRepo reports whether the namespace of an official-protocol id +// owns the GitHub repository it names as its source (Spec 109 D36.5): an +// `io.github.` namespace needs repo owner x; a domain namespace needs its +// owner label (≥ 3 characters, e.g. "notion" of com.notion) to appear in the +// repo owner (e.g. "makenotion"). A re-publisher of someone else's server, a +// borrowed repo URL or a missing repository never verifies. +func publisherOwnsRepo(id, sourceCodeURL string) bool { + key, ok := GitHubRepoKey(sourceCodeURL) + if !ok { + return false + } + repoOwner := key[:strings.IndexByte(key, '/')] + slash := strings.IndexByte(id, '/') + if slash <= 0 { + return false + } + namespace := strings.ToLower(id[:slash]) + if x, isGitHub := strings.CutPrefix(namespace, "io.github."); isGitHub { + return x != "" && x == repoOwner + } + label, ok := namespaceOwner(id) + label = strings.ToLower(label) + return ok && len(label) >= 3 && strings.Contains(repoOwner, label) +} + // applyCachedStars fills in hit.Popularity.Stars from the installed // PopularityProvider's cache ONLY (Spec 110 FR-002): no I/O, so both // BuildCatalogHit and SearchAll's post-Resolve re-apply can call this freely. // A nil provider, an entry with no GitHub-shaped SourceCodeURL, or a // non-displayable lookup state (Absent/Negative) leave the hit unchanged. func applyCachedStars(hit *CatalogHit) { + if hit.starsBorrowed { + // The publisher does not own the named repo: its stars are not this + // server's. Keep only what the source itself reported (D36.7). + resetToSourceNativeStars(hit) + return + } key, ok := GitHubRepoKey(hit.Entry.SourceCodeURL) if !ok { return @@ -449,26 +549,59 @@ func resetToSourceNativeStars(hit *CatalogHit) { } // derivePublisher extracts a display publisher from an official-protocol -// reverse-DNS id such as "io.github.github/github-mcp-server" (→ "github"). -// Falls back to the registry's own name when the id carries no such -// namespace. +// reverse-DNS id such as "io.github.github/github-mcp-server" (→ "github"; see +// namespaceOwner). Falls back to the registry's own name when the id carries +// no such namespace. func derivePublisher(id, registryName string) string { + if owner, ok := namespaceOwner(id); ok { + return owner + } + return registryName +} + +// secondLevelSuffixes are the second-level public suffixes that sit between a +// country code and the organisation in a reverse-DNS namespace (uk.co.acme). +var secondLevelSuffixes = map[string]bool{"co": true, "com": true, "org": true, "net": true, "ac": true, "gov": true, "edu": true} + +// namespaceOwner returns the publisher label of an id's reverse-DNS namespace +// (Spec 109 D36.1 tier 5): the user of `io.github.`, otherwise the +// registrable-domain label, the second one ("com.notion/x" -> "notion", +// "com.quranmajeed.time/x" -> "quranmajeed", "uk.co.acme/x" -> "acme"). It +// reports false when the id has no dotted namespace, so the registry-name +// fallback of derivePublisher never counts as an owner. +func namespaceOwner(id string) (string, bool) { slash := strings.IndexByte(id, '/') if slash <= 0 { - return registryName + return "", false } - namespace := id[:slash] - if dot := strings.LastIndexByte(namespace, '.'); dot >= 0 && dot+1 < len(namespace) { - return namespace[dot+1:] + labels := strings.Split(id[:slash], ".") + if len(labels) < 2 { + return "", false } - return registryName + i := 1 + switch { + case labels[0] == "io" && labels[1] == "github": + i = 2 + case len(labels) >= 3 && secondLevelSuffixes[labels[1]]: + i = 2 + } + if i >= len(labels) || labels[i] == "" { + return "", false + } + return labels[i], true } -// Rank is the pure, deterministic catalog ordering (data-model §9, -// contracts/rest-api.md#catalog): official desc, verified desc, popularity -// desc (missing = 0), text relevance desc, title asc, id asc. It reports -// whether a sorts strictly before b. +// Rank is the pure, deterministic catalog ordering (Spec 109 D36.1, data-model +// §9, contracts/rest-api.md#catalog): match tier desc (how well the NAME +// matches q: publisher equals q > exact name > name prefix > name token > +// substring or description > namespace-only), official source desc, verified +// desc, popularity desc (missing = 0, Spec 110 FR-004), title asc, id asc. It +// reports whether a sorts strictly before b. An empty q puts every hit in +// tier 0, so browse-time order is official, verified, popularity, title, id. func Rank(a, b CatalogHit, q string) bool { + if at, bt := matchTier(a, q), matchTier(b, q); at != bt { + return at > bt + } if a.Official != b.Official { return a.Official } @@ -478,9 +611,6 @@ func Rank(a, b CatalogHit, q string) bool { if !popularityEqual(a.Popularity, b.Popularity) { return morePopular(a.Popularity, b.Popularity) } - if ar, br := relevanceScore(a, q), relevanceScore(b, q); ar != br { - return ar > br - } at, bt := strings.ToLower(catalogTitle(a)), strings.ToLower(catalogTitle(b)) if at != bt { return at < bt @@ -534,31 +664,77 @@ func popularityEqual(a, b *Popularity) bool { return as == bs && ai == bi } -// relevanceScore is a simple, deterministic token-match count of q against -// title/id/description — good enough to break ties below popularity, never -// used as the primary key. -func relevanceScore(h CatalogHit, q string) int { - q = strings.ToLower(strings.TrimSpace(q)) - if q == "" { +// normalizeMatchText lower-cases s and collapses every run of the separators +// `- _ . /` and whitespace to one space, so "GitHub-MCP_server" and "github mcp +// server" compare equal. Surrounding separators are trimmed. +func normalizeMatchText(s string) string { + var b strings.Builder + b.Grow(len(s)) + space := true // swallow leading separators + for _, r := range strings.ToLower(s) { + switch r { + case '-', '_', '.', '/', ' ', '\t', '\n', '\r': + if !space { + b.WriteByte(' ') + space = true + } + default: + b.WriteRune(r) + space = false + } + } + return strings.TrimSuffix(b.String(), " ") +} + +// matchTier scores how well a hit's NAME matches q (Spec 109 D36.1), 0-5. Rank +// puts it first so the real server beats a merely popular or official one: +// +// 5 the publisher (namespace owner) equals q io.github.github/… for "github" +// 4 the name segment or the title equals q com.mcparmory/github +// 3 the segment or title starts with q github-mcp-server +// 2 a whole word of the segment/title equals q obsidian-github-mcp +// 1 q is a substring of the segment/title/description +// 0 no match, or the only match is the namespace (io.github.*) +// +// q and every field are normalized by normalizeMatchText. An empty q is 0. +func matchTier(h CatalogHit, q string) int { + nq := normalizeMatchText(q) + if nq == "" { return 0 } - score := 0 - title := strings.ToLower(catalogTitle(h)) - id := strings.ToLower(h.Entry.ID) - desc := strings.ToLower(h.Entry.Description) - if title == q { - score += 100 + id := h.Entry.ID + if owner, ok := namespaceOwner(id); ok && normalizeMatchText(owner) == nq { + return 5 } - if strings.Contains(title, q) { - score += 10 + seg := id + if i := strings.IndexByte(id, '/'); i >= 0 { + seg = id[i+1:] } - if strings.Contains(id, q) { - score += 5 + fields := [2]string{normalizeMatchText(seg), normalizeMatchText(catalogTitle(h))} + for _, f := range fields { + if f == nq { + return 4 + } } - if strings.Contains(desc, q) { - score++ + for _, f := range fields { + if strings.HasPrefix(f, nq+" ") { + return 3 + } + } + for _, f := range fields { + if strings.Contains(" "+f+" ", " "+nq+" ") { + return 2 + } } - return score + for _, f := range fields { + if strings.Contains(f, nq) { + return 1 + } + } + if strings.Contains(normalizeMatchText(h.Entry.Description), nq) { + return 1 + } + return 0 } // buildSections splits the merged, de-duplicated, source-filtered pool @@ -574,7 +750,7 @@ func relevanceScore(h CatalogHit, q string) int { // - Popular: hits with a known signal (stars>0 ∨ installs>0), sorted by // popularity (FR-004) then Rank as a tiebreak, at most one per GitHub // repo key (a monorepo's shared star count keeps only the first by Rank — -// spec.md edge cases), capped at 12. +// spec.md edge cases) and one per normalized title (D36.8), capped at 12. func buildSections(pool []CatalogHit, q string) *CatalogSections { sections := &CatalogSections{Official: []CatalogHit{}, Popular: []CatalogHit{}} @@ -597,6 +773,7 @@ func buildSections(pool []CatalogHit, q string) *CatalogSections { }) seenRepo := make(map[string]bool, len(candidates)) + seenTitle := make(map[string]bool, len(candidates)) for _, h := range candidates { if len(sections.Popular) >= catalogSectionCap { break @@ -607,15 +784,29 @@ func buildSections(pool []CatalogHit, q string) *CatalogSections { // hit) — (source, id) already made this unique within `pool`. dedupKey = "no-repo:" + h.Source + "\x00" + h.Entry.ID } - if seenRepo[dedupKey] { + // The same server listed by two sources (the reference `fetch` and + // Docker's mcp/fetch) shares a normalized title, not a repo key + // (Spec 109 D36.8). + titleKey := popularTitleKey(h) + if seenRepo[dedupKey] || (titleKey != "" && seenTitle[titleKey]) { continue } seenRepo[dedupKey] = true + if titleKey != "" { + seenTitle[titleKey] = true + } sections.Popular = append(sections.Popular, h) } return sections } +// popularTitleKey is Popular's second de-dup key (Spec 109 D36.8): the title +// lower-cased with a Docker `mcp/` prefix stripped, nothing else, so `fetch` +// and `mcp/fetch` collapse while `fetch-mcp` stays separate. +func popularTitleKey(h CatalogHit) string { + return strings.TrimPrefix(strings.ToLower(strings.TrimSpace(catalogTitle(h))), "mcp/") +} + // officialBrowseOrder orders the official-source hits of the merged pool (in // MERGE order: registry-list order, then each source's native order) for the // empty-query Official section. The curated reference servers come first, in diff --git a/internal/registries/catalog_bench_test.go b/internal/registries/catalog_bench_test.go index d95842cd5..8bd8415f1 100644 --- a/internal/registries/catalog_bench_test.go +++ b/internal/registries/catalog_bench_test.go @@ -34,7 +34,6 @@ func TestSearchAll_PerformanceBudget(t *testing.T) { defer fast2.Close() block := make(chan struct{}) - defer close(block) hung := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { select { case <-block: @@ -42,6 +41,10 @@ func TestSearchAll_PerformanceBudget(t *testing.T) { } })) defer hung.Close() + // Registered AFTER hung.Close so it runs first: the timed-out fetches keep + // running in the background (warm-behind, Spec 109 D36.11) and hold their + // connections until released, which Close would otherwise wait out. + defer close(block) withTestRegistries(t, []RegistryEntry{ {ID: "fast1", Name: "Fast1", ServersURL: fast1.URL}, diff --git a/internal/registries/catalog_hit_signals_test.go b/internal/registries/catalog_hit_signals_test.go new file mode 100644 index 000000000..cd2b440c1 --- /dev/null +++ b/internal/registries/catalog_hit_signals_test.go @@ -0,0 +1,149 @@ +package registries + +import ( + "testing" +) + +// Spec 109 fix-catalog-rank T169 (D36.5, D36.7, D36.9, D36.10): what the +// catalog hit says about itself. Verified is no longer "any built-in source". + +func officialReg() *RegistryEntry { + return &RegistryEntry{ID: "official", Name: "Official", Protocol: protocolOfficial, Provenance: "official"} +} + +func TestNamespaceOwner(t *testing.T) { + cases := []struct { + id string + owner string + ok bool + }{ + {"io.github.github/github-mcp-server", "github", true}, + {"com.notion/mcp", "notion", true}, + {"ai.smithery/Hint-Services-x", "smithery", true}, + {"com.quranmajeed.time/prayer-times", "quranmajeed", true}, // a subdomain label is not the publisher + {"uk.co.acme/x", "acme", true}, + {"com.example.api.v2/x", "example", true}, + {"io.github/x", "", false}, + {"acme/github-fork", "", false}, // no dotted namespace: the registry-name fallback + {"fetch", "", false}, + {"/x", "", false}, + {"io.github./x", "", false}, + } + for _, c := range cases { + owner, ok := namespaceOwner(c.id) + if owner != c.owner || ok != c.ok { + t.Errorf("namespaceOwner(%q) = %q,%v want %q,%v", c.id, owner, ok, c.owner, c.ok) + } + } +} + +func TestBuildCatalogHit_VerifiedMeansPublisherOwnsRepo(t *testing.T) { + custom := &RegistryEntry{ID: "c", Name: "C", Protocol: protocolOfficial, Provenance: "custom"} + docker := &RegistryEntry{ID: "docker", Name: "Docker", Provenance: "official"} + ref := &RegistryEntry{ID: "reference", Name: "Reference", Protocol: protocolReference, Provenance: "official"} + cases := []struct { + name string + reg *RegistryEntry + id string + repo string + wants bool + }{ + {"github owns github", officialReg(), "io.github.github/github-mcp-server", "https://github.com/github/github-mcp-server", true}, + {"io.github owner case-insensitive", officialReg(), "io.github.Dave-London/github", "https://github.com/dave-london/mcp", true}, + {"io.github other owner", officialReg(), "io.github.rog0x/github", "https://github.com/someone-else/github", false}, + {"domain label inside owner", officialReg(), "com.notion/mcp", "https://github.com/makenotion/notion-mcp-server", true}, + {"re-publisher", officialReg(), "ai.smithery/Hint-Services-x", "https://github.com/Hint-Services/x", false}, + {"borrowed repo", officialReg(), "agency.ottobot/foo", "https://github.com/modelcontextprotocol/registry", false}, + {"short label never matches", officialReg(), "io.ab/foo", "https://github.com/cabinet/foo", false}, + {"no repository", officialReg(), "io.github.github/github-mcp-server", "", false}, + {"non-github repository", officialReg(), "com.notion/mcp", "https://gitlab.com/makenotion/x", false}, + {"untrusted source", custom, "io.github.github/github-mcp-server", "https://github.com/github/github-mcp-server", false}, + {"docker keeps IsTrusted", docker, "fetch", "", true}, + {"reference keeps IsTrusted", ref, "fetch", "", true}, + } + for _, c := range cases { + hit := BuildCatalogHit(c.reg, ServerEntry{ID: c.id, Name: c.id, SourceCodeURL: c.repo}) + if hit.Verified != c.wants { + t.Errorf("%s: Verified = %v, want %v", c.name, hit.Verified, c.wants) + } + if hit.Official != c.reg.IsTrusted() { + t.Errorf("%s: Official = %v must stay IsTrusted (D36.6)", c.name, hit.Official) + } + } +} + +func TestBuildCatalogHit_TitleOrder(t *testing.T) { + reg := officialReg() + cases := []struct { + name string + reg *RegistryEntry + entry ServerEntry + want string + }{ + {"server.json title first", reg, ServerEntry{ID: "io.github.github/github-mcp-server", Name: "io.github.github/github-mcp-server", Title: "GitHub"}, "GitHub"}, + {"then the name segment", reg, ServerEntry{ID: "ai.smithery/smithery-ai-github", Name: "ai.smithery/smithery-ai-github"}, "smithery-ai-github"}, + {"flat source keeps its name", &RegistryEntry{ID: "flat", Name: "Flat"}, ServerEntry{ID: "acme/github-fork", Name: "GitHub (community fork)"}, "GitHub (community fork)"}, + {"then the id", &RegistryEntry{ID: "flat", Name: "Flat"}, ServerEntry{ID: "acme/x"}, "acme/x"}, + {"official entry without a slash keeps its name", reg, ServerEntry{ID: "plain", Name: "plain"}, "plain"}, + } + for _, c := range cases { + if got := BuildCatalogHit(c.reg, c.entry).Title; got != c.want { + t.Errorf("%s: Title = %q, want %q", c.name, got, c.want) + } + } +} + +func TestBuildCatalogHit_PlaceholderDescriptionBecomesEmpty(t *testing.T) { + hit := BuildCatalogHit(officialReg(), ServerEntry{ID: "a.b/c", Name: "a.b/c", Description: noDescAvailable}) + if hit.Entry.Description != "" { + t.Fatalf("Entry.Description = %q, want empty (D36.9)", hit.Entry.Description) + } + real := BuildCatalogHit(officialReg(), ServerEntry{ID: "a.b/c", Name: "a.b/c", Description: "real"}) + if real.Entry.Description != "real" { + t.Fatalf("a real description must stay, got %q", real.Entry.Description) + } +} + +func TestToCatalogResult_DescriptionEmptyForPlaceholder(t *testing.T) { + hit := BuildCatalogHit(officialReg(), ServerEntry{ID: "a.b/c", Name: "a.b/c", Description: noDescAvailable}) + if got := ToCatalogResult(hit, false).Description; got != "" { + t.Fatalf("CatalogResult.Description = %q, want empty", got) + } +} + +func TestBuildCatalogHit_StarsIgnoredWhenPublisherDoesNotOwnRepo(t *testing.T) { + stub := &stubPopularityProvider{stars: map[string]int{"modelcontextprotocol/registry": 5000, "github/github-mcp-server": 21000}} + defer SetPopularityProviderForTest(stub)() + + borrowed := BuildCatalogHit(officialReg(), ServerEntry{ + ID: "agency.ottobot/foo", Name: "agency.ottobot/foo", + SourceCodeURL: "https://github.com/modelcontextprotocol/registry", + }) + if borrowed.Popularity != nil { + t.Fatalf("borrowed stars must not count, got %+v", borrowed.Popularity) + } + // Re-applying after a Resolve must not leak them either. + applyCachedStars(&borrowed) + if borrowed.Popularity != nil { + t.Fatalf("applyCachedStars leaked borrowed stars: %+v", borrowed.Popularity) + } + + own := BuildCatalogHit(officialReg(), ServerEntry{ + ID: "io.github.github/github-mcp-server", Name: "io.github.github/github-mcp-server", + SourceCodeURL: "https://github.com/github/github-mcp-server", + }) + if own.Popularity == nil || own.Popularity.Stars == nil || *own.Popularity.Stars != 21000 { + t.Fatalf("the publisher's own repo keeps its stars, got %+v", own.Popularity) + } + + // An ineligible hit still keeps its source-native signal. + installs := 7 + native := BuildCatalogHit(officialReg(), ServerEntry{ + ID: "agency.ottobot/bar", Name: "agency.ottobot/bar", + SourceCodeURL: "https://github.com/modelcontextprotocol/registry", + Popularity: &Popularity{Installs: &installs}, + }) + if native.Popularity == nil || native.Popularity.Installs == nil || native.Popularity.Stars != nil { + t.Fatalf("source-native installs stay, stars do not, got %+v", native.Popularity) + } +} diff --git a/internal/registries/catalog_rank_tier_test.go b/internal/registries/catalog_rank_tier_test.go new file mode 100644 index 000000000..e384e3b3f --- /dev/null +++ b/internal/registries/catalog_rank_tier_test.go @@ -0,0 +1,108 @@ +package registries + +import ( + "sort" + "testing" +) + +// Spec 109 fix-catalog-rank T170 (D36.1): the relevance tier ranks first. + +func tierHit(id, title, desc string) CatalogHit { + pub := derivePublisher(id, "Reg") + return CatalogHit{Source: "s", Title: title, Publisher: pub, Entry: ServerEntry{ID: id, Name: id, Description: desc}} +} + +func TestMatchTier_Table(t *testing.T) { + cases := []struct { + name string + hit CatalogHit + q string + want int + }{ + {"owner equals q", tierHit("io.github.github/github-mcp-server", "GitHub", ""), "github", 5}, + {"owner equals q, case", tierHit("io.github.github/x", "X", ""), "GitHub", 5}, + {"segment equals q", tierHit("com.mcparmory/github", "github", ""), "github", 4}, + {"title equals q", tierHit("io.github.rog0x/other", "GitHub", ""), "github", 4}, + {"segment starts with q at a token", tierHit("io.x.y/github-mcp-server", "github-mcp-server", ""), "github", 3}, + {"title starts with q", tierHit("a.b/zzz", "GitHub Actions helper", ""), "github", 3}, + {"token equals q", tierHit("io.github.x/obsidian-github-mcp", "obsidian-github-mcp", ""), "github", 2}, + {"prefix without a token boundary is only a substring", tierHit("a.b/githubx", "githubx", ""), "github", 1}, + {"substring of segment", tierHit("a.b/mygithubthing", "mygithubthing", ""), "github", 1}, + {"description only", tierHit("a.b/zzz", "zzz", "talks about GitHub issues"), "github", 1}, + {"namespace only", tierHit("io.github.06ketan/slideshot", "slideshot", ""), "github", 0}, + {"no match", tierHit("a.b/zzz", "zzz", ""), "github", 0}, + {"multi-word matches hyphenated names", tierHit("a.b/github-actions", "github-actions", ""), "github actions", 4}, + {"multi-word token window", tierHit("a.b/my-github-actions-tool", "my-github-actions-tool", ""), "github actions", 2}, + {"empty q", tierHit("a.b/github", "github", ""), "", 0}, + {"a subdomain label is not the owner", tierHit("com.quranmajeed.time/prayer-times", "prayer-times", ""), "time", 1}, + {"flat id owner is never set", tierHit("github/thing", "Thing", ""), "github", 0}, + {"registry-name publisher is never tier 5", CatalogHit{Publisher: "GitHub", Entry: ServerEntry{ID: "tool", Name: "tool"}}, "github", 0}, + } + for _, c := range cases { + if got := matchTier(c.hit, c.q); got != c.want { + t.Errorf("%s: matchTier(%q) = %d, want %d", c.name, c.q, got, c.want) + } + } +} + +func TestRank_RelevanceTierBeatsPopularity(t *testing.T) { + popular := CatalogHit{Popularity: intPop(100), Entry: ServerEntry{ID: "zzz", Name: "Unrelated"}} + relevant := CatalogHit{Popularity: intPop(1), Entry: ServerEntry{ID: "aaa", Name: "github tool"}} + if !Rank(relevant, popular, "github") { + t.Error("expected the name match to outrank a more popular non-match") + } + if Rank(popular, relevant, "github") { + t.Error("expected the name match to outrank (reverse check)") + } +} + +func TestRank_OfficialBeatsVerifiedWithinATier(t *testing.T) { + official := CatalogHit{Source: "official", Official: true, Entry: ServerEntry{ID: "z", Name: "Z"}} + verifiedPopular := CatalogHit{Source: "smithery", Verified: true, Popularity: intPop(9999), Entry: ServerEntry{ID: "a", Name: "A"}} + if !Rank(official, verifiedPopular, "") { + t.Error("official must rank first within a tier") + } + // Across tiers the tier wins, even against an official source. + exact := CatalogHit{Source: "custom", Entry: ServerEntry{ID: "c/github", Name: "github"}} + prefixOfficial := CatalogHit{Source: "official", Official: true, Verified: true, Entry: ServerEntry{ID: "c/github-thing", Name: "github-thing"}} + if !Rank(exact, prefixOfficial, "github") { + t.Error("an exact name from any source must outrank an official prefix match") + } +} + +func TestRank_ExactNameBeatsTokenMatchAcrossSources(t *testing.T) { + hits := []CatalogHit{ + {Source: "official", Official: true, Verified: true, Popularity: intPop(900), Title: "obsidian-github-mcp", Entry: ServerEntry{ID: "io.x.y/obsidian-github-mcp"}}, + {Source: "docker", Official: true, Verified: true, Popularity: &Popularity{Installs: intPtr(5)}, Title: "time", Entry: ServerEntry{ID: "time"}}, + {Source: "reference", Official: true, Verified: true, Curated: true, Title: "time", Entry: ServerEntry{ID: "time"}}, + {Source: "official", Official: true, Verified: true, Title: "mcp-time-server", Entry: ServerEntry{ID: "io.x.y/mcp-time-server"}}, + } + sort.SliceStable(hits, func(i, j int) bool { return Rank(hits[i], hits[j], "time") }) + if hits[0].Title != "time" || hits[1].Title != "time" { + t.Fatalf("both exact `time` hits must lead, got %s %s %s %s", hits[0].Title, hits[1].Title, hits[2].Title, hits[3].Title) + } + if hits[0].Source != "docker" { + t.Errorf("within the tier popularity breaks the tie, got %s first", hits[0].Source) + } +} + +func TestRank_EmptyQueryUnchanged(t *testing.T) { + hits := []CatalogHit{ + {Source: "c", Title: "Zed", Entry: ServerEntry{ID: "z"}}, + {Source: "o", Official: true, Title: "Beta", Entry: ServerEntry{ID: "b"}}, + {Source: "o", Official: true, Verified: true, Title: "Gamma", Entry: ServerEntry{ID: "g"}}, + {Source: "o", Official: true, Verified: true, Popularity: intPop(5), Title: "Delta", Entry: ServerEntry{ID: "d"}}, + {Source: "o", Official: true, Verified: true, Title: "Alpha", Entry: ServerEntry{ID: "a"}}, + } + sort.SliceStable(hits, func(i, j int) bool { return Rank(hits[i], hits[j], "") }) + var got []string + for _, h := range hits { + got = append(got, h.Title) + } + want := []string{"Delta", "Alpha", "Gamma", "Beta", "Zed"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("empty-query order = %v, want %v (official, verified, popularity, title)", got, want) + } + } +} diff --git a/internal/registries/catalog_typed_test.go b/internal/registries/catalog_typed_test.go new file mode 100644 index 000000000..0032e1c73 --- /dev/null +++ b/internal/registries/catalog_typed_test.go @@ -0,0 +1,274 @@ +package registries + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +// Spec 109 fix-catalog-rank T171 (D36.3, D36.7, D36.8): a typed catalog query +// is fetched wide, ranked, then truncated, and the GitHub star budget is spent +// only on the hits that survive. + +// installCatalogFixtureSources serves every source of the shared SC-008 fixture +// (testdata/catalog_github_order.json) the way the parity legs do: the official +// source from its recorded corpus, the others from their flat servers. +func installCatalogFixtureSources(t *testing.T, wrap func(http.Handler, string) http.Handler) { + t.Helper() + f, _ := loadCatalogFixture(t) + var regs []RegistryEntry + for _, raw := range f.Sources { + var src struct { + ID string `json:"id"` + Name string `json:"name"` + Provenance string `json:"provenance"` + Protocol string `json:"protocol"` + Corpus []json.RawMessage `json:"corpus"` + Servers []json.RawMessage `json:"servers"` + } + if err := json.Unmarshal(raw, &src); err != nil { + t.Fatal(err) + } + var h http.Handler + if src.Protocol == protocolOfficial { + h = RecordedRegistryHandlerForTest(src.Corpus) + } else { + body, _ := json.Marshal(src.Servers) + h = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }) + } + if wrap != nil { + h = wrap(h, src.ID) + } + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + regs = append(regs, RegistryEntry{ID: src.ID, Name: src.Name, ServersURL: srv.URL + "/v0.1/servers", Protocol: src.Protocol, Provenance: src.Provenance}) + } + t.Cleanup(AllowPrivateRegistryFetchForTest()) + t.Cleanup(SetRegistriesForTest(regs)) + t.Cleanup(ResetListingCacheForTest) +} + +func TestSearchAll_RecordedRegistry_GitHubFirst(t *testing.T) { + installCatalogFixtureSources(t, nil) + hits, _, unavailable := SearchAll(context.Background(), "github", "", 20, SearchOptions{PopularityWait: -1}) + if len(unavailable) != 0 { + t.Fatalf("unavailable = %+v", unavailable) + } + if len(hits) != 20 { + t.Fatalf("got %d hits, want 20", len(hits)) + } + first := hits[0] + if first.Source != "official" || first.Entry.ID != "io.github.github/github-mcp-server" { + t.Fatalf("first hit = %s:%s, want GitHub's own server", first.Source, first.Entry.ID) + } + if first.Title != "GitHub" || first.Publisher != "github" || !first.Verified || !first.Official { + t.Fatalf("first hit = %+v", first) + } + + seen := map[string]bool{} + lastTier4, firstTier3 := -1, len(hits) + for i, h := range hits { + key := h.Source + ":" + h.Entry.ID + if seen[key] { + t.Errorf("%s appears twice", key) + } + seen[key] = true + tier := matchTier(h, "github") + if tier == 0 { + t.Errorf("tier-0 (namespace-only) hit %s must not make the top 20", key) + } + if tier >= 4 { + lastTier4 = i + } + if tier == 3 && i < firstTier3 { + firstTier3 = i + } + } + if lastTier4 > firstTier3 { + t.Errorf("an exact-name hit (idx %d) ranks after a prefix hit (idx %d)", lastTier4, firstTier3) + } + if !seen["official:com.mcparmory/github"] { + t.Error("com.mcparmory/github (exact name) must be in the top 20") + } + t.Logf("top 20: %v", idsOf(hits)) +} + +func TestSearchAll_TypedQueryRanksBeforeTruncation(t *testing.T) { + // 60 sources-order-first entries only mention github in their description + // (tier 1); the real match is LAST. With limit 10 it must still lead. + var items []string + for i := 0; i < 60; i++ { + items = append(items, fmt.Sprintf(`{"id":"acme/n%02d","name":"note %02d","description":"works with github"}`, i, i)) + } + items = append(items, `{"id":"acme/github","name":"github","description":"the real one"}`) + src := jsonServer(t, "["+strings.Join(items, ",")+"]") + withTestRegistries(t, []RegistryEntry{{ID: "flat", Name: "Flat", ServersURL: src.URL}}) + + hits, _, _ := SearchAll(context.Background(), "github", "", 10, SearchOptions{PopularityWait: -1}) + if len(hits) != 10 { + t.Fatalf("got %d hits, want 10", len(hits)) + } + if hits[0].Entry.ID != "acme/github" { + t.Fatalf("the exact name must lead, got %s", hits[0].Entry.ID) + } +} + +func TestSearchAll_MainTimesOutExpansionHitsStillShown(t *testing.T) { + released := make(chan struct{}) + t.Cleanup(func() { close(released) }) + installCatalogFixtureSources(t, func(h http.Handler, id string) http.Handler { + if id != "official" { + return h + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("search") == "github" { // the slow main query + select { + case <-released: + case <-r.Context().Done(): + } + http.Error(w, "late", http.StatusGatewayTimeout) + return + } + h.ServeHTTP(w, r) + }) + }) + t.Cleanup(SetCatalogWarmBehindForTest(2*time.Second, 2)) + + hits, _, unavailable := SearchAll(context.Background(), "github", "", 20, SearchOptions{SourceTimeout: 400 * time.Millisecond, PopularityWait: -1}) + if len(unavailable) != 1 || unavailable[0].Source != "official" || !strings.HasPrefix(unavailable[0].Reason, "timeout after") { + t.Fatalf("unavailable = %+v, want the official source timing out", unavailable) + } + if len(hits) == 0 || hits[0].Entry.ID != "io.github.github/github-mcp-server" { + t.Fatalf("the expansion hits must still be shown, GitHub first; got %v", idsOf(hits)) + } + if hits[0].FromCache { + t.Error("expansion hits are live, not from the cache") + } +} + +// recordingProvider is a PopularityProvider that holds no stars and records +// every key SearchAll asks it to resolve. +type recordingProvider struct { + mu sync.Mutex + keys []string +} + +func (r *recordingProvider) Lookup(string) (int, LookupState) { return 0, LookupAbsent } +func (r *recordingProvider) Resolve(_ context.Context, keys []string, _ time.Duration) { + r.mu.Lock() + r.keys = append(r.keys, keys...) + r.mu.Unlock() +} + +func TestSearchAll_PopularityPrefetchCappedAtLimit(t *testing.T) { + var items []string + for i := 0; i < 40; i++ { + items = append(items, fmt.Sprintf(`{"id":"acme/github-%02d","name":"github %02d","source_code_url":"https://github.com/org/r%02d"}`, i, i, i)) + } + src := jsonServer(t, "["+strings.Join(items, ",")+"]") + withTestRegistries(t, []RegistryEntry{{ID: "flat", Name: "Flat", ServersURL: src.URL}}) + rec := &recordingProvider{} + t.Cleanup(SetPopularityProviderForTest(rec)) + + hits, _, _ := SearchAll(context.Background(), "github", "", 5, SearchOptions{PopularityWait: -1}) + if len(hits) != 5 { + t.Fatalf("got %d hits", len(hits)) + } + if len(rec.keys) == 0 || len(rec.keys) > 5 { + t.Fatalf("enqueued %d keys, want 1..5 (the typed-query cap is `limit`)", len(rec.keys)) + } + top := map[string]bool{} + for _, h := range hits { + k, _ := GitHubRepoKey(h.Entry.SourceCodeURL) + top[k] = true + } + for _, k := range rec.keys { + if !top[k] { + t.Errorf("enqueued %s, which is not among the returned top hits", k) + } + } +} + +func TestSearchAll_PopularityPrefetchSkipsBorrowedRepos(t *testing.T) { + corpus := []json.RawMessage{ + mustJSON(map[string]interface{}{ + "server": map[string]interface{}{"name": "agency.ottobot/github", "repository": map[string]string{"url": "https://github.com/modelcontextprotocol/registry"}}, + "_meta": map[string]interface{}{officialMetaKey: map[string]interface{}{"status": "active", "isLatest": true}}, + }), + mustJSON(map[string]interface{}{ + "server": map[string]interface{}{"name": "io.github.acme/github", "repository": map[string]string{"url": "https://github.com/acme/github"}}, + "_meta": map[string]interface{}{officialMetaKey: map[string]interface{}{"status": "active", "isLatest": true}}, + }), + } + srv := httptest.NewServer(RecordedRegistryHandlerForTest(corpus)) + t.Cleanup(srv.Close) + withTestRegistries(t, []RegistryEntry{{ID: "official", Name: "Official", ServersURL: srv.URL + "/v0.1/servers", Protocol: protocolOfficial, Provenance: "official"}}) + t.Cleanup(AllowPrivateRegistryFetchForTest()) + t.Cleanup(ResetListingCacheForTest) + rec := &recordingProvider{} + t.Cleanup(SetPopularityProviderForTest(rec)) + + SearchAll(context.Background(), "github", "", 10, SearchOptions{PopularityWait: -1}) + if strings.Join(rec.keys, ",") != "acme/github" { + t.Fatalf("enqueued %v, want only the repo its publisher owns", rec.keys) + } +} + +func mustJSON(v interface{}) json.RawMessage { + b, err := json.Marshal(v) + if err != nil { + panic(err) + } + return b +} + +func TestBuildSections_PopularDedupesSameTitleAcrossSources(t *testing.T) { + pool := []CatalogHit{ + {Source: "reference", Title: "fetch", Popularity: intPop(900), Entry: ServerEntry{ID: "fetch"}}, + {Source: "docker", Title: "mcp/fetch", Popularity: &Popularity{Installs: intPtr(1000000)}, Entry: ServerEntry{ID: "mcp/fetch"}}, + {Source: "docker", Title: "fetch-mcp", Popularity: &Popularity{Installs: intPtr(5)}, Entry: ServerEntry{ID: "fetch-mcp"}}, + } + sections := buildSections(pool, "") + var titles []string + for _, h := range sections.Popular { + titles = append(titles, h.Title) + } + if len(titles) != 2 { + t.Fatalf("Popular = %v, want fetch once plus fetch-mcp", titles) + } + hasFetchMCP := false + fetchCount := 0 + for _, title := range titles { + if title == "fetch-mcp" { + hasFetchMCP = true + } else { + fetchCount++ + } + } + if !hasFetchMCP || fetchCount != 1 { + t.Fatalf("Popular = %v", titles) + } +} + +func TestBuildSections_PopularSkipsBorrowedStars(t *testing.T) { + stub := &stubPopularityProvider{stars: map[string]int{"modelcontextprotocol/registry": 5000, "github/github-mcp-server": 21000}} + t.Cleanup(SetPopularityProviderForTest(stub)) + reg := officialReg() + pool := []CatalogHit{ + BuildCatalogHit(reg, ServerEntry{ID: "agency.ottobot/x", Name: "agency.ottobot/x", SourceCodeURL: "https://github.com/modelcontextprotocol/registry"}), + BuildCatalogHit(reg, ServerEntry{ID: "io.github.github/github-mcp-server", Name: "io.github.github/github-mcp-server", SourceCodeURL: "https://github.com/github/github-mcp-server"}), + } + sections := buildSections(pool, "") + if len(sections.Popular) != 1 || sections.Popular[0].Entry.ID != "io.github.github/github-mcp-server" { + t.Fatalf("Popular = %v, want only the publisher-owned repo", idsOf(sections.Popular)) + } +} diff --git a/internal/registries/catalog_warm_behind.go b/internal/registries/catalog_warm_behind.go new file mode 100644 index 000000000..b5f562163 --- /dev/null +++ b/internal/registries/catalog_warm_behind.go @@ -0,0 +1,148 @@ +package registries + +import ( + "context" + "errors" + "sync" + "time" +) + +// catalog_warm_behind.go: "warm behind" for a slow catalog source (Spec 109 +// D36.11, FR-060). +// +// The official registry answers a cold ?search= in 4-25 s, longer than the 5 s +// per-source budget (FR-060). A timed-out fetch used to be cancelled, so nothing +// was cached and every later search started cold again. Now each network fetch +// runs under its OWN context (a copy of the caller's values, no cancellation, +// bounded by catalogWarmBehindTimeout) while SearchAll still waits only the +// source budget and reports "timeout after 5s" exactly as before. A fetch that +// finishes later calls cacheListing, so the NEXT search that times out is +// answered from the warmed cache (D35), and a warm registry answers live. +// +// Bounded on purpose: at most catalogWarmBehindSlots fetches per source +// (listingKey) may run past the budget at once. A source already holding all its +// slots falls back to the old behaviour, cancelled at the budget. The built-in +// reference source is served in-binary and never runs in the background. + +const ( + // catalogWarmBehindTimeout bounds one background fetch. + catalogWarmBehindTimeout = 30 * time.Second + // catalogWarmBehindSlots is how many background fetches one source may hold. + catalogWarmBehindSlots = 2 +) + +var warmBehind = struct { + mu sync.Mutex + timeout time.Duration + slots int + inflight map[string]int +}{timeout: catalogWarmBehindTimeout, slots: catalogWarmBehindSlots, inflight: make(map[string]int)} + +// acquireWarmBehind takes one background slot for key, returning the +// background timeout, or false when the source already holds all of them. +func acquireWarmBehind(key string) (time.Duration, bool) { + warmBehind.mu.Lock() + defer warmBehind.mu.Unlock() + if warmBehind.inflight[key] >= warmBehind.slots { + return 0, false + } + warmBehind.inflight[key]++ + return warmBehind.timeout, true +} + +func releaseWarmBehind(key string) { + warmBehind.mu.Lock() + defer warmBehind.mu.Unlock() + if warmBehind.inflight[key] <= 1 { + delete(warmBehind.inflight, key) + return + } + warmBehind.inflight[key]-- +} + +// sourceFetchFunc fetches one source's entries. onPartial receives hits that +// are already known while the fetch is still running (see +// searchCatalogSourceProgress); it may be ignored. +type sourceFetchFunc func(ctx context.Context, onPartial func([]ServerEntry)) ([]ServerEntry, error) + +// sourceFetchOutcome is what SearchAll gets back for one source. entries is the +// fetch's result (on error: whatever hits arrived anyway, e.g. the official +// protocol's expansion hits); timedOut says the budget, not the source, ended +// the wait. +type sourceFetchOutcome struct { + entries []ServerEntry + err error + timedOut bool +} + +type partialEntries struct { + mu sync.Mutex + entries []ServerEntry +} + +func (p *partialEntries) set(entries []ServerEntry) { + p.mu.Lock() + p.entries = entries + p.mu.Unlock() +} + +func (p *partialEntries) get() []ServerEntry { + p.mu.Lock() + defer p.mu.Unlock() + return p.entries +} + +// fetchSourceWithinBudget runs fetch for one source and waits at most budget +// for it. On success the entries are cached as the source's listing (D35). When +// the budget runs out first and the source holds a free background slot, the +// fetch keeps running (bounded by the warm-behind timeout, independent of ctx) +// and caches its listing when it lands. +func fetchSourceWithinBudget(ctx context.Context, reg RegistryEntry, budget time.Duration, fetch sourceFetchFunc) sourceFetchOutcome { + sctx, cancel := context.WithTimeout(ctx, budget) + defer cancel() + + key := listingKey(®) + var bgTimeout time.Duration + background := false + if reg.Protocol != protocolReference { + bgTimeout, background = acquireWarmBehind(key) + } + + partial := &partialEntries{} + run := func(c context.Context) sourceFetchOutcome { + entries, err := fetch(c, partial.set) + if err == nil { + cacheListing(®, entries) + } + return sourceFetchOutcome{entries: entries, err: err} + } + + if !background { + out := run(sctx) + out.timedOut = out.err != nil && sctx.Err() != nil + return out + } + + bctx, bcancel := context.WithTimeout(context.WithoutCancel(ctx), bgTimeout) + done := make(chan sourceFetchOutcome, 1) + go func() { + defer releaseWarmBehind(key) + defer bcancel() + done <- run(bctx) + }() + + select { + case out := <-done: + out.timedOut = out.err != nil && sctx.Err() != nil + return out + case <-sctx.Done(): + // A result that landed at the same instant still counts. + select { + case out := <-done: + out.timedOut = out.err != nil + return out + default: + } + return sourceFetchOutcome{entries: partial.get(), err: errors.New("source budget exceeded"), timedOut: true} + } +} diff --git a/internal/registries/catalog_warm_behind_test.go b/internal/registries/catalog_warm_behind_test.go new file mode 100644 index 000000000..107f4eb58 --- /dev/null +++ b/internal/registries/catalog_warm_behind_test.go @@ -0,0 +1,200 @@ +package registries + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" +) + +// Spec 109 fix-catalog-rank T172 (D36.11): a fetch that outlives the 5s source +// budget finishes in the background and warms the listing cache; the budget, +// the unavailable[] wording and the FR-060 contract do not change. + +func waitFor(t *testing.T, what string, within time.Duration, cond func() bool) { + t.Helper() + deadline := time.Now().Add(within) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("timed out waiting for %s", what) +} + +// slowThenHang answers the first request after `delay` and then hangs every +// later one until released, counting how many requests are in flight. +type slowThenHang struct { + body string + delay time.Duration + released chan struct{} + calls atomic.Int32 + inflight atomic.Int32 + cancels atomic.Int32 +} + +func (s *slowThenHang) ServeHTTP(w http.ResponseWriter, r *http.Request) { + n := s.calls.Add(1) + s.inflight.Add(1) + defer s.inflight.Add(-1) + if n == 1 { + select { + case <-time.After(s.delay): + case <-r.Context().Done(): + s.cancels.Add(1) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(s.body)) + return + } + select { + case <-s.released: + case <-r.Context().Done(): + s.cancels.Add(1) + } +} + +func newSlowThenHang(t *testing.T, body string, delay time.Duration) (*slowThenHang, *httptest.Server) { + t.Helper() + h := &slowThenHang{body: body, delay: delay, released: make(chan struct{})} + srv := httptest.NewServer(h) + // close(released) runs BEFORE srv.Close (LIFO), so Close never waits on a + // request the test left hanging. + t.Cleanup(srv.Close) + t.Cleanup(func() { close(h.released) }) + return h, srv +} + +func TestSearchAll_TimedOutFetchWarmsTheListingCache(t *testing.T) { + t.Cleanup(SetCatalogWarmBehindForTest(2*time.Second, 2)) + t.Cleanup(ResetListingCacheForTest) + h, srv := newSlowThenHang(t, `[{"id":"acme/github-late","name":"github late"}]`, 300*time.Millisecond) + reg := RegistryEntry{ID: "slow", Name: "Slow", ServersURL: srv.URL} + withTestRegistries(t, []RegistryEntry{reg}) + + opts := SearchOptions{SourceTimeout: 100 * time.Millisecond, PopularityWait: -1} + hits, _, unavailable := SearchAll(context.Background(), "github", "", 10, opts) + if len(hits) != 0 || len(unavailable) != 1 || unavailable[0].Reason != "timeout after 100ms" || unavailable[0].Fallback != "" { + t.Fatalf("first search: hits=%v unavailable=%+v, want an empty timeout (nothing cached yet)", idsOf(hits), unavailable) + } + + waitFor(t, "the background fetch to warm the cache", 2*time.Second, func() bool { + _, _, ok := cachedListing(®) + return ok + }) + + // The server now hangs: the second search times out too, and is answered + // from the warmed listing. + hits, _, unavailable = SearchAll(context.Background(), "github", "", 10, opts) + if len(hits) != 1 || hits[0].Entry.ID != "acme/github-late" || !hits[0].FromCache { + t.Fatalf("second search: hits=%v, want the warmed hit marked from cache", idsOf(hits)) + } + if len(unavailable) != 1 || unavailable[0].Fallback != FallbackCachedListing || unavailable[0].Reason != "timeout after 100ms" { + t.Fatalf("second search unavailable = %+v", unavailable) + } + if h.calls.Load() < 2 { + t.Fatalf("expected a second request, got %d", h.calls.Load()) + } +} + +func TestSearchAll_WarmBehindSlotsBounded(t *testing.T) { + t.Cleanup(SetCatalogWarmBehindForTest(5*time.Second, 2)) + t.Cleanup(ResetListingCacheForTest) + h := &slowThenHang{released: make(chan struct{})} + h.calls.Store(1) // every request hangs + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + t.Cleanup(func() { close(h.released) }) + withTestRegistries(t, []RegistryEntry{{ID: "hang", Name: "Hang", ServersURL: srv.URL}}) + + opts := SearchOptions{SourceTimeout: 150 * time.Millisecond, PopularityWait: -1} + for i := 0; i < 3; i++ { + _, _, unavailable := SearchAll(context.Background(), "x", "", 10, opts) + if len(unavailable) != 1 || unavailable[0].Reason != "timeout after 150ms" { + t.Fatalf("search %d unavailable = %+v", i, unavailable) + } + } + // The third search found both slots taken and ran under the plain budget: + // its request is cancelled at the timeout. + waitFor(t, "the third request to be cancelled", 2*time.Second, func() bool { return h.cancels.Load() >= 1 }) + time.Sleep(50 * time.Millisecond) + if got := h.inflight.Load(); got > 2 { + t.Fatalf("%d requests in flight, want ≤ 2 background fetches per source", got) + } + if got := h.cancels.Load(); got != 1 { + t.Fatalf("cancelled requests = %d, want exactly the third", got) + } +} + +func TestSearchAll_WarmBehindHonoursItsOwnTimeout(t *testing.T) { + t.Cleanup(SetCatalogWarmBehindForTest(250*time.Millisecond, 2)) + t.Cleanup(ResetListingCacheForTest) + h := &slowThenHang{released: make(chan struct{})} + h.calls.Store(1) + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + t.Cleanup(func() { close(h.released) }) + withTestRegistries(t, []RegistryEntry{{ID: "hang", Name: "Hang", ServersURL: srv.URL}}) + + SearchAll(context.Background(), "x", "", 10, SearchOptions{SourceTimeout: 50 * time.Millisecond, PopularityWait: -1}) + waitFor(t, "the background request to start", time.Second, func() bool { return h.inflight.Load() == 1 }) + // The background context's own 250ms timeout cancels it. + waitFor(t, "the warm-behind timeout to cancel the fetch", 3*time.Second, func() bool { return h.inflight.Load() == 0 }) + if h.cancels.Load() != 1 { + t.Fatalf("cancels = %d, want the background fetch cancelled by its own timeout", h.cancels.Load()) + } +} + +func TestSearchAll_CallerCancelDoesNotCancelWarmBehind(t *testing.T) { + t.Cleanup(SetCatalogWarmBehindForTest(3*time.Second, 2)) + t.Cleanup(ResetListingCacheForTest) + h, srv := newSlowThenHang(t, `[{"id":"acme/github-late","name":"github late"}]`, 250*time.Millisecond) + reg := RegistryEntry{ID: "slow", Name: "Slow", ServersURL: srv.URL} + withTestRegistries(t, []RegistryEntry{reg}) + + ctx, cancel := context.WithCancel(context.Background()) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + SearchAll(ctx, "github", "", 10, SearchOptions{SourceTimeout: 5 * time.Second, PopularityWait: -1}) + }() + waitFor(t, "the request to arrive", time.Second, func() bool { return h.calls.Load() == 1 }) + cancel() + wg.Wait() + + waitFor(t, "the background fetch to finish and warm the cache", 3*time.Second, func() bool { + _, _, ok := cachedListing(®) + return ok + }) + if h.cancels.Load() != 0 { + t.Fatal("the caller's cancellation must not cancel the in-flight fetch") + } +} + +func TestSearchAll_ReferenceSourceNeverRunsInBackground(t *testing.T) { + if _, ok := acquireWarmBehind("probe"); !ok { + t.Fatal("a slot should be free") + } + releaseWarmBehind("probe") + + reg := RegistryEntry{ID: "reference", Name: "Reference", Protocol: protocolReference, ServersURL: "builtin://reference"} + key := listingKey(®) + out := fetchSourceWithinBudget(context.Background(), reg, time.Second, func(context.Context, func([]ServerEntry)) ([]ServerEntry, error) { + warmBehind.mu.Lock() + held := warmBehind.inflight[key] + warmBehind.mu.Unlock() + if held != 0 { + t.Errorf("the reference source took %d background slot(s)", held) + } + return nil, nil + }) + if out.err != nil { + t.Fatal(out.err) + } +} diff --git a/internal/registries/listing_cache.go b/internal/registries/listing_cache.go index c845ccf63..a026455e8 100644 --- a/internal/registries/listing_cache.go +++ b/internal/registries/listing_cache.go @@ -134,7 +134,7 @@ func pruneListingCache(regs []RegistryEntry) { } // matchCachedEntry is the fallback filter: a case-insensitive substring of the -// trimmed query in the entry's name, description OR id. The live path's +// trimmed query in the entry's name, title, description OR id. The live path's // filterServers skips the id, but the official registry's own search matches // names, so including the id is what lets "github" find "io.github.*". An empty // query matches everything (browse). @@ -144,6 +144,7 @@ func matchCachedEntry(e *ServerEntry, q string) bool { return true } return strings.Contains(strings.ToLower(e.Name), q) || + strings.Contains(strings.ToLower(e.Title), q) || strings.Contains(strings.ToLower(e.Description), q) || strings.Contains(strings.ToLower(e.ID), q) } diff --git a/internal/registries/official.go b/internal/registries/official.go index 29ab08971..85dd33479 100644 --- a/internal/registries/official.go +++ b/internal/registries/official.go @@ -5,7 +5,10 @@ import ( "encoding/json" "fmt" "net/url" + "regexp" + "strconv" "strings" + "sync" "github.com/smart-mcp-proxy/mcpproxy-go/internal/experiments" ) @@ -46,25 +49,10 @@ func fetchOfficialServers(ctx context.Context, reg *RegistryEntry, guesser *expe var all []ServerEntry cursor := "" for page := 0; page < officialMaxPages; page++ { - reqURL, err := buildOfficialURL(reg.ServersURL, query, cursor) + servers, next, err := fetchOfficialPage(ctx, reg, query, cursor) if err != nil { - return nil, fmt.Errorf("invalid registry URL %q: %w", reg.ServersURL, err) + return nil, err } - - // registryGet sets the standard headers (Accept/User-Agent/auth), checks - // the status, and auto-retries transient failures (slow pages, 5xx/429) - // so a single hiccup mid-pagination no longer fails the whole listing. - body, err := registryGet(ctx, reg, reqURL) - if err != nil { - return nil, fmt.Errorf("failed to fetch servers: %w", err) - } - - var rawData interface{} - if err := json.Unmarshal(body, &rawData); err != nil { - return nil, fmt.Errorf("invalid JSON from registry: %w", err) - } - - servers, next := parseOfficialPage(rawData) all = append(all, servers...) // Enough to answer the caller? Stop paying for pages nobody will see. @@ -86,6 +74,207 @@ func fetchOfficialServers(ctx context.Context, reg *RegistryEntry, guesser *expe return all, nil } +// fetchOfficialPage fetches and parses ONE page of an official v0.1 registry +// listing, returning its classified entries and the opaque nextCursor (empty +// when exhausted). +func fetchOfficialPage(ctx context.Context, reg *RegistryEntry, query, cursor string) ([]ServerEntry, string, error) { + reqURL, err := buildOfficialURL(reg.ServersURL, query, cursor) + if err != nil { + return nil, "", fmt.Errorf("invalid registry URL %q: %w", reg.ServersURL, err) + } + + // registryGet sets the standard headers (Accept/User-Agent/auth), checks + // the status, and auto-retries transient failures (slow pages, 5xx/429) + // so a single hiccup mid-pagination no longer fails the whole listing. + body, err := registryGet(ctx, reg, reqURL) + if err != nil { + return nil, "", fmt.Errorf("failed to fetch servers: %w", err) + } + + var rawData interface{} + if err := json.Unmarshal(body, &rawData); err != nil { + return nil, "", fmt.Errorf("invalid JSON from registry: %w", err) + } + + servers, next := parseOfficialPage(rawData) + return servers, next, nil +} + +// officialExpansionPattern gates which typed queries are expanded: a plain +// name-like token or phrase, never a path, a markup fragment or a long string. +var officialExpansionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{1,63}$`) + +// officialExpansionQueries returns the extra registry-side queries a typed q +// is expanded into (Spec 109 D36.2). The official registry's `search` is a +// substring of server.name only and returns names in byte order, so a plain +// `github` page is 100 alphabetical `io.github.*` entries and never reaches +// io.github.github/github-mcp-server. Two anchored queries recover it: +// `.q/` matches the publisher (owner) label and `/q` a name segment that +// starts with q. A multi-word q additionally searches its hyphenated phrase, +// because names hold no spaces. Nil when q is not expandable. +func officialExpansionQueries(q string) []string { + words := strings.Fields(q) + if len(words) == 0 { + return nil + } + qh := strings.Join(words, "-") + if !officialExpansionPattern.MatchString(qh) { + return nil + } + out := make([]string, 0, 3) + if len(words) > 1 { + out = append(out, qh) + } + return append(out, "."+qh+"/", "/"+qh) +} + +// fetchOfficialCatalog is the typed-query fetch for the official protocol +// (D36.2): the main query plus its expansions, ONE page (100) each, requested +// concurrently. Entries merge in this order: owner hits, segment hits, phrase +// hits, main hits, version-collapsed by name. The MAIN query decides whether +// the source is available: its error is returned (with whatever the +// expansions found), while an expansion failure is best-effort and ignored. +func fetchOfficialCatalog(ctx context.Context, reg *RegistryEntry, q string) ([]ServerEntry, error) { + return fetchOfficialCatalogProgress(ctx, reg, q, nil) +} + +// fetchOfficialCatalogProgress is fetchOfficialCatalog that also calls +// onExpansion, serialized, with the merged hits of the expansion queries +// finished so far each time one lands, so a caller that gives up waiting for +// the (slower) main query can still use them. onExpansion may be nil. +func fetchOfficialCatalogProgress(ctx context.Context, reg *RegistryEntry, q string, onExpansion func([]ServerEntry)) ([]ServerEntry, error) { + expansions := officialExpansionQueries(q) + // Merge order: owner (".x/"), segment ("/x"), phrase ("x-y"), then main. + var queries []string + for _, prefix := range []string{".", "/"} { + for _, e := range expansions { + if strings.HasPrefix(e, prefix) { + queries = append(queries, e) + } + } + } + for _, e := range expansions { + if !strings.HasPrefix(e, ".") && !strings.HasPrefix(e, "/") { + queries = append(queries, e) + } + } + queries = append(queries, q) + mainIdx := len(queries) - 1 + + type pageResult struct { + entries []ServerEntry + err error + } + var mu sync.Mutex + results := make([]pageResult, len(queries)) + var wg sync.WaitGroup + for i, query := range queries { + wg.Add(1) + go func(i int, query string) { + defer wg.Done() + entries, _, err := fetchOfficialPage(ctx, reg, query, "") + mu.Lock() + defer mu.Unlock() + results[i] = pageResult{entries: entries, err: err} + if onExpansion == nil || i == mainIdx || err != nil { + return + } + var merged []ServerEntry + for j := 0; j < mainIdx; j++ { + merged = append(merged, results[j].entries...) + } + onExpansion(collapseOfficialVersions(merged)) + }(i, query) + } + wg.Wait() + + var merged []ServerEntry + for _, r := range results { + merged = append(merged, r.entries...) + } + return collapseOfficialVersions(merged), results[mainIdx].err +} + +// collapseOfficialVersions keeps ONE entry per server name, at the position of +// the name's first occurrence (D36.4). Without version=latest, or on a generic +// endpoint with no publication metadata, one name arrives once per published +// version. The kept entry is the one with an explicit isLatest:true, else the +// highest dotted-numeric version (a prerelease suffix is ignored), else the +// last one seen. +func collapseOfficialVersions(entries []ServerEntry) []ServerEntry { + best := make(map[string]int, len(entries)) + order := make([]string, 0, len(entries)) + for i := range entries { + key := entries[i].Name + if key == "" { + key = entries[i].ID + } + cur, seen := best[key] + if !seen { + order = append(order, key) + best[key] = i + continue + } + if !keepsCurrentVersion(&entries[i], &entries[cur]) { + best[key] = i + } + } + out := make([]ServerEntry, 0, len(order)) + for _, key := range order { + out = append(out, entries[best[key]]) + } + return out +} + +// keepsCurrentVersion reports whether current stays the kept entry against a +// later candidate. A tie lets the later candidate win (last one seen). +func keepsCurrentVersion(candidate, current *ServerEntry) bool { + if candidate.isLatest != current.isLatest { + return current.isLatest + } + return compareDottedVersions(candidate.Version, current.Version) < 0 +} + +// compareDottedVersions compares two dotted-numeric versions ("1.0.10" > +// "1.0.4"), ignoring any "-prerelease" or "+build" suffix. A non-numeric +// segment reads as 0; a missing segment reads as 0. +func compareDottedVersions(a, b string) int { + as, bs := versionSegments(a), versionSegments(b) + for i := 0; i < len(as) || i < len(bs); i++ { + var x, y int + if i < len(as) { + x = as[i] + } + if i < len(bs) { + y = bs[i] + } + if x != y { + if x < y { + return -1 + } + return 1 + } + } + return 0 +} + +func versionSegments(v string) []int { + v = strings.TrimPrefix(strings.TrimSpace(v), "v") + if i := strings.IndexAny(v, "-+"); i >= 0 { + v = v[:i] + } + if v == "" { + return nil + } + parts := strings.Split(v, ".") + out := make([]int, len(parts)) + for i, p := range parts { + n, _ := strconv.Atoi(p) + out[i] = n + } + return out +} + // buildOfficialURL appends version=latest, the page limit, an optional search // query, and the page cursor to the registry's servers endpoint. func buildOfficialURL(base, query, cursor string) (string, error) { @@ -184,9 +373,28 @@ func parseOfficialItems(items []interface{}) []ServerEntry { if entry.Name == "" && entry.ID == "" { continue } + entry.isLatest = officialExplicitLatest(itemMap, serverMap) servers = append(servers, entry) } - return servers + return collapseOfficialVersions(servers) +} + +// officialExplicitLatest reports whether the entry's publication metadata says +// isLatest:true explicitly (absent reads as false: that is not an assertion). +func officialExplicitLatest(wrapper, server map[string]interface{}) bool { + meta := officialMetaBlock(wrapper) + if meta == nil { + meta = officialMetaBlock(server) + } + if meta == nil { + return false + } + for _, key := range []string{"isLatest", "is_latest"} { + if b, ok := meta[key].(bool); ok { + return b + } + } + return false } // officialEntryIncluded reports whether an entry should be surfaced: by default @@ -239,6 +447,8 @@ func officialServerToEntry(server map[string]interface{}) ServerEntry { entry := ServerEntry{ Name: firstString(server, "name"), Description: firstString(server, "description"), + Title: firstString(server, "title"), + Version: firstString(server, "version"), } entry.ID = entry.Name diff --git a/internal/registries/official_catalog_test.go b/internal/registries/official_catalog_test.go new file mode 100644 index 000000000..dc2ccf56b --- /dev/null +++ b/internal/registries/official_catalog_test.go @@ -0,0 +1,255 @@ +package registries + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +// Spec 109 fix-catalog-rank T167 (D36.2, D36.4, D36.10): the official +// protocol's typed-query fetch (one page per query, concurrent), title and +// version parsing, and version collapse. + +func TestOfficialServerToEntry_ReadsTitleAndVersion(t *testing.T) { + entry := officialServerToEntry(map[string]interface{}{ + "name": "io.github.github/github-mcp-server", + "title": "GitHub", + "description": "d", + "version": "1.13.0", + }) + if entry.Title != "GitHub" { + t.Errorf("Title = %q, want the server.json title", entry.Title) + } + if entry.Version != "1.13.0" { + t.Errorf("Version = %q, want 1.13.0", entry.Version) + } + if entry.Name != "io.github.github/github-mcp-server" || entry.ID != entry.Name { + t.Errorf("Name/ID must stay the reverse-DNS name, got %q / %q", entry.Name, entry.ID) + } +} + +func officialEntry(name, version string, latest bool) ServerEntry { + return ServerEntry{ID: name, Name: name, Version: version, isLatest: latest} +} + +func TestCollapseOfficialVersions_KeepsLatestAtFirstPosition(t *testing.T) { + in := []ServerEntry{ + officialEntry("a/x", "1.0.3", false), + officialEntry("b/y", "2.0.0", false), + officialEntry("a/x", "1.0.10", false), + officialEntry("a/x", "1.0.4", false), + } + out := collapseOfficialVersions(in) + if len(out) != 2 { + t.Fatalf("got %d entries, want 2: %+v", len(out), out) + } + if out[0].Name != "a/x" || out[0].Version != "1.0.10" { + t.Errorf("a/x must keep the highest dotted version at its first position, got %+v", out[0]) + } + if out[1].Name != "b/y" { + t.Errorf("order must follow first occurrence, got %+v", out) + } +} + +func TestCollapseOfficialVersions_ExplicitIsLatestWins(t *testing.T) { + out := collapseOfficialVersions([]ServerEntry{ + officialEntry("a/x", "9.9.9", false), + officialEntry("a/x", "1.0.3", true), + }) + if len(out) != 1 || out[0].Version != "1.0.3" { + t.Fatalf("an explicit isLatest:true must beat a higher number, got %+v", out) + } +} + +func TestCollapseOfficialVersions_PrereleaseSuffixIgnoredAndLastSeenTies(t *testing.T) { + out := collapseOfficialVersions([]ServerEntry{ + officialEntry("a/x", "1.2.0-rc.1", false), + officialEntry("a/x", "1.10.0-beta", false), + }) + if len(out) != 1 || out[0].Version != "1.10.0-beta" { + t.Fatalf("got %+v", out) + } + tie := collapseOfficialVersions([]ServerEntry{ + {ID: "a/x", Name: "a/x", Description: "first"}, + {ID: "a/x", Name: "a/x", Description: "last"}, + }) + if len(tie) != 1 || tie[0].Description != "last" { + t.Fatalf("no version info: the last one seen wins, got %+v", tie) + } +} + +func TestParseOfficialItems_CollapsesVersionsWithoutMeta(t *testing.T) { + var items []interface{} + for _, v := range []string{"1.0.3", "1.0.10", "1.0.4"} { + items = append(items, map[string]interface{}{"server": map[string]interface{}{"name": "a/x", "version": v}}) + } + got := parseOfficialItems(items) + if len(got) != 1 || got[0].Version != "1.0.10" { + t.Fatalf("got %+v, want one a/x at 1.0.10", got) + } +} + +func TestOfficialExpansionQueries(t *testing.T) { + cases := []struct { + q string + want []string + }{ + {"github", []string{".github/", "/github"}}, + {" GitHub ", []string{".GitHub/", "/GitHub"}}, + {"github actions", []string{"github-actions", ".github-actions/", "/github-actions"}}, + {"a/b", nil}, + {"", nil}, + {"", nil}, + {"x", nil}, + {strings.Repeat("a", 65), nil}, + } + for _, c := range cases { + got := officialExpansionQueries(c.q) + if strings.Join(got, "|") != strings.Join(c.want, "|") { + t.Errorf("officialExpansionQueries(%q) = %v, want %v", c.q, got, c.want) + } + } + if len(officialExpansionQueries(strings.Repeat("a", 64))) != 2 { + t.Error("a 64-character query is still expanded") + } +} + +// countingRegistry serves the recorded corpus and records every request's +// search and cursor parameters. +type countingRegistry struct { + mu sync.Mutex + searches []string + cursors []string +} + +func (c *countingRegistry) handler(inner http.Handler, fail map[string]int) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.mu.Lock() + c.searches = append(c.searches, r.URL.Query().Get("search")) + if cur := r.URL.Query().Get("cursor"); cur != "" { + c.cursors = append(c.cursors, cur) + } + c.mu.Unlock() + if code, ok := fail[r.URL.Query().Get("search")]; ok { + http.Error(w, "boom", code) + return + } + inner.ServeHTTP(w, r) + }) +} + +func newOfficialFixtureRegistry(t *testing.T, fail map[string]int) (*RegistryEntry, *countingRegistry) { + t.Helper() + _, official := loadCatalogFixture(t) + counter := &countingRegistry{} + srv := httptest.NewServer(counter.handler(RecordedRegistryHandlerForTest(official.Corpus), fail)) + t.Cleanup(srv.Close) + t.Cleanup(AllowPrivateRegistryFetchForTest()) + return &RegistryEntry{ID: "official", Name: "Official", ServersURL: srv.URL + "/v0.1/servers", Protocol: protocolOfficial, Provenance: "official"}, counter +} + +func entryIDs(entries []ServerEntry) []string { + ids := make([]string, 0, len(entries)) + for _, e := range entries { + ids = append(ids, e.ID) + } + return ids +} + +func TestFetchOfficialCatalog_OnePagePerQueryConcurrently(t *testing.T) { + reg, counter := newOfficialFixtureRegistry(t, nil) + entries, err := fetchOfficialCatalog(context.Background(), reg, "github") + if err != nil { + t.Fatal(err) + } + if len(counter.searches) != 3 { + t.Fatalf("requests = %v, want 3 (main + owner + segment)", counter.searches) + } + if len(counter.cursors) != 0 { + t.Fatalf("a typed query fetches ONE page per query, saw cursors %v", counter.cursors) + } + ids := entryIDs(entries) + // Merge order: owner hits first, then segment hits, then the main page. + if ids[0] != "io.github.github/github-mcp-server" { + t.Fatalf("owner hit must lead, got %v", ids[:3]) + } + seen := map[string]bool{} + for _, id := range ids { + if seen[id] { + t.Fatalf("id %s repeated", id) + } + seen[id] = true + } + if !seen["com.mcparmory/github"] || !seen["io.github.3121n/statfin-mcp"] { + t.Fatalf("segment and main hits must both be present (%d entries)", len(ids)) + } + if len(ids) > typedFetchCap { + t.Fatalf("%d entries exceed the %d per-source ceiling", len(ids), typedFetchCap) + } +} + +func TestFetchOfficialCatalog_ExpansionErrorIsBestEffort(t *testing.T) { + reg, _ := newOfficialFixtureRegistry(t, map[string]int{"/github": http.StatusInternalServerError}) + entries, err := fetchOfficialCatalog(context.Background(), reg, "github") + if err != nil { + t.Fatalf("an expansion failure must not fail the source: %v", err) + } + ids := strings.Join(entryIDs(entries), ",") + if !strings.Contains(ids, "io.github.github/github-mcp-server") { + t.Fatal("owner hit missing") + } + if !strings.Contains(ids, "io.github.3121n/statfin-mcp") { + t.Fatal("main hits missing") + } +} + +func TestFetchOfficialCatalog_MainErrorReturnsExpansionHitsAndError(t *testing.T) { + reg, _ := newOfficialFixtureRegistry(t, map[string]int{"github": http.StatusBadRequest}) + entries, err := fetchOfficialCatalog(context.Background(), reg, "github") + if err == nil { + t.Fatal("the main query decides availability: its error must be returned") + } + if !strings.Contains(strings.Join(entryIDs(entries), ","), "io.github.github/github-mcp-server") { + t.Fatalf("expansion hits must still be returned, got %v", entryIDs(entries)) + } +} + +func TestFetchOfficialCatalog_NoExpansionForAWeirdQuery(t *testing.T) { + reg, counter := newOfficialFixtureRegistry(t, nil) + if _, err := fetchOfficialCatalog(context.Background(), reg, "a/b"); err != nil { + t.Fatal(err) + } + if len(counter.searches) != 1 { + t.Fatalf("requests = %v, want only the main query", counter.searches) + } +} + +func TestFetchOfficialCatalog_PhraseQueryAddsHyphenatedPhrase(t *testing.T) { + reg, counter := newOfficialFixtureRegistry(t, nil) + entries, err := fetchOfficialCatalog(context.Background(), reg, "github actions") + if err != nil { + t.Fatal(err) + } + if len(counter.searches) != 4 { + t.Fatalf("requests = %v, want main + phrase + owner + segment", counter.searches) + } + ids := strings.Join(entryIDs(entries), ",") + if !strings.Contains(ids, "io.github.ofershap/github-actions") { + t.Fatalf("the hyphenated phrase must find github-actions servers, got %s", ids) + } +} + +func TestParseOfficialPage_PreservesRawJSONShape(t *testing.T) { + var raw interface{} + if err := json.Unmarshal([]byte(`{"servers":[{"server":{"name":"a/b","title":"T"},"_meta":{}}],"metadata":{"nextCursor":"x"}}`), &raw); err != nil { + t.Fatal(err) + } + servers, next := parseOfficialPage(raw) + if len(servers) != 1 || servers[0].Title != "T" || next != "x" { + t.Fatalf("got %+v next=%q", servers, next) + } +} diff --git a/internal/registries/rank_test.go b/internal/registries/rank_test.go index 5095b3209..d342752a7 100644 --- a/internal/registries/rank_test.go +++ b/internal/registries/rank_test.go @@ -2,20 +2,6 @@ package registries import "testing" -// TestRank_OfficialBeatsEverything pins the primary sort key: official source -// wins regardless of everything else (data-model §9, contracts/rest-api.md#catalog). -func TestRank_OfficialBeatsEverything(t *testing.T) { - official := CatalogHit{Source: "official", Official: true, Entry: ServerEntry{ID: "z", Name: "Z"}} - verifiedPopular := CatalogHit{Source: "smithery", Verified: true, Popularity: intPop(9999), Entry: ServerEntry{ID: "a", Name: "A"}} - - if !Rank(official, verifiedPopular, "") { - t.Error("expected official source to rank first") - } - if Rank(verifiedPopular, official, "") { - t.Error("expected official source to rank first (reverse check)") - } -} - // TestRank_VerifiedBeatsPopularity pins the secondary sort key. func TestRank_VerifiedBeatsPopularity(t *testing.T) { verified := CatalogHit{Verified: true, Popularity: intPop(1), Entry: ServerEntry{ID: "b"}} @@ -25,15 +11,6 @@ func TestRank_VerifiedBeatsPopularity(t *testing.T) { } } -// TestRank_PopularityBeatsRelevance pins the third sort key. -func TestRank_PopularityBeatsRelevance(t *testing.T) { - popular := CatalogHit{Popularity: intPop(100), Entry: ServerEntry{ID: "zzz", Name: "Unrelated"}} - relevant := CatalogHit{Popularity: intPop(1), Entry: ServerEntry{ID: "aaa", Name: "github tool"}} - if !Rank(popular, relevant, "github") { - t.Error("expected popularity to outrank text relevance") - } -} - // TestRank_RelevanceBeatsTitle pins the fourth key: a query match outranks // alphabetical order. func TestRank_RelevanceBeatsTitle(t *testing.T) { diff --git a/internal/registries/recorded_registry_test.go b/internal/registries/recorded_registry_test.go new file mode 100644 index 000000000..bb39cf6f4 --- /dev/null +++ b/internal/registries/recorded_registry_test.go @@ -0,0 +1,371 @@ +package registries + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "sort" + "strings" + "testing" + "time" +) + +// Spec 109 fix-catalog-rank (T166, D36.12): the registry-shaped SC-008 fixture +// and the fake registry that serves it. The corpus in +// testdata/catalog_github_order.json is the union of three REAL responses of +// registry.modelcontextprotocol.io recorded on 2026-10-02, so the live bug +// (search=github page 1 never reaches io.github.github/github-mcp-server) +// reproduces offline. + +const catalogGithubFixturePath = "testdata/catalog_github_order.json" + +type catalogFixtureOfficialSource struct { + ID string `json:"id"` + Name string `json:"name"` + Provenance string `json:"provenance"` + Protocol string `json:"protocol"` + Corpus []json.RawMessage `json:"corpus"` +} + +type catalogFixtureFile struct { + Comment string `json:"_comment"` + Recorded json.RawMessage `json:"recorded"` + Query string `json:"query"` + Sources []json.RawMessage `json:"sources"` + IDs json.RawMessage `json:"ids"` + Results json.RawMessage `json:"results"` +} + +func loadCatalogFixture(t *testing.T) (catalogFixtureFile, catalogFixtureOfficialSource) { + t.Helper() + raw, err := os.ReadFile(catalogGithubFixturePath) + if err != nil { + t.Fatal(err) + } + var f catalogFixtureFile + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatal(err) + } + var official catalogFixtureOfficialSource + if len(f.Sources) == 0 { + t.Fatal("fixture has no sources") + } + if err := json.Unmarshal(f.Sources[0], &official); err != nil { + t.Fatal(err) + } + if official.Protocol != protocolOfficial { + t.Fatalf("sources[0].protocol = %q, want the official protocol", official.Protocol) + } + return f, official +} + +func recordedItem(name string, isLatest bool, version string) json.RawMessage { + b, _ := json.Marshal(map[string]interface{}{ + "server": map[string]interface{}{"name": name, "version": version}, + "_meta": map[string]interface{}{officialMetaKey: map[string]interface{}{"status": "active", "isLatest": isLatest}}, + }) + return b +} + +type recordedPage struct { + Servers []struct { + Server struct { + Name string `json:"name"` + } `json:"server"` + } `json:"servers"` + Metadata struct { + NextCursor string `json:"nextCursor"` + Count int `json:"count"` + } `json:"metadata"` +} + +func getRecordedPage(t *testing.T, base, rawQuery string) recordedPage { + t.Helper() + resp, err := http.Get(base + "/v0.1/servers?" + rawQuery) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var p recordedPage + if err := json.NewDecoder(resp.Body).Decode(&p); err != nil { + t.Fatal(err) + } + return p +} + +func (p recordedPage) names() []string { + out := make([]string, 0, len(p.Servers)) + for _, s := range p.Servers { + out = append(out, s.Server.Name) + } + return out +} + +func TestRecordedRegistryHandler_SearchIsNameSubstringByteOrder(t *testing.T) { + corpus := []json.RawMessage{ + recordedItem("io.github.zed/other", true, "1"), + recordedItem("com.Example/GitHub", true, "1"), + recordedItem("ai.smithery/none", true, "1"), + recordedItem("io.github.github/github-mcp-server", true, "1"), + } + srv := httptest.NewServer(RecordedRegistryHandlerForTest(corpus)) + defer srv.Close() + + got := getRecordedPage(t, srv.URL, "search=GITHUB&version=latest").names() + want := []string{"com.Example/GitHub", "io.github.github/github-mcp-server", "io.github.zed/other"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("search=GITHUB = %v, want %v (case-insensitive substring of name, byte order)", got, want) + } + // The match is on the name only: a description-only mention never matches. + if n := len(getRecordedPage(t, srv.URL, "search=none-such").Servers); n != 0 { + t.Fatalf("unknown search returned %d servers", n) + } +} + +func TestRecordedRegistryHandler_CursorAndLimit(t *testing.T) { + var corpus []json.RawMessage + for _, n := range []string{"a/1", "a/2", "a/3", "a/4", "a/5"} { + corpus = append(corpus, recordedItem(n, true, "1")) + } + srv := httptest.NewServer(RecordedRegistryHandlerForTest(corpus)) + defer srv.Close() + + p1 := getRecordedPage(t, srv.URL, "limit=2") + if strings.Join(p1.names(), ",") != "a/1,a/2" || p1.Metadata.NextCursor != "a/2" { + t.Fatalf("page 1 = %v cursor %q", p1.names(), p1.Metadata.NextCursor) + } + p2 := getRecordedPage(t, srv.URL, "limit=2&cursor="+url.QueryEscape(p1.Metadata.NextCursor)) + if strings.Join(p2.names(), ",") != "a/3,a/4" || p2.Metadata.NextCursor != "a/4" { + t.Fatalf("page 2 = %v cursor %q", p2.names(), p2.Metadata.NextCursor) + } + p3 := getRecordedPage(t, srv.URL, "limit=2&cursor=a/4") + if strings.Join(p3.names(), ",") != "a/5" || p3.Metadata.NextCursor != "" { + t.Fatalf("last page = %v cursor %q (no cursor when exhausted)", p3.names(), p3.Metadata.NextCursor) + } + if n := len(getRecordedPage(t, srv.URL, "").Servers); n != 5 { + t.Fatalf("default limit must be 100, got %d servers", n) + } +} + +func TestRecordedRegistryHandler_VersionLatestFiltersIsLatest(t *testing.T) { + corpus := []json.RawMessage{ + recordedItem("a/x", false, "1.0.0"), + recordedItem("a/x", false, "1.1.0"), + recordedItem("a/x", true, "1.2.0"), + } + srv := httptest.NewServer(RecordedRegistryHandlerForTest(corpus)) + defer srv.Close() + + if n := len(getRecordedPage(t, srv.URL, "version=latest").Servers); n != 1 { + t.Fatalf("version=latest returned %d rows, want 1", n) + } + if n := len(getRecordedPage(t, srv.URL, "").Servers); n != 3 { + t.Fatalf("without version=latest all %d rows come back, got %d", 3, n) + } +} + +// TestCatalogGithubFixture_MainPageLacksGitHubServer proves the live bug: the +// registry's own search=github page 1 holds 100 names in byte order and +// io.github.github/github-mcp-server is not among them. +func TestCatalogGithubFixture_MainPageLacksGitHubServer(t *testing.T) { + _, official := loadCatalogFixture(t) + srv := httptest.NewServer(RecordedRegistryHandlerForTest(official.Corpus)) + defer srv.Close() + + page := getRecordedPage(t, srv.URL, "search=github&version=latest&limit=100") + names := page.names() + if len(names) != 100 { + t.Fatalf("search=github page 1 has %d entries, want 100", len(names)) + } + if page.Metadata.NextCursor == "" { + t.Fatal("search=github must report a nextCursor (the listing continues)") + } + if !sort.StringsAreSorted(names) { + t.Fatal("page is not in byte order") + } + for _, n := range names { + if n == "io.github.github/github-mcp-server" { + t.Fatal("page 1 must NOT contain GitHub's own server: that is the bug") + } + } +} + +func TestCatalogGithubFixture_ExpansionQueriesFindIt(t *testing.T) { + _, official := loadCatalogFixture(t) + srv := httptest.NewServer(RecordedRegistryHandlerForTest(official.Corpus)) + defer srv.Close() + + owner := getRecordedPage(t, srv.URL, "search="+url.QueryEscape(".github/")+"&version=latest").names() + if len(owner) != 1 || owner[0] != "io.github.github/github-mcp-server" { + t.Fatalf("search=.github/ = %v, want exactly GitHub's server", owner) + } + seg := getRecordedPage(t, srv.URL, "search="+url.QueryEscape("/github")+"&version=latest").names() + found := false + for _, n := range seg { + if n == "io.github.github/github-mcp-server" { + found = true + } + if !strings.Contains(strings.ToLower(n), "/github") { + t.Fatalf("search=/github returned %q", n) + } + } + if !found || len(seg) < 30 { + t.Fatalf("search=/github = %d entries (found GitHub: %v), want ≥ 30 incl. GitHub's", len(seg), found) + } +} + +// ---- recording ---- + +var recordedQueries = []string{"github", ".github/", "/github"} + +// sanitizeRegistryItem keeps only the fields the catalog reads (D36.12). +func sanitizeRegistryItem(item map[string]interface{}) map[string]interface{} { + server, _ := item["server"].(map[string]interface{}) + out := map[string]interface{}{} + for _, k := range []string{"name", "title", "description", "version"} { + if v, ok := server[k]; ok { + out[k] = v + } + } + if repo, ok := server["repository"].(map[string]interface{}); ok { + if u, ok := repo["url"]; ok { + out["repository"] = map[string]interface{}{"url": u} + } + } + if pkgs, ok := server["packages"].([]interface{}); ok && len(pkgs) > 0 { + if pkg, ok := pkgs[0].(map[string]interface{}); ok { + keep := map[string]interface{}{} + for _, k := range []string{"registryType", "identifier", "version", "runtimeHint", "transport", "runtimeArguments", "packageArguments"} { + if v, ok := pkg[k]; ok { + keep[k] = v + } + } + if envs, ok := pkg["environmentVariables"].([]interface{}); ok { + keep["environmentVariables"] = pickList(envs, "name", "description", "isSecret") + } + out["packages"] = []interface{}{keep} + } + } + if rems, ok := server["remotes"].([]interface{}); ok && len(rems) > 0 { + if rem, ok := rems[0].(map[string]interface{}); ok { + keep := map[string]interface{}{} + for _, k := range []string{"type", "url"} { + if v, ok := rem[k]; ok { + keep[k] = v + } + } + if hdrs, ok := rem["headers"].([]interface{}); ok { + keep["headers"] = pickList(hdrs, "name", "description", "isSecret") + } + out["remotes"] = []interface{}{keep} + } + } + meta := map[string]interface{}{"status": "active", "isLatest": true} + if m, ok := item["_meta"].(map[string]interface{}); ok { + if o, ok := m[officialMetaKey].(map[string]interface{}); ok { + if v, ok := o["status"]; ok { + meta["status"] = v + } + if v, ok := o["isLatest"]; ok { + meta["isLatest"] = v + } + } + } + return map[string]interface{}{"server": out, "_meta": map[string]interface{}{officialMetaKey: meta}} +} + +func pickList(list []interface{}, keys ...string) []interface{} { + out := make([]interface{}, 0, len(list)) + for _, raw := range list { + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + keep := map[string]interface{}{} + for _, k := range keys { + if v, ok := m[k]; ok { + keep[k] = v + } + } + out = append(out, keep) + } + return out +} + +// TestRecordCatalogGithubFixture re-records the corpus from the live official +// registry: RECORD_LIVE_REGISTRY=1 go test ./internal/registries -run +// TestRecordCatalogGithubFixture. It rewrites `corpus` and `recorded` only; +// ids and results come from UPDATE_GOLDEN=1 on the httpapi test. +func TestRecordCatalogGithubFixture(t *testing.T) { + if os.Getenv("RECORD_LIVE_REGISTRY") != "1" { + t.Skip("set RECORD_LIVE_REGISTRY=1 to re-record the live registry fixture") + } + const base = "https://registry.modelcontextprotocol.io/v0.1/servers" + client := &http.Client{Timeout: 90 * time.Second} + byName := map[string]map[string]interface{}{} + for _, q := range recordedQueries { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, + base+"?version=latest&limit=100&search="+url.QueryEscape(q), http.NoBody) + if err != nil { + t.Fatal(err) + } + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("search=%q: HTTP %d", q, resp.StatusCode) + } + var page struct { + Servers []map[string]interface{} `json:"servers"` + } + if err := json.Unmarshal(body, &page); err != nil { + t.Fatal(err) + } + for _, item := range page.Servers { + clean := sanitizeRegistryItem(item) + name, _ := clean["server"].(map[string]interface{})["name"].(string) + if name != "" { + byName[name] = clean + } + } + } + names := make([]string, 0, len(byName)) + for n := range byName { + names = append(names, n) + } + sort.Strings(names) + corpus := make([]json.RawMessage, 0, len(names)) + for _, n := range names { + b, err := json.Marshal(byName[n]) + if err != nil { + t.Fatal(err) + } + corpus = append(corpus, b) + } + + f, official := loadCatalogFixture(t) + official.Corpus = corpus + srcBytes, err := json.Marshal(official) + if err != nil { + t.Fatal(err) + } + f.Sources[0] = srcBytes + f.Comment = "Spec 109 fix-catalog-rank (SC-008, C1): catalog search \"github\" ranks GitHub's own official, verified server first, with identical order on REST, MCP, CLI, Web and macOS. sources + query are the INPUT: the official source's corpus is the union of three real registry.modelcontextprotocol.io responses (see recorded), served by RecordedRegistryHandlerForTest; ids + results are the golden OUTPUT written by internal/httpapi/spec109_catalog_order_test.go with UPDATE_GOLDEN=1 (REST GET /catalog/search?limit=20 data.results). ids are \":\"." + rec, _ := json.Marshal(map[string]interface{}{"from": base, "at": time.Now().UTC().Format("2006-01-02"), "queries": recordedQueries}) + f.Recorded = rec + out, err := json.MarshalIndent(f, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(catalogGithubFixturePath, append(out, '\n'), 0o644); err != nil { + t.Fatal(err) + } + t.Logf("recorded %d unique servers", len(corpus)) +} diff --git a/internal/registries/search.go b/internal/registries/search.go index 7206f539b..f0821e5f3 100644 --- a/internal/registries/search.go +++ b/internal/registries/search.go @@ -55,7 +55,13 @@ func SearchServers(ctx context.Context, registryID, tag, query string, limit int if reg == nil { return nil, fmt.Errorf("registry '%s' not found", registryID) } + return searchRegistry(ctx, reg, tag, query, limit, guesser) +} +// searchRegistry is SearchServers for an already-resolved registry. The +// catalog's background fetches call it with their own copy of the entry rather +// than re-reading the (unsynchronised) registry list. +func searchRegistry(ctx context.Context, reg *RegistryEntry, tag, query string, limit int, guesser *experiments.Guesser) ([]ServerEntry, error) { // FR-008: skip a key-requiring registry when no key is configured, rather // than performing a doomed fetch. Surfaces map ErrRegistryKeyMissing to an // "unavailable" marker so the overall search still succeeds. @@ -114,6 +120,100 @@ func SearchServers(ctx context.Context, registryID, tag, query string, limit int return filtered, nil } +// typedFetchCap bounds how many entries one source contributes to a typed +// catalog query before ranking (Spec 109 D36.3): the fetch is one page per +// query (3 on the official protocol), so this is a ceiling, not a target. +const typedFetchCap = 300 + +// searchCatalogSource is the typed-query fetch behind catalog search (Spec 109 +// D36.3). Unlike SearchServers it does NOT truncate to a limit in the +// registry's own order: the official registry's search returns names in byte +// order, so truncating first decided what the user could ever see. It returns +// every match (capped at typedFetchCap) and lets Rank order them. The official +// protocol also fetches the owner and name-prefix expansion queries +// (fetchOfficialCatalog). When the official source's main query fails the +// error is returned together with the expansion hits that did arrive. +func searchCatalogSource(ctx context.Context, reg *RegistryEntry, q string) ([]ServerEntry, error) { + return searchCatalogSourceProgress(ctx, reg, q, nil) +} + +// searchCatalogSourceProgress is searchCatalogSource that also reports, through +// onPartial, the already-filtered hits of the official protocol's expansion +// queries as they arrive. A caller that stops waiting (the 5s source budget) +// can then still show them while the slow main query finishes in the +// background (Spec 109 D36.2/D36.11). onPartial may be nil. +func searchCatalogSourceProgress(ctx context.Context, reg *RegistryEntry, q string, onPartial func([]ServerEntry)) ([]ServerEntry, error) { + // FR-008: skip a key-requiring registry when no key is configured. + if err := checkRegistryKey(reg); err != nil { + return nil, err + } + if reg.ServersURL == "" { + return nil, fmt.Errorf("registry '%s' has no servers endpoint", reg.Name) + } + + var ( + servers []ServerEntry + err error + ) + if reg.Protocol == protocolOfficial { + var progress func([]ServerEntry) + if onPartial != nil { + progress = func(expansion []ServerEntry) { onPartial(finishCatalogEntries(reg, q, expansion)) } + } + servers, err = fetchOfficialCatalogProgress(ctx, reg, q, progress) + } else { + servers, err = fetchServers(ctx, reg, nil, q, 0) + } + if err != nil { + err = fmt.Errorf("failed to fetch servers from %s: %w", reg.Name, err) + } + + return finishCatalogEntries(reg, q, servers), err +} + +// finishCatalogEntries applies the typed query's filter and the per-source cap +// to fetched entries and stamps the registry name. +func finishCatalogEntries(reg *RegistryEntry, q string, servers []ServerEntry) []ServerEntry { + filtered := filterCatalogEntries(servers, q) + if len(filtered) > typedFetchCap { + filtered = filtered[:typedFetchCap] + } + for i := range filtered { + filtered[i].Registry = reg.Name + } + return filtered +} + +// filterCatalogEntries keeps the entries a typed catalog query matches: the +// per-registry filter (name, title, description substring) plus a +// separator-insensitive match, so "github actions" finds "github-actions". +func filterCatalogEntries(servers []ServerEntry, q string) []ServerEntry { + filtered := make([]ServerEntry, 0, len(servers)) + nq := normalizeMatchText(q) + for i := range servers { + e := &servers[i] + if entryMatchesQuery(e, q) || + (nq != "" && (strings.Contains(normalizeMatchText(e.Name), nq) || + strings.Contains(normalizeMatchText(e.Title), nq) || + strings.Contains(normalizeMatchText(e.Description), nq))) { + filtered = append(filtered, *e) + } + } + return filtered +} + +// entryMatchesQuery is filterServers' per-entry query test: a case-insensitive +// substring of the name, the title or the description. An empty query matches. +func entryMatchesQuery(e *ServerEntry, query string) bool { + if query == "" { + return true + } + q := strings.ToLower(query) + return strings.Contains(strings.ToLower(e.Name), q) || + strings.Contains(strings.ToLower(e.Title), q) || + strings.Contains(strings.ToLower(e.Description), q) +} + // FindServerByID resolves a single server within a registry by its exact ID. // It performs a live registry fetch and is the shared resolution path used by // every add-from-registry surface (CN-001/CN-004). Returns ErrRegistryNotFound @@ -554,15 +654,9 @@ func filterServers(servers []ServerEntry, tag, query string) []ServerEntry { for i := range servers { srv := &servers[i] - // Filter by query (search in name and description) - if query != "" { - q := strings.ToLower(query) - name := strings.ToLower(srv.Name) - desc := strings.ToLower(srv.Description) - - if !strings.Contains(name, q) && !strings.Contains(desc, q) { - continue - } + // Filter by query (search in name, title and description) + if !entryMatchesQuery(srv, query) { + continue } filtered = append(filtered, *srv) diff --git a/internal/registries/search_catalog_test.go b/internal/registries/search_catalog_test.go new file mode 100644 index 000000000..dd1f40a9d --- /dev/null +++ b/internal/registries/search_catalog_test.go @@ -0,0 +1,113 @@ +package registries + +import ( + "context" + "fmt" + "strings" + "testing" +) + +// Spec 109 fix-catalog-rank T168 (D36.3): a typed catalog query fetches the +// whole filtered result for ranking instead of truncating to `limit` in the +// registry's own order. The exported per-registry SearchServers contract is +// unchanged. + +func flatGithubServers(n int) string { + items := make([]string, 0, n) + for i := 0; i < n; i++ { + items = append(items, fmt.Sprintf(`{"id":"acme/s%03d","name":"github tool %03d","description":"d"}`, i, i)) + } + return "[" + strings.Join(items, ",") + "]" +} + +func TestSearchCatalogSource_NoTruncationBeforeRank(t *testing.T) { + for _, tc := range []struct{ matches, want int }{{80, 80}, {400, typedFetchCap}} { + srv := jsonServer(t, flatGithubServers(tc.matches)) + reg := &RegistryEntry{ID: "flat", Name: "Flat", ServersURL: srv.URL} + got, err := searchCatalogSource(context.Background(), reg, "github") + if err != nil { + t.Fatal(err) + } + if len(got) != tc.want { + t.Errorf("%d matches -> %d entries, want %d", tc.matches, len(got), tc.want) + } + for _, e := range got { + if e.Registry != "Flat" { + t.Fatalf("Registry = %q, want the source name", e.Registry) + } + } + } + if typedFetchCap != 300 { + t.Errorf("typedFetchCap = %d, want 300 (D36.3)", typedFetchCap) + } +} + +func TestSearchCatalogSource_OfficialUsesExpansion(t *testing.T) { + reg, counter := newOfficialFixtureRegistry(t, nil) + got, err := searchCatalogSource(context.Background(), reg, "github") + if err != nil { + t.Fatal(err) + } + if len(counter.searches) != 3 { + t.Fatalf("requests = %v, want main + 2 expansions", counter.searches) + } + if len(got) == 0 || got[0].ID != "io.github.github/github-mcp-server" { + t.Fatalf("the owner hit must be in the fetched set and lead it, got %v", entryIDs(got)[:3]) + } +} + +func TestSearchCatalogSource_PhraseQueryMatchesHyphenatedNames(t *testing.T) { + reg, _ := newOfficialFixtureRegistry(t, nil) + got, err := searchCatalogSource(context.Background(), reg, "github actions") + if err != nil { + t.Fatal(err) + } + ids := strings.Join(entryIDs(got), ",") + if !strings.Contains(ids, "io.github.ofershap/github-actions") { + t.Fatalf("a multi-word q must match names whose words are hyphenated, got %s", ids) + } +} + +func TestSearchCatalogSource_MissingKeyAndNoEndpoint(t *testing.T) { + if _, err := searchCatalogSource(context.Background(), &RegistryEntry{ID: "x", Name: "X"}, "q"); err == nil { + t.Fatal("a source with no servers endpoint must error") + } +} + +func TestFilterServers_MatchesTitle(t *testing.T) { + servers := []ServerEntry{ + {ID: "a/one", Name: "a/one", Title: "GitHub", Description: "x"}, + {ID: "a/two", Name: "a/two", Description: "y"}, + } + got := filterServers(servers, "", "github") + if len(got) != 1 || got[0].ID != "a/one" { + t.Fatalf("filterServers must match the title, got %+v", got) + } +} + +func TestMatchCachedEntry_MatchesTitle(t *testing.T) { + e := ServerEntry{ID: "a/one", Name: "a/one", Title: "GitHub"} + if !matchCachedEntry(&e, "github") { + t.Fatal("matchCachedEntry must match the title") + } +} + +func TestSearchServers_PerRegistryContractUnchanged(t *testing.T) { + srv := jsonServer(t, flatGithubServers(80)) + withTestRegistries(t, []RegistryEntry{{ID: "flat", Name: "Flat", ServersURL: srv.URL}}) + + got, err := SearchServers(context.Background(), "flat", "", "github", 10, nil) + if err != nil { + t.Fatal(err) + } + if len(got) != 10 || got[0].ID != "acme/s000" || got[9].ID != "acme/s009" { + t.Fatalf("per-registry search keeps limit and registry order, got %d (%s..)", len(got), got[0].ID) + } + got, err = SearchServers(context.Background(), "flat", "", "github", 500, nil) + if err != nil { + t.Fatal(err) + } + if len(got) != 50 { + t.Fatalf("limit is still capped at 50, got %d", len(got)) + } +} diff --git a/internal/registries/testdata/catalog_github_order.json b/internal/registries/testdata/catalog_github_order.json index 856d6af54..a7f0f2ed4 100644 --- a/internal/registries/testdata/catalog_github_order.json +++ b/internal/registries/testdata/catalog_github_order.json @@ -1,23 +1,4199 @@ { - "_comment": "Spec 109-m SC-008 (M12): catalog search \"github\" ranks the official, verified GitHub server first, with identical order on REST, MCP, CLI, Web and macOS. sources + query are the INPUT (served by httptest registry fixtures in each Go test); ids + results are the golden OUTPUT written by internal/httpapi/spec109_catalog_order_test.go with UPDATE_GOLDEN=1 (REST GET /catalog/search data.results). ids are \":\".", + "_comment": "Spec 109 fix-catalog-rank (SC-008, C1): catalog search \"github\" ranks GitHub's own official, verified server first, with identical order on REST, MCP, CLI, Web and macOS. sources + query are the INPUT: the official source's corpus is the union of three real registry.modelcontextprotocol.io responses (see recorded), served by RecordedRegistryHandlerForTest; ids + results are the golden OUTPUT written by internal/httpapi/spec109_catalog_order_test.go with UPDATE_GOLDEN=1 (REST GET /catalog/search?limit=20 data.results). ids are \":\".", + "recorded": { + "at": "2026-10-02", + "from": "https://registry.modelcontextprotocol.io/v0.1/servers", + "queries": [ + "github", + ".github/", + "/github" + ] + }, "query": "github", "sources": [ { "id": "official", "name": "Official", "provenance": "official", - "servers": [ + "protocol": "modelcontextprotocol/registry", + "corpus": [ + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Connect AI assistants to your GitHub-hosted Obsidian vault to seamlessly access, search, and analy…", + "name": "ai.smithery/Hint-Services-obsidian-github-mcp", + "remotes": [ + { + "headers": [ + { + "description": "Bearer token for Smithery authentication", + "isSecret": true, + "name": "Authorization" + } + ], + "type": "streamable-http", + "url": "https://server.smithery.ai/@Hint-Services/obsidian-github-mcp/mcp" + } + ], + "repository": { + "url": "https://github.com/Hint-Services/obsidian-github-mcp" + }, + "version": "0.4.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "A Model Context Protocol (MCP) application for automated GitHub PR analysis and issue management.…", + "name": "ai.smithery/saidsef-mcp-github-pr-issue-analyser", + "remotes": [ + { + "headers": [ + { + "description": "Bearer token for Smithery authentication", + "name": "Authorization" + } + ], + "type": "streamable-http", + "url": "https://server.smithery.ai/@saidsef/mcp-github-pr-issue-analyser/mcp" + } + ], + "repository": { + "url": "https://github.com/saidsef/mcp-github-pr-issue-analyser" + }, + "version": "1.15.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Access the GitHub API, enabling file operations, repository management, search functionality, and…", + "name": "ai.smithery/smithery-ai-github", + "remotes": [ + { + "headers": [ + { + "description": "Bearer token for Smithery authentication", + "isSecret": true, + "name": "Authorization" + } + ], + "type": "streamable-http", + "url": "https://server.smithery.ai/@smithery-ai/github/mcp" + } + ], + "repository": { + "url": "https://github.com/smithery-ai/mcp-servers" + }, + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "GitHub platform changelog feed. $0.01/query. Register in-session — free testnet funds.", + "name": "com.a2awire/data-github-changelog-platform-change-announcement", + "remotes": [ + { + "type": "streamable-http", + "url": "https://a2awire.com/mcp/data/githubchangelog-github-platform-changelog-copilot-actions-security-change-394959/http" + } + ], + "repository": { + "url": "https://github.com/ee324/a2awire" + }, + "title": "GitHub Platform Changelog — buy per-query in-session (githubchangelog)", + "version": "0.1.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Trending GitHub repos: new \u0026 rising daily. $0.01/query. Register in-session — free testnet funds.", + "name": "com.a2awire/data-trending-repository-new-github-repo-discovery", + "remotes": [ + { + "type": "streamable-http", + "url": "https://a2awire.com/mcp/data/ghtrend-fast-growing-new-github-repositories-01390c/http" + } + ], + "repository": { + "url": "https://github.com/ee324/a2awire" + }, + "title": "Trending Repository Tracker — new GitHub repo discovery ($0.01/query)", + "version": "0.1.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Hiring developers? Find and source software engineers by what they build on GitHub.", + "name": "com.getstarhunt/github-developer-sourcing", + "remotes": [ + { + "type": "streamable-http", + "url": "https://app.getstarhunt.com/mcp" + } + ], + "title": "StarHunt", + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Resolve a company domain to its GitHub organization with repo, language and activity signals.", + "name": "com.mambabuilt/mcp-github-organization-signal-scanner", + "packages": [ + { + "environmentVariables": [ + { + "description": "Apify API token from https://console.apify.com/account/integrations", + "isSecret": true, + "name": "APIFY_TOKEN" + } + ], + "identifier": "@mambalabsdev/mcp-github-organization-signal-scanner", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "1.0.0" + } + ], + "repository": { + "url": "https://github.com/mambalabsdev/mcp-github-organization-signal-scanner" + }, + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Manage repositories, users, releases, and automate GitHub workflows", + "name": "com.mcparmory/github", + "packages": [ + { + "identifier": "mcparmory-github", + "registryType": "pypi", + "runtimeHint": "uvx", + "transport": { + "type": "stdio" + }, + "version": "1.0.6" + } + ], + "repository": { + "url": "https://github.com/mcparmory/registry" + }, + "version": "1.0.6" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "GitHub repo analytics: stars, trending, code search, contributor maps for project research.", + "name": "com.thenextgennexus/github-mcp-server", + "remotes": [ + { + "headers": [ + { + "description": "Apify API token. Free tier at console.apify.com", + "isSecret": true, + "name": "Authorization" + } + ], + "type": "streamable-http", + "url": "https://nexgendata-mcp-proxy.steve-corbeil.workers.dev/github-mcp-server/mcp" + } + ], + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "deprecated" + } + }, + "server": { + "description": "Scrape GitHub repository metadata, stars, forks, topics, licences and activity. Pay per row.", + "name": "dev.reapx/github-repos", + "remotes": [ + { + "headers": [ + { + "description": "Your own Apify API token as 'Bearer \u003ctoken\u003e'. Get one free at https://console.apify.com/account/integrations, or buy a prepaid, spend-capped token with no signup at https://agi.apify.com. Runs bill to your own account, per result returned.", + "isSecret": true, + "name": "Authorization" + } + ], + "type": "streamable-http", + "url": "https://mcp.apify.com/?actors=reapx/github-repo-scraper" + } + ], + "title": "reapx GitHub repos - repository metadata and activity", + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "BlackHawkMCP - connect AI to Google Sheets", + "name": "io.github.000safah000-ai/blackhawk-mcp", + "packages": [ + { + "environmentVariables": [ + { + "description": "Google OAuth2 client ID", + "name": "GOOGLE_CLIENT_ID" + }, + { + "description": "Google OAuth2 client secret", + "isSecret": true, + "name": "GOOGLE_CLIENT_SECRET" + }, + { + "description": "OAuth2 redirect URI", + "name": "GOOGLE_REDIRECT_URI" + } + ], + "identifier": "blackhawk-mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "1.0.0" + } + ], + "repository": { + "url": "https://github.com/000safah000-ai/BlackHawkMCP" + }, + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Decode any Base tx: plain English, strict JSON, risk flags, no LLM. 50 free/day per IP, then $0.02.", + "name": "io.github.0200project/base-transaction-decoder", + "remotes": [ + { + "type": "streamable-http", + "url": "https://api.0200project.com/mcp?ref=mcp-registry" + } + ], + "repository": { + "url": "https://github.com/0200project/base-tx-explain" + }, + "title": "Base Transaction Decoder", + "version": "0.1.4" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Research saved LinkedIn contacts, review monitored activity, and prepare engagement campaigns.", + "name": "io.github.02inf/opencomment-ai", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.opencomment.ai/mcp" + } + ], + "title": "OpenComment AI", + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Pay-per-use tool API for AI agents. Free tier, x402 USDC micropayments, or API key.", + "name": "io.github.0580iris-lang/x711-gas-station", + "remotes": [ + { + "type": "streamable-http", + "url": "https://x711.io/mcp" + } + ], + "title": "x711 — Universal Agent Gas Station", + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Medium CLI + 23-tool MCP server. Your IDE drafts replies. No API keys.", + "name": "io.github.06ketan/medium-ops", + "packages": [ + { + "environmentVariables": [ + { + "description": "Legacy Medium Integration Token (api.medium.com/v1/*). Optional. Generate at medium.com/me/settings → 'Integration tokens' (note: Medium stopped issuing new tokens in 2023).", + "isSecret": true, + "name": "MEDIUM_INTEGRATION_TOKEN" + }, + { + "description": "Medium 'sid' cookie value from medium.com (Application → Cookies). Used for authenticated reads + dashboard GraphQL.", + "isSecret": true, + "name": "MEDIUM_SID" + }, + { + "description": "Medium 'uid' cookie value. Required alongside sid for some authenticated endpoints.", + "name": "MEDIUM_UID" + }, + { + "description": "Medium 'xsrf' cookie value. Required for any dashboard write (post_response, publish_post, delete_post, draft updates).", + "isSecret": true, + "name": "MEDIUM_XSRF" + }, + { + "description": "Cloudflare 'cf_clearance' cookie. Only required when Cloudflare challenges your IP for dashboard write calls.", + "isSecret": true, + "name": "MEDIUM_CF_CLEARANCE" + }, + { + "description": "Your Medium handle (without the @). Required for the public RSS read path so the client knows whose feed to fetch.", + "name": "MEDIUM_USERNAME" + }, + { + "description": "Override path to the mcp.json file the auth layer reads. Defaults to ~/.cursor/mcp.json.", + "name": "MEDIUM_OPS_MCP_PATH" + }, + { + "description": "Optional: override the host CLI used by the unattended daemon path (default: auto-detect claude / cursor-agent / codex on PATH). Use {prompt} placeholder if your CLI takes the prompt as an arg.", + "name": "MEDIUM_OPS_LLM_CMD" + } + ], + "identifier": "medium-ops", + "packageArguments": [ + { + "type": "positional", + "value": "mcp" + }, + { + "type": "positional", + "value": "serve" + } + ], + "registryType": "pypi", + "runtimeHint": "uvx", + "transport": { + "type": "stdio" + }, + "version": "0.1.2" + } + ], + "repository": { + "url": "https://github.com/06ketan/medium-ops" + }, + "title": "medium-ops", + "version": "0.1.2" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Convert HTML to PDF/PNG/WebP/PPTX slide carousels with 11 themes — for LinkedIn, decks, posts.", + "name": "io.github.06ketan/slideshot", + "packages": [ + { + "identifier": "slideshot-mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "4.4.0" + } + ], + "repository": { + "url": "https://github.com/06ketan/slideshot" + }, + "version": "4.4.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Substack CLI + 26-tool MCP server. Your IDE drafts replies via propose_reply. No API keys.", + "name": "io.github.06ketan/substack-ops", + "packages": [ + { + "environmentVariables": [ + { + "description": "Your Substack publication URL (e.g. https://you.substack.com/). Optional if set in ~/.cursor/mcp.json's mcpServers.substack-api.env.", + "name": "SUBSTACK_PUBLICATION_URL" + }, + { + "description": "Your Substack numeric user id. Optional if set in ~/.cursor/mcp.json.", + "name": "SUBSTACK_USER_ID" + }, + { + "description": "Your Substack session cookie value (the s%3A... string). Required for authenticated calls. Easier path: run `substack-ops auth login --browser chrome` and the cookie is auto-grabbed.", + "isSecret": true, + "name": "SUBSTACK_SESSION_TOKEN" + }, + { + "description": "Override path to the mcp.json file the auth layer reads. Defaults to ~/.cursor/mcp.json.", + "name": "SUBSTACK_OPS_MCP_PATH" + }, + { + "description": "Optional: override the host CLI used by the unattended daemon path (default: auto-detect claude / cursor-agent / codex on PATH). Use {prompt} placeholder if your CLI takes the prompt as an arg.", + "name": "SUBSTACK_OPS_LLM_CMD" + } + ], + "identifier": "substack-ops", + "packageArguments": [ + { + "type": "positional", + "value": "mcp" + }, + { + "type": "positional", + "value": "serve" + } + ], + "registryType": "pypi", + "runtimeHint": "uvx", + "transport": { + "type": "stdio" + }, + "version": "0.3.5" + } + ], + "repository": { + "url": "https://github.com/06ketan/substack-ops" + }, + "title": "substack-ops", + "version": "0.3.5" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Memory for coding agents, checked against the filesystem and git before it is believed.", + "name": "io.github.0Mattias/bettermemory", + "packages": [ + { + "identifier": "bettermemory", + "registryType": "pypi", + "runtimeHint": "uvx", + "transport": { + "type": "stdio" + }, + "version": "8.0.0" + } + ], + "repository": { + "url": "https://github.com/0Mattias/bettermemory" + }, + "version": "8.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Human-to-AI code review bridge. Review UI in the browser, AI agents fix code via MCP.", + "name": "io.github.0ics-srls/ui-ticket-mcp", + "packages": [ + { + "environmentVariables": [ + { + "description": "Root directory of the project being reviewed", + "name": "PROJECT_ROOT" + }, + { + "description": "HTTP port for REST API (default: 3200)", + "name": "REVIEW_PORT" + } + ], + "identifier": "ui-ticket-mcp", + "registryType": "pypi", + "transport": { + "type": "stdio" + }, + "version": "1.6.1" + } + ], + "repository": { + "url": "https://github.com/0ics-srls/ui-ticket-mcp_public" + }, + "title": "ui-ticket-mcp", + "version": "1.6.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Universal AI API Orchestrator — 1,554 tools, 96 services. One install.", + "name": "io.github.0nork/0nMCP", + "packages": [ + { + "identifier": "0nmcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "4.5.1" + } + ], + "remotes": [ + { + "type": "streamable-http", + "url": "https://0nmcp.com/api/mcp" + } + ], + "repository": { + "url": "https://github.com/0nork/0nMCP" + }, + "title": "0nMCP — Universal AI API Orchestrator", + "version": "4.5.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "PayPerByte — per-byte data for AI agents: x402 USDC on Base, EIP-712-attested. No token.", + "name": "io.github.0rkz/byte-protocol", + "packages": [ + { + "environmentVariables": [ + { + "description": "Ethereum private key for write/buy tools (subscribe, unsubscribe, publish, buy_data). buy_data spends real Base-mainnet USDC — use a dedicated wallet. Not required for read-only tools.", + "isSecret": true, + "name": "PRIVATE_KEY" + }, + { + "description": "RPC for the on-chain library + EIP-712 attestation layer (Arbitrum Sepolia, eip155:421614). The x402 buy rail settles USDC on Base mainnet and needs no RPC here. Defaults to Arbitrum Sepolia public RPC.", + "name": "RPC_URL" + }, + { + "description": "Gateway attester address byte_buy_data pins receipts against. Defaults to the current PayPerByte gateway attester (rotated 2026-08-19; previous default 0x77c86a...C472 is retired). Override only if the gateway discloses a rotation you need ahead of a package update - see /.well-known/agent.json receipt.attester and receipt.retiredAttesters.", + "name": "BYTE_GATEWAY_ATTESTER" + } + ], + "identifier": "byte-mcp-server", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "0.13.0" + } + ], + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.payperbyte.io/mcp" + } + ], + "repository": { + "url": "https://github.com/0rkz/byte-mcp-server" + }, + "version": "0.13.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Local-first shared memory and task coordination for AI coding agents. Go daemon plus headless CLI.", + "name": "io.github.0spoon/seamless", + "repository": { + "url": "https://github.com/0spoon/seamless" + }, + "title": "Seamless", + "version": "0.4.9" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Read-only MCP server for querying the live NOVAI blockchain over its public JSON-RPC endpoint.", + "name": "io.github.0x-devc/novai-mcp-server", + "packages": [ + { + "identifier": "novai-mcp-server", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "0.1.1" + } + ], + "repository": { + "url": "https://github.com/0x-devc/novai-mcp-server" + }, + "version": "0.1.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Secret scanning for AI agents: rules and entropy find candidates, a model decides which are real.", + "name": "io.github.0x1Adi/klarion", + "packages": [ + { + "environmentVariables": [ + { + "description": "Optional. With a key Klarion adjudicates candidates itself; without one it hands them to the calling agent to judge.", + "isSecret": true, + "name": "ANTHROPIC_API_KEY" + } + ], + "identifier": "https://github.com/0x1Adi/Klarion/releases/download/v0.4.3/klarion-mcp-v0.4.3.mcpb", + "registryType": "mcpb", + "transport": { + "type": "stdio" + }, + "version": "0.4.3" + } + ], + "repository": { + "url": "https://github.com/0x1Adi/Klarion" + }, + "title": "Klarion", + "version": "0.4.3" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Search 1M+ open-access papers, standards, and case law from an AI agent.", + "name": "io.github.0x923041-dotcom/conceptio-search", + "packages": [ + { + "environmentVariables": [ + { + "description": "Conceptio API key (Free, Dev, Pro or Enterprise) from your profile at https://www.conceptio.app. Required because an MCP client cannot run the CLI's interactive auth.", + "isSecret": true, + "name": "CONCEPTIO_API_KEY" + } + ], + "identifier": "conceptio-search", + "packageArguments": [ + { + "type": "positional", + "value": "mcp" + } + ], + "registryType": "pypi", + "runtimeHint": "uvx", + "transport": { + "type": "stdio" + }, + "version": "0.3.8" + } + ], + "repository": { + "url": "https://github.com/0x923041-dotcom/conceptio-cli" + }, + "title": "Conceptio Search", + "version": "0.3.8" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "x402-paid Base agent tools (USDC). 5 deterministic tools. No API keys. No NFT pass.", + "name": "io.github.0xAxiom/axiom-agentic-tools", + "remotes": [ + { + "type": "streamable-http", + "url": "https://agentic.clawbots.org/api/agentic/mcp" + } + ], + "repository": { + "url": "https://github.com/0xAxiom/axiom-agentic-tools" + }, + "title": "Axiom Agentic Tools", + "version": "0.2.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Read-only Polymarket prediction market data for AI agents.", + "name": "io.github.0xChron/polymarket-mcp", + "packages": [ + { + "identifier": "0xchron-polymarket-mcp", + "registryType": "pypi", + "runtimeHint": "uvx", + "transport": { + "type": "streamable-http", + "url": "http://localhost:8000/mcp" + }, + "version": "0.1.0" + } + ], + "repository": { + "url": "https://github.com/0xChron/polymarket-mcp" + }, + "version": "0.1.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Public phishing feed: suspicious/confirmed phishing URLs detected hourly. No auth, CC0.", + "name": "io.github.0xDanielLopez/phishunt", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.phishunt.io/" + } + ], + "repository": { + "url": "https://github.com/0xDanielLopez/phishunt-mcp" + }, + "title": "Phishunt", + "version": "0.1.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "IOCs (URLs, domains, IPs, hashes) shared by the infosec community on X/Twitter. No auth, CC0.", + "name": "io.github.0xDanielLopez/tweetfeed", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.tweetfeed.live/" + } + ], + "repository": { + "url": "https://github.com/0xDanielLopez/tweetfeed-mcp" + }, + "title": "TweetFeed", + "version": "0.1.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Turn any URL into clean markdown/JSON for AI agents. Self-hostable web content extraction.", + "name": "io.github.0xMassi/webclaw", + "packages": [ + { + "identifier": "@webclaw/mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "0.6.16" + } + ], + "title": "webclaw", + "version": "0.6.17" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Solana onchain intelligence for AI agents: wallet risk, due-diligence, perps funding, smart money.", + "name": "io.github.0xSardius/solenrich", + "remotes": [ + { + "type": "streamable-http", + "url": "https://api.solenrich.com/mcp" + } + ], + "repository": { + "url": "https://github.com/0xSardius/solenrich" + }, + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Cross-chain DEX for AI agents. Swap tokens across 7+ chains.", + "name": "io.github.0xSoftBoi/suwappu", + "packages": [ + { + "environmentVariables": [ + { + "description": "Your Suwappu API key", + "isSecret": true, + "name": "SUWAPPU_API_KEY" + } + ], + "identifier": "@suwappu/mcp-server", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "0.1.1" + } + ], + "repository": { + "url": "https://github.com/0xSoftBoi/suwappubot" + }, + "version": "0.1.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Wallet and payments for AI agents: auto-pay x402 APIs in USDC on XDC, within on-chain limits.", + "name": "io.github.0xbeny/xdc-ai", + "remotes": [ + { + "type": "streamable-http", + "url": "https://api.xdcai.tech/mcp" + } + ], + "title": "XDC AI", + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Query AI agent profiles, trust scores, verifications, and marketplace jobs on AgentFolio.", + "name": "io.github.0xbrainkid/agentfolio", + "packages": [ + { + "identifier": "agentfolio-mcp-server", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "1.1.1" + } + ], + "repository": { + "url": "https://github.com/0xbrainkid/agentfolio-mcp-server" + }, + "version": "1.1.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "AI agent identity verification via SATP on Solana. Trust scores and on-chain attestations.", + "name": "io.github.0xbrainkid/agentfolio-mcp-server", + "packages": [ + { + "identifier": "agentfolio-mcp-server", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "1.2.1" + } + ], + "repository": { + "url": "https://github.com/0xbrainkid/agentfolio-mcp-server" + }, + "version": "1.2.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Private signed AI task receipts with replay checks and optional validated AIPOU claims.", + "name": "io.github.0xddneto/ai-proof-of-us", + "packages": [ + { + "environmentVariables": [ + { + "description": "Optional inline private key for a dedicated farming wallet. Prefer AIPOU_AGENT_KEY_FILE and never use a primary wallet.", + "isSecret": true, + "name": "AIPOU_AGENT_PRIVATE_KEY" + }, + { + "description": "Path to the protected dedicated-wallet key created by aipou-mcp --init.", + "name": "AIPOU_AGENT_KEY_FILE" + }, + { + "description": "AIPOU token contract address on the configured network.", + "name": "AIPOU_CONTRACT_ADDRESS" + }, + { + "description": "AIPOU claims contract address on the configured network.", + "name": "AIPOU_CLAIMS_ADDRESS" + }, + { + "description": "Optional Base-compatible JSON-RPC endpoint.", + "name": "AIPOU_RPC_URL" + }, + { + "description": "Optional local directory for receipt and collector state.", + "name": "AIPOU_DATA_DIR" + } + ], + "identifier": "aipou-mcp-server", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "0.5.0" + } + ], + "repository": { + "url": "https://github.com/0xddneto/AI-Proof-of-Us" + }, + "title": "AI Proof of Us", + "version": "0.5.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Agent-to-agent escrow on Base. Post quests with ETH/USDC bounties and settle on-chain.", + "name": "io.github.0xdirectping/escrow", + "remotes": [ + { + "type": "streamable-http", + "url": "https://0xdirectping.com/mcp" + } + ], + "repository": { + "url": "https://github.com/0xdirectping/app" + }, + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Read-only Sui analytics: fund tracing, protocol-aware tx decoding, no API keys or wallet.", + "name": "io.github.0xfreak0/sui-mcp", + "packages": [ + { + "environmentVariables": [ + { + "description": "Default network when a tool call doesn't pass one. Every tool also takes a per-call network argument.", + "name": "SUI_NETWORK" + }, + { + "description": "Override the gRPC fullnode endpoint for the default network only.", + "name": "SUI_FULLNODE_URL" + }, + { + "description": "Override the GraphQL endpoint for the default network only.", + "name": "SUI_GRAPHQL_URL" + }, + { + "description": "Requests per 10 seconds sent to one RPC endpoint. Defaults to 180 for *.sui.io endpoints and no limit for others; 0 turns it off.", + "name": "SUI_RATE_LIMIT" + }, + { + "description": "Tool profiles to load at startup, comma-separated, or 'all'. Defaults to 'core'.", + "name": "SUI_TOOLS" + }, + { + "description": "Path to a locally built Revela move-decompiler binary. Only decompile_module needs it.", + "name": "SUI_DECOMPILER_PATH" + }, + { + "description": "JSON file of address attribution labels that overrides the shipped set for fund tracing.", + "name": "SUI_LABELS_FILE" + } + ], + "identifier": "sui-analytics-mcp", + "registryType": "npm", + "runtimeHint": "npx", + "transport": { + "type": "stdio" + }, + "version": "1.26.0" + } + ], + "repository": { + "url": "https://github.com/0xfreak0/sui-mcp" + }, + "title": "Sui Analytics", + "version": "1.26.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Read-only Polymarket sports and esports data: wallet grades on settled P\u0026L, large trades, markets.", + "name": "io.github.0xinsider/mcp", + "packages": [ + { + "environmentVariables": [ + { + "description": "0xinsider API key (oxi_sk_live_...) from https://0xinsider.com/developers", + "isSecret": true, + "name": "OXINSIDER_API_KEY" + } + ], + "identifier": "@0xinsider/mcp", + "registryType": "npm", + "runtimeHint": "npx", + "transport": { + "type": "stdio" + }, + "version": "2.14.0" + } + ], + "remotes": [ + { + "type": "streamable-http", + "url": "https://api.0xinsider.com/api/v1/mcp" + } + ], + "repository": { + "url": "https://github.com/0xinsider/agent-plugin" + }, + "title": "0xinsider", + "version": "2.14.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Microsoft Outlook (mail) MCP server — read, search, and send email through Microsoft Graph.", + "name": "io.github.0xka13b/microsoft-outlook-mcp", + "packages": [ + { + "environmentVariables": [ + { + "description": "Entra ID application (client) ID for the public client app used to sign in. Run `npx -y microsoft-outlook-mcp login` once to cache a refresh token.", + "name": "MICROSOFT_CLIENT_ID" + } + ], + "identifier": "microsoft-outlook-mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "1.0.1" + } + ], + "repository": { + "url": "https://github.com/0xka13b/microsoft-mcps" + }, + "version": "1.0.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Microsoft Calendar (Outlook Calendar) MCP server for managing events via Microsoft Graph.", + "name": "io.github.0xka13b/ms-calendar-mcp", + "packages": [ + { + "environmentVariables": [ + { + "description": "Entra ID application (client) ID for the public client app used to sign in. Run `npx -y ms-calendar-mcp login` once to cache a refresh token.", + "name": "MICROSOFT_CLIENT_ID" + } + ], + "identifier": "ms-calendar-mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "1.0.1" + } + ], + "repository": { + "url": "https://github.com/0xka13b/microsoft-mcps" + }, + "version": "1.0.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Microsoft Contacts MCP server — read and manage your address book through Microsoft Graph.", + "name": "io.github.0xka13b/ms-contacts-mcp", + "packages": [ + { + "environmentVariables": [ + { + "description": "Entra ID application (client) ID for the public client app used to sign in. Run `npx -y ms-contacts-mcp login` once to cache a refresh token.", + "name": "MICROSOFT_CLIENT_ID" + } + ], + "identifier": "ms-contacts-mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "1.0.1" + } + ], + "repository": { + "url": "https://github.com/0xka13b/microsoft-mcps" + }, + "version": "1.0.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Microsoft OneDrive MCP server — browse, read, and manage files and folders through Microsoft Graph.", + "name": "io.github.0xka13b/ms-onedrive-mcp", + "packages": [ + { + "environmentVariables": [ + { + "description": "Entra ID application (client) ID for the public client app used to sign in. Run `npx -y ms-onedrive-mcp login` once to cache a refresh token.", + "name": "MICROSOFT_CLIENT_ID" + } + ], + "identifier": "ms-onedrive-mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "1.0.1" + } + ], + "repository": { + "url": "https://github.com/0xka13b/microsoft-mcps" + }, + "version": "1.0.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Microsoft SharePoint MCP server — access sites, lists, and documents through Microsoft Graph.", + "name": "io.github.0xka13b/ms-sharepoint-mcp", + "packages": [ + { + "environmentVariables": [ + { + "description": "Entra ID application (client) ID for the public client app used to sign in. Run `npx -y ms-sharepoint-mcp login` once to cache a refresh token.", + "name": "MICROSOFT_CLIENT_ID" + } + ], + "identifier": "ms-sharepoint-mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "1.0.1" + } + ], + "repository": { + "url": "https://github.com/0xka13b/microsoft-mcps" + }, + "version": "1.0.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Read-only Hyperliquid vault search, risk, drawdown, rankings, TVL, alerts, and comparisons.", + "name": "io.github.0xkayser/vaultvision", + "remotes": [ + { + "type": "streamable-http", + "url": "https://vaultvision.tech/mcp" + } + ], + "repository": { + "url": "https://github.com/0xkayser/vaultvision-mcp" + }, + "title": "VaultVision Hyperliquid Vault Research", + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Attested evidence tools for AI agents: sanctions screening, agent/wallet records, paid via x402.", + "name": "io.github.0xsims/rubric-mcp", + "packages": [ + { + "environmentVariables": [ + { + "description": "Optional. USDC-on-Base wallet private key enabling paid x402 tools; without it paid tools return setup guidance.", + "isSecret": true, + "name": "RUBRIC_WALLET_KEY" + }, + { + "description": "Optional. Daily spend ceiling in USD for paid tools (default 1.00).", + "name": "RUBRIC_X402_DAILY_LIMIT" + }, + { + "description": "Optional. Rubric developer key for HCS-anchored attestation.", + "isSecret": true, + "name": "RUBRIC_API_KEY" + }, + { + "description": "Optional. Comma-separated tool modules (default core,x402).", + "name": "RUBRIC_MCP_MODULES" + } + ], + "identifier": "@rubric-protocol/mcp-server", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "2.2.2" + } + ], + "repository": { + "url": "https://github.com/0xsims/rubric-mcp" + }, + "version": "2.2.2" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Attest agent decisions and verify records on a public ledger. Evidence, not just data.", + "name": "io.github.0xsims/rubric-mcp-server", + "packages": [ + { + "identifier": "rubric_mcp_server", + "registryType": "pypi", + "transport": { + "type": "stdio" + }, + "version": "2.5.0" + } + ], + "repository": { + "url": "https://github.com/0xsims/rubric-mcp-py" + }, + "version": "2.5.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "AI compliance attestation for EU AI Act, SR 11-7, HIPAA. Free local tier, no key required.", + "name": "io.github.0xsims/rubric-protocol", + "packages": [ + { + "environmentVariables": [ + { + "description": "Rubric API key for Hedera mainnet anchoring. Optional — omit for free local PQ-signed attestation.", + "isSecret": true, + "name": "RUBRIC_API_KEY" + } + ], + "identifier": "@rubric-protocol/mcp-server", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "2.0.2" + } + ], + "repository": { + "url": "https://github.com/0xsims/rubric-mcp-server" + }, + "version": "2.0.2" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Real-time Hyperliquid perps intelligence: TA, funding, OI, liquidations. Pay-per-call via x402.", + "name": "io.github.0xsl1m/cerebrus-pulse-mcp", + "packages": [ + { + "identifier": "cerebrus-pulse-mcp", + "registryType": "pypi", + "transport": { + "type": "stdio" + }, + "version": "0.5.1" + } + ], + "repository": { + "url": "https://github.com/0xsl1m/cerebrus-pulse-mcp" + }, + "title": "Cerebrus Pulse MCP", + "version": "0.5.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Reputa: wallet risk \u0026 security plus DeFi cover (insurance) analyses, paid via x402.", + "name": "io.github.0xthaner/reputa", + "packages": [ + { + "environmentVariables": [ + { + "description": "API base URL (default https://reputa.xyz). Use http://localhost:5173 for local dev.", + "name": "REPUTA_BASE_URL" + }, + { + "description": "Optional wallet private key (USDC on Polygon) to auto-pay the paid x402 tools (scores, approvals, safe-check, safe-owner details, 24-month chain history).", + "isSecret": true, + "name": "REPUTA_PAYER_PRIVATE_KEY" + } + ], + "identifier": "reputa-mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "1.4.0" + } + ], + "repository": { + "url": "https://github.com/0xthaner/reputa" + }, + "version": "1.4.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Free no-key IP intelligence: geolocation, VPN detection, DNS, WHOIS, blacklists, breach checks", + "name": "io.github.0xvibly/hackmyip-mcp", + "remotes": [ + { + "type": "streamable-http", + "url": "https://hackmyip.com/mcp" + } + ], + "repository": { + "url": "https://github.com/0xvibly/hackmyip-mcp" + }, + "title": "HackMyIP — no-key IP intelligence", + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "OpenAI-compatible MCP gateway cataloging 22 LLM providers, with routing, failover, and tools.", + "name": "io.github.0xzr/freellmpool", + "packages": [ + { + "identifier": "freellmpool", + "packageArguments": [ + { + "type": "positional", + "value": "mcp" + } + ], + "registryType": "pypi", + "runtimeHint": "uvx", + "transport": { + "type": "stdio" + }, + "version": "0.13.0" + } + ], + "repository": { + "url": "https://github.com/0xzr/freellmpool" + }, + "title": "freellmpool", + "version": "0.13.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Shared knowledge base that people and AI agents read and write: pages, atoms, hybrid search.", + "name": "io.github.100monkeys-ai/cortex", + "remotes": [ + { + "headers": [ + { + "description": "Bearer \u003ctoken\u003e, a token minted in your instance's settings under API tokens", + "isSecret": true, + "name": "Authorization" + } + ], + "type": "streamable-http", + "url": "https://{instance}.cortex.page/api/mcp" + } + ], + "title": "Cortex", + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Runtime security for AI agent commerce. CLI + MCP server blocks hallucinated purchases.", + "name": "io.github.100xPercent/pop-pay", + "packages": [ + { + "environmentVariables": [ + { + "description": "Chrome DevTools Protocol endpoint for credential injection (default: http://localhost:9222)", + "name": "POP_CDP_URL" + }, + { + "description": "JSON array of allowed vendor categories (e.g. '[\"aws\",\"cloudflare\"]')", + "name": "POP_ALLOWED_CATEGORIES" + }, + { + "description": "Per-transaction spending limit in USD", + "name": "POP_MAX_PER_TX" + }, + { + "description": "Daily spending limit in USD", + "name": "POP_MAX_DAILY" + }, + { + "description": "Guardrail engine: 'keyword' (offline, default) or 'llm' (requires API key)", + "name": "POP_GUARDRAIL_ENGINE" + } + ], + "identifier": "pop-pay", + "registryType": "npm", + "runtimeArguments": [ + { + "isRequired": true, + "type": "positional", + "value": "launch-mcp" + } + ], + "transport": { + "type": "stdio" + }, + "version": "0.5.7" + } + ], + "repository": { + "url": "https://github.com/100xPercent/pop-pay" + }, + "version": "0.5.7" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "AI-optimized tool output compression for read, grep, diff, bash, test, web, search, and more", + "name": "io.github.10iii/air", + "packages": [ + { + "identifier": "@10iii/air-mcp-server", + "registryType": "npm", + "runtimeHint": "npx", + "transport": { + "type": "stdio" + }, + "version": "0.2.8" + } + ], + "repository": { + "url": "https://github.com/10iii/air" + }, + "version": "0.2.8" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "BLS Occupational Employment and Wage Statistics market wages. 7 tools.", + "name": "io.github.1102tools/bls-oews-mcp", + "packages": [ + { + "environmentVariables": [ + { + "description": "Optional BLS API key from https://data.bls.gov/registrationEngine/ for v2 (500/day).", + "name": "BLS_API_KEY" + } + ], + "identifier": "bls-oews-mcp", + "registryType": "pypi", + "transport": { + "type": "stdio" + }, + "version": "0.2.7" + } + ], + "repository": { + "url": "https://github.com/1102tools/federal-contracting-mcps" + }, + "title": "BLS OEWS", + "version": "0.2.7" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Electronic Code of Federal Regulations including FAR and DFARS. 13 tools.", + "name": "io.github.1102tools/ecfr-mcp", + "packages": [ + { + "identifier": "ecfr-mcp", + "registryType": "pypi", + "transport": { + "type": "stdio" + }, + "version": "0.2.6" + } + ], + "repository": { + "url": "https://github.com/1102tools/federal-contracting-mcps" + }, + "title": "eCFR", + "version": "0.2.6" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Federal Register proposed/final rules, notices, executive orders, FAR cases. 8 tools.", + "name": "io.github.1102tools/federal-register-mcp", + "packages": [ + { + "identifier": "federal-register-mcp", + "registryType": "pypi", + "transport": { + "type": "stdio" + }, + "version": "0.2.7" + } + ], + "repository": { + "url": "https://github.com/1102tools/federal-contracting-mcps" + }, + "title": "Federal Register", + "version": "0.2.7" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "GSA CALC+ awarded labor ceiling rates from 230K+ MAS contracts. 8 tools.", + "name": "io.github.1102tools/gsa-calc-mcp", + "packages": [ + { + "identifier": "gsa-calc-mcp", + "registryType": "pypi", + "transport": { + "type": "stdio" + }, + "version": "0.2.7" + } + ], + "repository": { + "url": "https://github.com/1102tools/federal-contracting-mcps" + }, + "title": "GSA CALC+", + "version": "0.2.7" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "GSA Per Diem federal travel lodging and M\u0026IE rates. 6 tools.", + "name": "io.github.1102tools/gsa-perdiem-mcp", + "packages": [ + { + "environmentVariables": [ + { + "description": "Free api.data.gov key from https://api.data.gov/signup/ for 1,000 req/hr.", + "isSecret": true, + "name": "PERDIEM_API_KEY" + } + ], + "identifier": "gsa-perdiem-mcp", + "registryType": "pypi", + "transport": { + "type": "stdio" + }, + "version": "0.2.6" + } + ], + "repository": { + "url": "https://github.com/1102tools/federal-contracting-mcps" + }, + "title": "GSA Per Diem", + "version": "0.2.6" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Regulations.gov rulemaking dockets, documents, public comments. 8 tools.", + "name": "io.github.1102tools/regulations-gov-mcp", + "packages": [ + { + "environmentVariables": [ + { + "description": "Free api.data.gov key from https://api.data.gov/signup/ for 1,000 req/hr.", + "isSecret": true, + "name": "REGULATIONS_GOV_API_KEY" + } + ], + "identifier": "regulationsgov-mcp", + "registryType": "pypi", + "transport": { + "type": "stdio" + }, + "version": "0.2.5" + } + ], + "repository": { + "url": "https://github.com/1102tools/federal-contracting-mcps" + }, + "title": "Regulations.gov", + "version": "0.2.5" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "SAM.gov entity registration, exclusions, opportunities, contract awards. 15 tools.", + "name": "io.github.1102tools/sam-gov-mcp", + "packages": [ + { + "environmentVariables": [ + { + "description": "Free SAM.gov API key from https://sam.gov. Rotates every 90 days.", + "isSecret": true, + "name": "SAM_API_KEY" + } + ], + "identifier": "sam-gov-mcp", + "registryType": "pypi", + "transport": { + "type": "stdio" + }, + "version": "0.4.1" + } + ], + "repository": { + "url": "https://github.com/1102tools/federal-contracting-mcps" + }, + "title": "SAM.gov", + "version": "0.4.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "USASpending.gov federal contracts, subawards, recipients, agencies. 55 tools.", + "name": "io.github.1102tools/usaspending-gov-mcp", + "packages": [ + { + "identifier": "usaspending-gov-mcp", + "registryType": "pypi", + "transport": { + "type": "stdio" + }, + "version": "0.3.2" + } + ], + "repository": { + "url": "https://github.com/1102tools/federal-contracting-mcps" + }, + "title": "USASpending.gov", + "version": "0.3.2" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Hypothesis-driven problem solving for AI agents: probe, falsify, escalate.", + "name": "io.github.1111111111111111111114oLvT2/inquisitor", + "packages": [ + { + "identifier": "inquisitor-mcp", + "registryType": "pypi", + "transport": { + "type": "stdio" + }, + "version": "0.2.2" + } + ], + "repository": { + "url": "https://github.com/1111111111111111111114oLvT2/inquisitor" + }, + "version": "0.2.2" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Structured company \u0026 industry news for AI agents: typed, dated, source-linked events.", + "name": "io.github.1145-am/syracuse-mcp-server", + "remotes": [ + { + "headers": [ + { + "description": "Your Syracuse API key (the raw key, no prefix). Free at syracuse.1145.am; not needed to connect or register.", + "isSecret": true, + "name": "X-API-Key" + } + ], + "type": "streamable-http", + "url": "https://syracuse.1145.am/mcp/" + } + ], + "repository": { + "url": "https://github.com/1145-am/syracuse-mcp-server" + }, + "title": "Syracuse Company News", + "version": "1.2.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Cloud MCP for project data, analytics, research, content planning, media generation, and growth.", + "name": "io.github.11Agents/cloud-mcp", + "remotes": [ + { + "headers": [ + { + "description": "11agents project token, sent as a Bearer token.", + "isSecret": true, + "name": "Authorization" + } + ], + "type": "streamable-http", + "url": "https://app.11agents.ai/mcp" + } + ], + "repository": { + "url": "https://github.com/11Agents/11agents-cloud-mcp" + }, + "title": "11agents Cloud MCP", + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "URL intelligence for AI agents and developers. 16 tools, 25 signal weights, 20 free checks.", + "name": "io.github.123Ergo/unphurl", + "packages": [ + { + "environmentVariables": [ + { + "description": "Your Unphurl API key (get one at unphurl.com or via the signup tool)", + "isSecret": true, + "name": "UNPHURL_API_KEY" + } + ], + "identifier": "@unphurl/mcp-server", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "0.2.3" + } + ], + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.unphurl.com/mcp" + } + ], + "repository": { + "url": "https://github.com/123Ergo/unphurl-mcp" + }, + "title": "Unphurl", + "version": "0.2.3" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Search 118,000+ Australian manufacturers by category and state, with Business contact access.", + "name": "io.github.1337mofo/australian-manufacturing-directory", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.ausmanufacturingdirectory.com/mcp" + } + ], + "title": "Australian Manufacturing Directory", + "version": "1.0.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Parametric should-cost: P50/P80/P90 estimates, 801 materials, 25 countries, quote review.", + "name": "io.github.1337mofo/costable", + "remotes": [ + { + "type": "streamable-http", + "url": "https://costable.ai/api/mcp" + } + ], + "title": "Costable — Parametric Should-Cost Analysis", + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Qimen Dunjia \u0026 Da Liu Ren divination: complete nine-palace charts and four-lesson analysis.", + "name": "io.github.139user/tword-divination", + "remotes": [ + { + "type": "streamable-http", + "url": "https://profound.fate-craft.com/api/mcp" + } + ], + "repository": { + "url": "https://github.com/139user/twords" + }, + "title": "衍象坊 · 奇门遁甲 \u0026 大六壬", + "version": "1.1.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Paid Capture and Guard tools for public-source observations before autonomous actions.", + "name": "io.github.15998194110/delta-witness", + "packages": [ + { + "identifier": "delta-witness-mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "0.7.1" + } + ], + "repository": { + "url": "https://github.com/15998194110/delta-witness" + }, + "version": "0.7.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "One MCP server for mod platforms and local modding diagnostics. No data kept.", + "name": "io.github.171county/modwrench", + "packages": [ + { + "identifier": "@modwrench/cli", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "0.2.4" + } + ], + "repository": { + "url": "https://github.com/171county/modwrench" + }, + "title": "ModWrench", + "version": "0.2.4" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "ATS resume checking, job description analysis, and AI resume tailoring powered by MatchCV.", + "name": "io.github.18boys/matchcv", + "packages": [ + { + "environmentVariables": [ + { + "description": "Override MatchCV API origin. Defaults to https://matchcv.co.", + "name": "MATCHCV_BASE_URL" + } + ], + "identifier": "matchcv-mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "0.1.1" + } + ], + "repository": { + "url": "https://github.com/18boys/matchcv-mcp" + }, + "version": "0.1.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Italian trails, huts, avalanche bulletins and elevation weather", + "name": "io.github.19Alma98/trekking-mcp", + "packages": [ + { + "identifier": "trekking-mcp", + "registryType": "pypi", + "runtimeHint": "uvx", + "transport": { + "type": "stdio" + }, + "version": "1.1.0" + } + ], + "repository": { + "url": "https://github.com/19Alma98/trekking_mcp" + }, + "title": "Sentieri e condizioni di montagna", + "version": "1.1.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "HSM-backed vault secrets for AI agents (JIT fetch) plus prompt-injection and threat scanning.", + "name": "io.github.1clawAI/1claw-mcp", + "packages": [ + { + "environmentVariables": [ + { + "description": "Agent API key (ocv_...). Exchanged for a short-lived JWT; auto-discovers agent ID and vault. Recommended for stdio.", + "isSecret": true, + "name": "ONECLAW_AGENT_API_KEY" + }, + { + "description": "Optional agent UUID when pinning identity (usually auto-discovered from the API key).", + "name": "ONECLAW_AGENT_ID" + }, + { + "description": "Optional vault UUID when the agent can access multiple vaults.", + "name": "ONECLAW_VAULT_ID" + }, + { + "description": "Vault API base URL (default https://api.1claw.xyz).", + "name": "ONECLAW_BASE_URL" + }, + { + "description": "Set to true for security-only mode (inspect_content only; no vault credentials).", + "name": "ONECLAW_LOCAL_ONLY" + } + ], + "identifier": "@1claw/mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "0.56.3" + } + ], + "repository": { + "url": "https://github.com/1clawAI/1claw-mcp" + }, + "title": "1Claw Vault", + "version": "0.56.3" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "75 tools for marketing analytics, campaign management and AI monitoring across 7 platforms.", + "name": "io.github.1clickreport/mcp", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.1clickreport.com/mcp" + } + ], + "repository": { + "url": "https://github.com/1clickreport/mcp" + }, + "title": "1ClickReport", + "version": "1.1.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Read-only MCP access to the 1F4BC job board for AI agents.", + "name": "io.github.1f4bcai/1f4bc", + "remotes": [ + { + "type": "streamable-http", + "url": "https://1f4bc.ai/mcp" + } + ], + "title": "1F4BC", + "version": "0.1.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "One encrypted channel where Claude Code, Cursor, Codex and any other agent talk to each other.", + "name": "io.github.1gr14/agents-party", + "packages": [ + { + "identifier": "agents-party", + "packageArguments": [ + { + "description": "Run the party MCP server over stdio.", + "type": "positional", + "value": "mcp", + "valueHint": "command" + } + ], + "registryType": "npm", + "runtimeHint": "npx", + "transport": { + "type": "stdio" + }, + "version": "0.7.2" + } + ], + "repository": { + "url": "https://github.com/1gr14/agents-party" + }, + "version": "0.7.2" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "MCP server for 1ly.store — buy/sell APIs and launch tokens on Bags.fm with USDC \u0026 $1LY.", + "name": "io.github.1lystore/mcp-server", + "packages": [ + { + "environmentVariables": [ + { + "description": "Path to Solana keypair JSON file (e.g. /home/user/.1ly/wallets/solana.json) or inline JSON byte array. Required for paid API calls and token tools (launch, trade, claim). Note: Claude Desktop does not expand ~, use absolute paths.", + "isSecret": true, + "name": "ONELY_WALLET_SOLANA_KEY" + }, + { + "description": "Path to EVM private key file or 0x-prefixed hex string. Required for payments on Base network.", + "isSecret": true, + "name": "ONELY_WALLET_EVM_KEY" + }, + { + "description": "1ly.store seller API key. Auto-saved locally after running the 1ly_create_store tool — you do not need to set this manually. Required for seller tools: manage links, stats, withdrawals, keys, and profile.", + "isSecret": true, + "name": "ONELY_API_KEY" + }, + { + "description": "Wallet provider to use. 'raw' (default) uses local key files via ONELY_WALLET_SOLANA_KEY or ONELY_WALLET_EVM_KEY. 'coinbase' uses Coinbase Agentic Wallet (Base network only, requires the wallet app running locally).", + "name": "ONELY_WALLET_PROVIDER" + }, + { + "description": "Preferred blockchain network for payments. 'solana' (default) or 'base'. Determines which wallet is used when an API supports both networks.", + "name": "ONELY_NETWORK" + }, + { + "description": "Solana RPC endpoint URL. Defaults to https://api.mainnet-beta.solana.com. Use a private RPC (e.g. Helius, Quicknode) for better reliability and rate limits.", + "name": "ONELY_SOLANA_RPC_URL" + }, + { + "description": "Maximum USD amount allowed per single paid API call. Defaults to 1.00. The server will refuse any call priced above this limit.", + "name": "ONELY_BUDGET_PER_CALL" + }, + { + "description": "Maximum total USD spending per calendar day (UTC). Defaults to 50.00. Resets at UTC midnight. The server will refuse calls that would exceed this limit.", + "name": "ONELY_BUDGET_DAILY" + }, + { + "description": "Path to the local JSON file used to track daily spending. Defaults to ~/.1ly-mcp-budget.json. Created automatically with owner-only permissions (0600).", + "name": "ONELY_BUDGET_STATE_FILE" + }, + { + "description": "Set to '1' to simulate Solana transactions without broadcasting to the blockchain. Returns a fake signature. Use for testing only — no real payments will be made.", + "name": "ONELY_SOLANA_DRY_RUN" + }, + { + "description": "Override the 1ly.store API base URL. Defaults to https://1ly.store. Only http://localhost:PORT is accepted as an alternative (for local development). Do not change in production.", + "name": "ONELY_API_BASE" + } + ], + "identifier": "@1ly/mcp-server", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "0.1.7" + } + ], + "repository": { + "url": "https://github.com/1lystore/1ly-mcp-server" + }, + "version": "0.1.7" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Search transcribed painting lessons. Results cite the exact lesson and Vimeo timestamp.", + "name": "io.github.1nzpainter/painting-lessons", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mypaintingclub.com/kb/mcp" + } + ], + "repository": { + "url": "https://github.com/1nzpainter/MCP" + }, + "title": "My Painting Club — Painting Lessons", + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Persistent memory and context for your agent, on a board you can see. Capped, so it stays small.", + "name": "io.github.1picassoai/sticky", + "packages": [ + { + "identifier": "sticky-mcp", + "registryType": "npm", + "runtimeArguments": [ + { + "isRequired": true, + "type": "positional", + "value": "--mcp", + "valueHint": "--mcp" + } + ], + "transport": { + "type": "stdio" + }, + "version": "0.2.2" + } + ], + "repository": { + "url": "https://github.com/1picassoai/sticky" + }, + "version": "0.2.2" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Private GANO MCP for feasibility, scenario analysis, persistence and executive operations.", + "name": "io.github.1powergrey/sios-gano-connector", + "remotes": [ + { + "type": "streamable-http", + "url": "https://sios-gano-connector.vercel.app/mcp" + } + ], + "repository": { + "url": "https://github.com/1powergrey/sios-gano-connector" + }, + "title": "GANO Connector", + "version": "1.5.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "MCP server for DebugAI. Browser sign-in, auto client setup, no key pasting.", + "name": "io.github.1shizaan/debugai-mcp", + "packages": [ + { + "environmentVariables": [ + { + "description": "Optional. Overrides browser sign-in (npx -y @debugai/mcp setup).", + "isSecret": true, + "name": "DEBUGAI_API_KEY" + } + ], + "identifier": "@debugai/mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "2.1.1" + } + ], + "repository": { + "url": "https://github.com/1shizaan/debugai-mcp" + }, + "version": "2.1.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Free joined public records for small business and CRE: Twin Cities parcels, sales, licences", + "name": "io.github.2016judea/small-business-intelligence", + "remotes": [ + { + "type": "streamable-http", + "url": "https://brickandmortar.dev/mcp" + } + ], + "repository": { + "url": "https://github.com/2016judea/small-business-intelligence-mcp" + }, + "title": "Small Business Intelligence by Brick \u0026 Mortar", + "version": "0.2.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Read Chalkieboard (iPad) iCloud lesson folders, add materials, append lesson plans. No network.", + "name": "io.github.207studio/chalkieboard-icloud-mcp", + "packages": [ + { + "identifier": "https://github.com/207studio/chalkieboard-icloud-mcp/releases/download/v2.0.1/chalkieboard.mcpb", + "registryType": "mcpb", + "transport": { + "type": "stdio" + } + } + ], + "repository": { + "url": "https://github.com/207studio/chalkieboard-icloud-mcp" + }, + "title": "Chalkieboard iCloud", + "version": "2.0.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "MCP server that exposes Claude-style skills to any MCP client.", + "name": "io.github.214140846/skillhub-mcp", + "packages": [ + { + "identifier": "skillhub-mcp", + "registryType": "pypi", + "transport": { + "type": "stdio" + }, + "version": "1.0.5" + } + ], + "repository": { + "url": "https://github.com/214140846/skillhub-mcp" + }, + "version": "1.0.5" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "21 tools: search 4,000+ Australian promotional products with live pricing, quotes and curated edits.", + "name": "io.github.24seagull-beep/sense2-catalogue", + "remotes": [ + { + "type": "streamable-http", + "url": "https://sense2.com.au/api/mcp" + } + ], + "repository": { + "url": "https://github.com/24seagull-beep/sense2-next" + }, + "title": "Sense2 Promotional Products", + "version": "1.1.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Game numerical design \u0026 balance auditing MCP server — 17 deterministic tools, zero LLM guessing.", + "name": "io.github.26048608982lp-ai/gamenumerics", + "packages": [ + { + "environmentVariables": [ + { + "description": "Workspace root directory where imported xlsx tables land (default: ~/.gamenumerics/workspaces)", + "name": "GND_WORKSPACES_DIR" + } + ], + "identifier": "mcp-server-gamenumerics", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "0.1.2" + } + ], + "repository": { + "url": "https://github.com/26048608982lp-ai/gamenumerics" + }, + "version": "0.1.2" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Agent-built web app quality gate. Real browser is the judge. PASS/FAIL with receipts.", + "name": "io.github.263311487-ux/dsh-verify", + "packages": [ + { + "identifier": "dsh-verify", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "0.9.1" + } + ], + "repository": { + "url": "https://github.com/263311487-ux/dsh-verify" + }, + "version": "0.9.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "AI-native print-grade PDF generator. Markdown in, publisher-quality PDF out.", + "name": "io.github.263311487-ux/imprint-pdf", + "packages": [ + { + "identifier": "imprint-pdf", + "registryType": "pypi", + "transport": { + "type": "stdio" + }, + "version": "0.14.0" + } + ], + "repository": { + "url": "https://github.com/263311487-ux/imprint-pdf" + }, + "title": "Imprint PDF", + "version": "0.14.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Authorization-gated MCP server to discover and run 670+ security tools for CTF, pentest, and DFIR.", + "name": "io.github.26zl/cybersec-toolkit", + "packages": [ + { + "environmentVariables": [ + { + "description": "Set to 1 only for explicitly authorized external scopes.", + "name": "CYBERSEC_MCP_ALLOW_EXTERNAL" + }, + { + "description": "Set to 1 to enable unsandboxed run_script execution.", + "name": "CYBERSEC_MCP_ALLOW_SCRIPTS" + } + ], + "identifier": "ghcr.io/26zl/cybersec-toolkit:1.3.0", + "packageArguments": [ + { + "type": "positional", + "value": "run" + }, + { + "type": "positional", + "value": "--directory" + }, + { + "type": "positional", + "value": "mcp_server" + }, + { + "type": "positional", + "value": "fastmcp" + }, + { + "type": "positional", + "value": "run" + }, + { + "type": "positional", + "value": "server.py" + }, + { + "type": "positional", + "value": "--transport" + }, + { + "type": "positional", + "value": "stdio" + }, + { + "type": "positional", + "value": "--no-banner" + } + ], + "registryType": "oci", + "runtimeArguments": [ + { + "name": "--entrypoint", + "type": "named", + "value": "uv" + } + ], + "transport": { + "type": "stdio" + } + } + ], + "repository": { + "url": "https://github.com/26zl/cybersec-toolkit" + }, + "version": "1.3.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Search live UK firefighter recruitment across 73 fire services, with on-call station data.", + "name": "io.github.29yeovil/bluewatch", + "remotes": [ + { + "type": "streamable-http", + "url": "https://bluewatch.app/mcp" + } + ], + "title": "Bluewatch", + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Persistent, interactive MCP Apps — the AI builds an app once, you both reuse it", + "name": "io.github.2nd1st/open-mcp-apps", + "packages": [ + { + "environmentVariables": [ + { + "description": "Path to the SQLite store. Defaults to a fixed per-user data directory; set this to isolate a store.", + "name": "OMA_DB" + }, + { + "description": "Browser viewer on loopback. On by default; set to 0 to turn it off.", + "name": "OMA_VIEWER" + }, + { + "description": "Set to 1 to also expose one open_\u003cname\u003e tool per saved app. Off by default, because it costs prompt cache.", + "name": "OMA_DYNAMIC_TOOLS" + } + ], + "identifier": "@2nd1st/open-mcp-apps", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "0.5.9" + } + ], + "repository": { + "url": "https://github.com/2nd1st/open-mcp-apps" + }, + "version": "0.5.9" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Star Ninja reports sky darkness, cloud cover and moonless hours for a night-sky spot you name.", + "name": "io.github.2pm-ninja/star-ninja", + "remotes": [ + { + "type": "streamable-http", + "url": "https://stars.2pm.ninja/mcp" + } + ], + "title": "Star Ninja", + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "575+ agent tools: data, AI gateway, storage/queues/watchers. x402 USDC, no keys, usage billing.", + "name": "io.github.2s-io/mcp", + "packages": [ + { + "environmentVariables": [ + { + "description": "Hex-encoded EVM private key (0x-prefixed) used by the SDK to sign x402 EIP-3009 payment authorizations. Funds USDC on Base.", + "isSecret": true, + "name": "EVM_PRIVATE_KEY" + } + ], + "identifier": "@2sio/mcp", + "registryType": "npm", + "runtimeHint": "npx", + "transport": { + "type": "stdio" + }, + "version": "1.82.0" + } + ], + "repository": { + "url": "https://github.com/2s-io/sdk" + }, + "version": "1.82.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Control DaVinci Resolve, including the free Lite edition. Manual setup only — see README.", + "name": "io.github.2sem/davinci-resolve-lite-mcp", + "repository": { + "url": "https://github.com/2sem/davinci-resolve-lite-mcp" + }, + "title": "DaVinci Resolve Lite MCP", + "version": "0.18.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Norwegian address geocoding, places, properties, buildings \u0026 elevation from Kartverket.", + "name": "io.github.3121n/kartverket-mcp", + "packages": [ + { + "identifier": "@nor-data/kartverket-mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "0.4.2" + } + ], + "repository": { + "url": "https://github.com/3121n/nor-data-kartverket-mcp" + }, + "version": "0.4.2" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Norwegian neighborhood data: transit, road-noise \u0026 green-area access by coordinate.", + "name": "io.github.3121n/nabolag-mcp", + "packages": [ + { + "identifier": "@nor-data/nabolag-mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "0.2.2" + } + ], + "repository": { + "url": "https://github.com/3121n/nor-data-nabolag-mcp" + }, + "version": "0.2.2" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Norwegian flood \u0026 landslide hazard data from NVE, by WGS84 coordinate.", + "name": "io.github.3121n/nve-mcp", + "packages": [ + { + "identifier": "@nor-data/nve-mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "0.1.2" + } + ], + "repository": { + "url": "https://github.com/3121n/nor-data-nve-mcp" + }, + "version": "0.1.2" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Statistics Finland (StatFin/PxWeb): search tables, read metadata, fetch JSON-stat2 data.", + "name": "io.github.3121n/statfin-mcp", + "packages": [ + { + "identifier": "@nor-data/statfin-mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "0.1.0" + } + ], + "repository": { + "url": "https://github.com/3121n/nor-data-statfin-mcp" + }, + "version": "0.1.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "GitHub via MCP: search, repos, issues, PRs, files, notifications. OAuth device flow or token.", + "name": "io.github.Abhishekkumar2021/github", + "packages": [ + { + "environmentVariables": [ + { + "description": "A GitHub Personal Access Token (skips the OAuth device flow).", + "isSecret": true, + "name": "GITHUB_TOKEN" + }, + { + "description": "OAuth App client id (public) to enable the device-flow login.", + "name": "GITHUB_CLIENT_ID" + }, + { + "description": "Set to 1 or true to disable issue-creating/commenting tools.", + "name": "GITHUB_READONLY" + } + ], + "identifier": "@abhishekmcp/github", + "registryType": "npm", + "runtimeHint": "npx", + "transport": { + "type": "stdio" + }, + "version": "0.2.0" + } + ], + "repository": { + "url": "https://github.com/Abhishekkumar2021/mcp-suite" + }, + "title": "GitHub", + "version": "0.2.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Personal RAG over your GitHub history (commits, code, reviews), served to Claude Code over MCP.", + "name": "io.github.ChristopherDavenport/github-twin", + "packages": [ + { + "environmentVariables": [ + { + "description": "GitHub personal access token. Optional: github-twin can also pick the token up from `gh auth token` or from its own OAuth device-flow login (`gt auth login`).", + "isSecret": true, + "name": "GITHUB_TOKEN" + }, + { + "description": "Optional. Enables the Claude backend for `gt summarize`, `gt distill`, and `gt eval`. Defaults to local Ollama when unset.", + "isSecret": true, + "name": "ANTHROPIC_API_KEY" + }, + { + "description": "Optional. Enables the Gemini backend for embedding and the LLM seam. Vertex AI ADC via `GT_GEMINI_PROJECT` is also supported.", + "isSecret": true, + "name": "GEMINI_API_KEY" + }, + { + "description": "Optional. Override the on-disk data directory holding the SQLite index, embeddings, and cached GitHub responses. Defaults to `$XDG_DATA_HOME/github-twin`.", + "name": "GT_PATHS__DATA_DIR" + } + ], + "identifier": "github-twin", + "registryType": "pypi", + "runtimeArguments": [ + { + "type": "positional", + "value": "serve" + } + ], + "runtimeHint": "uvx", + "transport": { + "type": "stdio" + }, + "version": "0.0.17" + } + ], + "repository": { + "url": "https://github.com/ChristopherDavenport/github-twin" + }, + "title": "github-twin", + "version": "0.0.17" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "MCP server for GitHub operations (PRs, issues, actions) with structured, token-efficient output", + "name": "io.github.Dave-London/github", + "packages": [ + { + "identifier": "@paretools/github", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "0.7.1" + } + ], + "repository": { + "url": "https://github.com/Dave-London/Pare" + }, + "version": "0.7.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "MCP server for GitHub: repos, files, issues, PRs, search. Single Go binary.", + "name": "io.github.FerhatDundar/github-mcp-connector", + "packages": [ + { + "environmentVariables": [ + { + "description": "GitHub personal access token (classic or fine-grained)", + "isSecret": true, + "name": "GITHUB_TOKEN" + }, + { + "description": "GitHub Enterprise Server API base URL. Leave unset for github.com.", + "name": "GITHUB_API_URL" + } + ], + "identifier": "https://github.com/FerhatDundar/github-mcp-connector/releases/download/v0.1.1/github-mcp-connector-plugin-v0.1.1-darwin-arm64.zip", + "registryType": "mcpb", + "transport": { + "type": "stdio" + }, + "version": "0.1.1" + } + ], + "repository": { + "url": "https://github.com/FerhatDundar/github-mcp-connector" + }, + "version": "0.1.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Normalized GitHub-CLI bridge for Claude Code — 89 ops, 6 families incl. federation sources", + "name": "io.github.Kirchlive/github-inside-claude-code", + "packages": [ + { + "environmentVariables": [ + { + "description": "API key for the Tavily federation source (gh_research_tavily_search / gh_research_tavily_research). Required only if those research ops are invoked.", + "isSecret": true, + "name": "TAVILY_API_KEY" + }, + { + "description": "API key for the Brave Search federation source (gh_research_brave_search). Required only if that research op is invoked.", + "isSecret": true, + "name": "BRAVE_API_KEY" + } + ], + "identifier": "github-inside-claude-code", + "registryType": "pypi", + "runtimeArguments": [ + { + "type": "positional", + "value": "gh-tool-use-mcp" + } + ], + "runtimeHint": "uvx", + "transport": { + "type": "stdio" + }, + "version": "0.9.6" + } + ], + "repository": { + "url": "https://github.com/Kirchlive/github-inside-claude-code" + }, + "title": "GitHub Inside Claude Code", + "version": "0.9.6" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "MCP server bridging GitHub webhooks via Cloudflare Worker for real-time event streaming", + "name": "io.github.Liplus-Project/github-webhook-mcp", + "packages": [ + { + "environmentVariables": [ + { + "description": "URL of your deployed Cloudflare Worker endpoint (default: https://github-webhook.smgjp.com)", + "name": "WEBHOOK_WORKER_URL" + }, + { + "description": "Set to '0' to disable SSE channel notifications (default: enabled)", + "name": "WEBHOOK_CHANNEL" + } + ], + "identifier": "github-webhook-mcp", + "registryType": "npm", + "runtimeHint": "npx", + "transport": { + "type": "stdio" + }, + "version": "0.8.2" + } + ], + "repository": { + "url": "https://github.com/Liplus-Project/github-webhook-mcp" + }, + "version": "0.8.2" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Get GitHub repository stats, search repos, analyze languages, and compare projects", + "name": "io.github.TheNextGenNexus/github-analytics-mcp-server", + "packages": [ + { + "environmentVariables": [ + { + "description": "Your Apify API token for accessing web scraping actors", + "isSecret": true, + "name": "APIFY_TOKEN" + } + ], + "identifier": "@thenextgennexus/github-analytics-mcp-server", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "1.0.1" + } + ], + "repository": { + "url": "https://github.com/TheNextGenNexus/ai-analytics-mcp-servers" + }, + "version": "1.0.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "GitHub Actions workflow security audit - 21 checks: pinning, permissions, secrets, injection.", + "name": "io.github.UnbearableDev/github-actions-audit", + "remotes": [ + { + "headers": [ + { + "description": "Apify API token. Format: 'Bearer \u003cYOUR_APIFY_TOKEN\u003e'. Get yours at https://console.apify.com/account/integrations", + "isSecret": true, + "name": "Authorization" + } + ], + "type": "streamable-http", + "url": "https://unbearable-dev--github-actions-audit.apify.actor/mcp" + } + ], + "repository": { + "url": "https://github.com/UnbearableDev/github-actions-audit" + }, + "title": "GitHub Actions Audit", + "version": "1.0.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Self-hosted GitHub MCP server. PAT auth, stdio transport, transport-agnostic so it works with…", + "name": "io.github.adelaidasofia/github-mcp", + "packages": [ + { + "environmentVariables": [ + { + "description": "Classic or fine-grained PAT with repo + workflow scopes", + "isSecret": true, + "name": "GITHUB_TOKEN" + } + ], + "identifier": "https://github.com/adelaidasofia/github-mcp/releases/download/v0.1.0/github-mcp.mcpb", + "registryType": "mcpb", + "transport": { + "type": "stdio" + }, + "version": "0.1.0" + } + ], + "repository": { + "url": "https://github.com/adelaidasofia/github-mcp" + }, + "title": "Self-hosted GitHub MCP server", + "version": "0.1.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Monitor GitHub repo health, DORA metrics and CI signals from your AI assistant.", + "name": "io.github.alexbypa/github-projectpulse-mcp", + "packages": [ + { + "environmentVariables": [ + { + "description": "GitHub personal access token. Optional - public repos work without it; raises rate limits and enables private repository access.", + "isSecret": true, + "name": "GITHUB_TOKEN" + } + ], + "identifier": "projectpulse-mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "1.8.4" + } + ], + "repository": { + "url": "https://github.com/alexbypa/github-projectpulse-mcp" + }, + "title": "ProjectPulse MCP", + "version": "1.8.4" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Track public GitHub repos: free snapshot, paid release, star \u0026 issue intel via x402 USDC.", + "name": "io.github.contentforge-press/github-intel", + "packages": [ + { + "identifier": "github-repo-change-intelligence", + "registryType": "npm", + "transport": { + "type": "streamable-http", + "url": "https://s-github.pixharvest.com/mcp" + }, + "version": "1.0.0" + } + ], + "remotes": [ + { + "type": "streamable-http", + "url": "https://s-github.pixharvest.com/mcp" + } + ], + "title": "GitHub Repo Change Intelligence", + "version": "1.0.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "AI-optimized GitHub MCP server: 112 tools, 98% token reduction, compact responses.", + "name": "io.github.crypto-ninja/github-mcp-server", + "packages": [ + { + "identifier": "github-mcp-server", + "registryType": "pypi", + "transport": { + "type": "stdio" + }, + "version": "2.5.7" + } + ], + "repository": { + "url": "https://github.com/crypto-ninja/mcp-server-for-Github" + }, + "version": "2.5.7" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "GitHub MCP Server - List PRs, issues, repo info, and search code", + "name": "io.github.davidweb3-ctrl/github", + "packages": [ + { + "environmentVariables": [ + { + "description": "GitHub Personal Access Token", + "isSecret": true, + "name": "GITHUB_TOKEN" + } + ], + "identifier": "@davidweb3-ctrl/mcp-github-server", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "1.0.0" + } + ], + "repository": { + "url": "https://github.com/davidweb3-ctrl/mcp-github-server" + }, + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "GitHub repo maintainability verdicts—maintained, slowing, at-risk, abandoned—via MCP.", + "name": "io.github.digitalgremlin/github-repo-intelligence-mcp", + "remotes": [ + { + "type": "streamable-http", + "url": "https://joeslade--github-repo-intelligence-mcp.apify.actor/mcp" + } + ], + "repository": { + "url": "https://github.com/digitalgremlin/github-repo-intelligence-mcp" + }, + "version": "0.1.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Connect AI assistants to GitHub - manage repos, issues, PRs, and workflows through natural language.", + "name": "io.github.github/github-mcp-server", + "packages": [ + { + "identifier": "ghcr.io/github/github-mcp-server:1.13.0", + "registryType": "oci", + "runtimeArguments": [ + { + "description": "Publish the OAuth callback port to loopback so the in-container login callback is reachable", + "name": "-p", + "type": "named", + "value": "127.0.0.1:8085:8085" + }, + { + "description": "Fixed OAuth callback port, matching the published port above", + "name": "-e", + "type": "named", + "value": "GITHUB_OAUTH_CALLBACK_PORT=8085" + }, + { + "description": "Optional GitHub Personal Access Token. Omit to log in with OAuth on first use.", + "name": "-e", + "type": "named", + "value": "GITHUB_PERSONAL_ACCESS_TOKEN={token}", + "variables": { + "token": { + "format": "string", + "isSecret": true + } + } + } + ], + "transport": { + "type": "stdio" + } + } + ], + "remotes": [ + { + "headers": [ + { + "description": "Authorization header with authentication token (PAT or App token)", + "isSecret": true, + "name": "Authorization" + } + ], + "type": "streamable-http", + "url": "https://api.githubcopilot.com/mcp/" + } + ], + "repository": { + "url": "https://github.com/github/github-mcp-server" + }, + "title": "GitHub", + "version": "1.13.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Repo intel for AI coding agents: overview, PRs, contributors, hot files, CI, deps. Remote MCP.", + "name": "io.github.heisencodex-jpg/github-repo-intel-mcp", + "remotes": [ + { + "headers": [ + { + "description": "Apify API token, formatted as: Bearer apify_api_xxx. Get a free token at https://console.apify.com/account/integrations.", + "isSecret": true, + "name": "Authorization" + } + ], + "type": "streamable-http", + "url": "https://cg-nguyen--github-repo-intel-mcp.apify.actor/mcp" + } + ], + "repository": { + "url": "https://github.com/heisencodex-jpg/github-repo-intel-mcp" + }, + "title": "GitHub Repository Intelligence", + "version": "0.3.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "MCP server for the GitHub REST API: issues, PRs, repos; read always on, write env-gated.", + "name": "io.github.jaimenbell/github-mcp", + "packages": [ + { + "identifier": "jaimenbell-github-mcp", + "registryType": "pypi", + "transport": { + "type": "stdio" + }, + "version": "0.1.1" + } + ], + "repository": { + "url": "https://github.com/jaimenbell/github-mcp" + }, + "version": "0.1.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "GitHub repository intelligence: search repos, read repo details, and list user repos. No key...", + "name": "io.github.mrfentmen/github-intel-mcp", + "packages": [ + { + "identifier": "github-intel-mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "1.0.0" + } + ], + "repository": { + "url": "https://github.com/mrfentmen/github-intel-mcp" + }, + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "GitHub status from GitHub Status. No key required.", + "name": "io.github.mrfentmen/github-status-mcp", + "packages": [ + { + "identifier": "github-status-mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "1.0.0" + } + ], + "repository": { + "url": "https://github.com/mrfentmen/github-status-mcp" + }, + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Convert any GitHub repo to MCP server - automatic tool generation API wrap", + "name": "io.github.nirholas/github-to-mcp", + "repository": { + "url": "https://github.com/nirholas/github-to-mcp" + }, + "title": "GitHub to MCP Converter", + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "GitHub Actions MCP — view runs, read logs, re-run jobs, and manage CI/CD.", + "name": "io.github.ofershap/github-actions", + "packages": [ + { + "identifier": "mcp-server-github-actions", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "1.0.1" + } + ], + "repository": { + "url": "https://github.com/ofershap/mcp-server-github-actions" + }, + "version": "1.0.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "MCP server to create, read, update, list, and search GitHub Gists from your IDE", + "name": "io.github.ofershap/github-gist", + "packages": [ + { + "identifier": "mcp-server-github-gist", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "1.0.2" + } + ], + "repository": { + "url": "https://github.com/ofershap/mcp-server-github-gist" + }, + "version": "1.0.2" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Retrieves GitHub PR history as context material; the connected IDE agent does the reasoning.", + "name": "io.github.paarths-collab/github-pr-context-mcp", + "repository": { + "url": "https://github.com/paarths-collab/github-pr-context-mcp" + }, + "title": "GitHub PR Context", + "version": "0.3.3" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "AI agent vision for GitHub repository monitoring — with ROI measurement.", + "name": "io.github.perceptdot/github", + "packages": [ + { + "identifier": "@perceptdot/github", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "0.1.5" + } + ], + "title": "Perceptdot GitHub", + "version": "0.1.5" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "GitHub MCP — wraps the GitHub public REST API (no auth required for public endpoints)", + "name": "io.github.pipeworx-io/github", + "remotes": [ + { + "type": "streamable-http", + "url": "https://gateway.pipeworx.io/github/mcp" + } + ], + "repository": { + "url": "https://github.com/pipeworx-io/mcp-github" + }, + "title": "Github", + "version": "0.1.6" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "GitHub Private MCP Pack — access private repos, org data via OAuth.", + "name": "io.github.pipeworx-io/github_private", + "remotes": [ + { + "type": "streamable-http", + "url": "https://gateway.pipeworx.io/github_private/mcp" + } + ], + "repository": { + "url": "https://github.com/pipeworx-io/mcp-github_private" + }, + "title": "Github_private", + "version": "0.1.2" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "GitHub analytics - repos, PRs, issues, releases, contributors", + "name": "io.github.rog0x/github", + "packages": [ + { + "identifier": "@rog0x/mcp-github-tools", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "1.0.1" + } + ], + "repository": { + "url": "https://github.com/rog0x/mcp-github-tools" + }, + "version": "1.0.1" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "MCP server for GitHub repo stats: stars, forks, contributors, activity.", + "name": "io.github.ryudi84/github-stats", + "packages": [ + { + "identifier": "sovereign-github-stats-mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "1.0.0" + } + ], + "repository": { + "url": "https://github.com/ryudi84/sovereign-mcp-servers" + }, + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Parse owner and repo from a GitHub URL. Path discarded.", + "name": "io.github.sadri-dridi/github-repo-shape", + "remotes": [ + { + "type": "streamable-http", + "url": "https://agent-observatory-sensor.nolimit-observatory.workers.dev/s/github-repo-shape/mcp" + } + ], + "repository": { + "url": "https://github.com/sadri-dridi/named-mcp-utilities" + }, + "title": "GitHub Repo Shape", + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "AI-powered MCP server for generating GitHub issues and detecting semantic duplicates.", + "name": "io.github.sarim-aliii/github-issue-mcp", + "packages": [ + { + "environmentVariables": [ + { + "description": "GitHub personal access token used to access the configured repository.", + "isSecret": true, + "name": "GITHUB_TOKEN" + }, + { + "description": "GitHub repository owner or organization.", + "name": "GITHUB_OWNER" + }, + { + "description": "GitHub repository name.", + "name": "GITHUB_REPO" + }, + { + "description": "Google Gemini API key used for AI-powered issue generation and semantic duplicate analysis.", + "isSecret": true, + "name": "GEMINI_API_KEY" + } + ], + "identifier": "github-issue-mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "1.1.2" + } + ], + "repository": { + "url": "https://github.com/sarim-aliii/github-issue-mcp" + }, + "title": "GitHub Issue MCP", + "version": "1.1.2" + } + }, { - "id": "io.github.example/github-notes", - "name": "GitHub Notes", - "description": "Notes for GitHub issues", - "installCmd": "npx github-notes-mcp" + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Read public GitHub Projects v2 boards without auth — items, fields, story points, priority.", + "name": "io.github.shubhtoy/github-project-info", + "packages": [ + { + "identifier": "github-project-info-mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "0.1.3" + } + ], + "repository": { + "url": "https://github.com/shubhtoy/github-project-info-mcp" + }, + "version": "0.1.3" + } }, { - "id": "io.github.github/github-mcp-server", - "name": "GitHub", - "description": "GitHub's official MCP server", - "url": "https://api.githubcopilot.com/mcp/" + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "MCP server for GitHub repo health, commit summaries, issue triage, and RAG Q\u0026A.", + "name": "io.github.thedhruv-07/github-repo-mcp", + "packages": [ + { + "environmentVariables": [ + { + "description": "GitHub personal access token (classic, no scopes needed for public repos). Raises the rate limit from 60/hr to 5000/hr. https://github.com/settings/tokens", + "isSecret": true, + "name": "GITHUB_TOKEN" + }, + { + "description": "Voyage AI API key, required for ask_repo's embeddings. Free tier at https://dashboard.voyageai.com/", + "isSecret": true, + "name": "VOYAGE_API_KEY" + }, + { + "description": "Qdrant Cloud cluster URL, required for ask_repo's vector storage. Free tier at https://cloud.qdrant.io/", + "name": "QDRANT_URL" + }, + { + "description": "API key for your Qdrant Cloud cluster.", + "isSecret": true, + "name": "QDRANT_API_KEY" + } + ], + "identifier": "@thedhruv07/github-repo-mcp", + "registryType": "npm", + "transport": { + "type": "stdio" + }, + "version": "0.1.0" + } + ], + "repository": { + "url": "https://github.com/thedhruv-07/github-repo-mcp" + }, + "version": "0.1.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "GitHub repo analytics, trending repos, contributor stats, and language breakdowns.", + "name": "io.github.therealMrFunGuy/github-insights", + "remotes": [ + { + "headers": [ + { + "description": "API key from https://auth.rjctdlabs.xyz/dashboard", + "isSecret": true, + "name": "X-API-Key" + } + ], + "type": "sse", + "url": "https://github.rjctdlabs.xyz/sse" + } + ], + "title": "GitHubInsights", + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Audit GitHub Actions workflows for script injection, unpinned actions and missing permissions.", + "name": "io.github.tylerscomic-lab/github-actions-audit-mcp", + "remotes": [ + { + "type": "streamable-http", + "url": "https://github-actions-audit-mcp.mcpize.run/mcp" + } + ], + "repository": { + "url": "https://github.com/tylerscomic-lab/github-actions-audit-mcp" + }, + "title": "GitHub Actions Security Audit", + "version": "1.0.0" + } + }, + { + "_meta": { + "io.modelcontextprotocol.registry/official": { + "isLatest": true, + "status": "active" + } + }, + "server": { + "description": "Dive into the world of open-source with the GitHub Repo Explorer! Utilize the powerful GitHub", + "name": "io.github.varvararatta/github_public_repos_mcp", + "remotes": [ + { + "type": "streamable-http", + "url": "https://github-public-repos-mcp.mcpize.run/mcp" + } + ], + "repository": { + "url": "https://github.com/varvararatta/botfactory-mcp" + }, + "title": "Github Public Repos Mcp", + "version": "1.0.0" + } } ] }, @@ -49,10 +4225,25 @@ ], "ids": [ "official:io.github.github/github-mcp-server", - "official:io.github.example/github-notes", - "community:acme/github-fork", - "community:acme/github-bridge", - "community:acme/weather" + "official:com.mcparmory/github", + "official:io.github.Abhishekkumar2021/github", + "official:io.github.Dave-London/github", + "official:io.github.davidweb3-ctrl/github", + "official:io.github.pipeworx-io/github", + "official:io.github.rog0x/github", + "official:io.github.perceptdot/github", + "official:io.github.UnbearableDev/github-actions-audit", + "official:io.github.tylerscomic-lab/github-actions-audit-mcp", + "official:io.github.Kirchlive/github-inside-claude-code", + "official:io.github.sarim-aliii/github-issue-mcp", + "official:io.github.paarths-collab/github-pr-context-mcp", + "official:io.github.varvararatta/github_public_repos_mcp", + "official:io.github.sadri-dridi/github-repo-shape", + "official:io.github.heisencodex-jpg/github-repo-intel-mcp", + "official:io.github.nirholas/github-to-mcp", + "official:io.github.ofershap/github-actions", + "official:io.github.TheNextGenNexus/github-analytics-mcp-server", + "official:io.github.ofershap/github-gist" ], "results": [ { @@ -62,73 +4253,424 @@ "publisher": "github", "verified": true, "official": true, - "description": "GitHub's official MCP server", + "description": "Connect AI assistants to GitHub - manage repos, issues, PRs, and workflows through natural language.", + "transport": "stdio", + "install": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-p", + "127.0.0.1:8085:8085", + "-e", + "GITHUB_OAUTH_CALLBACK_PORT=8085", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN={token}", + "ghcr.io/github/github-mcp-server:1.13.0" + ] + }, + "required_inputs": [ + { + "name": "Authorization", + "description": "Authorization header with authentication token (PAT or App token)", + "secret_like": true + } + ], + "source_code_url": "https://github.com/github/github-mcp-server", + "added": false + }, + { + "source": "official", + "id": "com.mcparmory/github", + "title": "github", + "publisher": "mcparmory", + "verified": true, + "official": true, + "description": "Manage repositories, users, releases, and automate GitHub workflows", + "transport": "stdio", + "install": { + "command": "uvx", + "args": [ + "mcparmory-github" + ] + }, + "source_code_url": "https://github.com/mcparmory/registry", + "added": false + }, + { + "source": "official", + "id": "io.github.Abhishekkumar2021/github", + "title": "GitHub", + "publisher": "Abhishekkumar2021", + "verified": true, + "official": true, + "description": "GitHub via MCP: search, repos, issues, PRs, files, notifications. OAuth device flow or token.", + "transport": "stdio", + "install": { + "command": "npx", + "args": [ + "@abhishekmcp/github@0.2.0" + ] + }, + "required_inputs": [ + { + "name": "GITHUB_CLIENT_ID", + "description": "OAuth App client id (public) to enable the device-flow login.", + "secret_like": false + }, + { + "name": "GITHUB_READONLY", + "description": "Set to 1 or true to disable issue-creating/commenting tools.", + "secret_like": false + }, + { + "name": "GITHUB_TOKEN", + "description": "A GitHub Personal Access Token (skips the OAuth device flow).", + "secret_like": true + } + ], + "source_code_url": "https://github.com/Abhishekkumar2021/mcp-suite", + "added": false + }, + { + "source": "official", + "id": "io.github.Dave-London/github", + "title": "github", + "publisher": "Dave-London", + "verified": true, + "official": true, + "description": "MCP server for GitHub operations (PRs, issues, actions) with structured, token-efficient output", + "transport": "stdio", + "install": { + "command": "npx", + "args": [ + "@paretools/github@0.7.1" + ] + }, + "source_code_url": "https://github.com/Dave-London/Pare", + "added": false + }, + { + "source": "official", + "id": "io.github.davidweb3-ctrl/github", + "title": "github", + "publisher": "davidweb3-ctrl", + "verified": true, + "official": true, + "description": "GitHub MCP Server - List PRs, issues, repo info, and search code", + "transport": "stdio", + "install": { + "command": "npx", + "args": [ + "@davidweb3-ctrl/mcp-github-server@1.0.0" + ] + }, + "required_inputs": [ + { + "name": "GITHUB_TOKEN", + "description": "GitHub Personal Access Token", + "secret_like": true + } + ], + "source_code_url": "https://github.com/davidweb3-ctrl/mcp-github-server", + "added": false + }, + { + "source": "official", + "id": "io.github.pipeworx-io/github", + "title": "Github", + "publisher": "pipeworx-io", + "verified": true, + "official": true, + "description": "GitHub MCP — wraps the GitHub public REST API (no auth required for public endpoints)", "transport": "http", "install": { - "url": "https://api.githubcopilot.com/mcp/" + "url": "https://gateway.pipeworx.io/github/mcp" }, + "source_code_url": "https://github.com/pipeworx-io/mcp-github", "added": false }, { "source": "official", - "id": "io.github.example/github-notes", - "title": "GitHub Notes", - "publisher": "example", + "id": "io.github.rog0x/github", + "title": "github", + "publisher": "rog0x", "verified": true, "official": true, - "description": "Notes for GitHub issues", + "description": "GitHub analytics - repos, PRs, issues, releases, contributors", "transport": "stdio", "install": { "command": "npx", "args": [ - "github-notes-mcp" + "@rog0x/mcp-github-tools@1.0.1" ] }, + "source_code_url": "https://github.com/rog0x/mcp-github-tools", "added": false }, { - "source": "community", - "id": "acme/github-fork", - "title": "GitHub (community fork)", - "publisher": "Community", + "source": "official", + "id": "io.github.perceptdot/github", + "title": "Perceptdot GitHub", + "publisher": "perceptdot", "verified": false, - "official": false, - "description": "A fork of the GitHub server", + "official": true, + "description": "AI agent vision for GitHub repository monitoring — with ROI measurement.", + "transport": "stdio", + "install": { + "command": "npx", + "args": [ + "@perceptdot/github@0.1.5" + ] + }, + "added": false + }, + { + "source": "official", + "id": "io.github.UnbearableDev/github-actions-audit", + "title": "GitHub Actions Audit", + "publisher": "UnbearableDev", + "verified": true, + "official": true, + "description": "GitHub Actions workflow security audit - 21 checks: pinning, permissions, secrets, injection.", "transport": "http", "install": { - "url": "https://fork.example.com/mcp" + "url": "https://unbearable-dev--github-actions-audit.apify.actor/mcp" }, + "required_inputs": [ + { + "name": "Authorization", + "description": "Apify API token. Format: 'Bearer '. Get yours at https://console.apify.com/account/integrations", + "secret_like": true + } + ], + "source_code_url": "https://github.com/UnbearableDev/github-actions-audit", "added": false }, { - "source": "community", - "id": "acme/github-bridge", - "title": "Issue Bridge", - "publisher": "Community", - "verified": false, - "official": false, - "description": "Syncs github issues", + "source": "official", + "id": "io.github.tylerscomic-lab/github-actions-audit-mcp", + "title": "GitHub Actions Security Audit", + "publisher": "tylerscomic-lab", + "verified": true, + "official": true, + "description": "Audit GitHub Actions workflows for script injection, unpinned actions and missing permissions.", + "transport": "http", + "install": { + "url": "https://github-actions-audit-mcp.mcpize.run/mcp" + }, + "source_code_url": "https://github.com/tylerscomic-lab/github-actions-audit-mcp", + "added": false + }, + { + "source": "official", + "id": "io.github.Kirchlive/github-inside-claude-code", + "title": "GitHub Inside Claude Code", + "publisher": "Kirchlive", + "verified": true, + "official": true, + "description": "Normalized GitHub-CLI bridge for Claude Code — 89 ops, 6 families incl. federation sources", + "transport": "stdio", + "install": { + "command": "uvx", + "args": [ + "gh-tool-use-mcp", + "github-inside-claude-code" + ] + }, + "required_inputs": [ + { + "name": "BRAVE_API_KEY", + "description": "API key for the Brave Search federation source (gh_research_brave_search). Required only if that research op is invoked.", + "secret_like": true + }, + { + "name": "TAVILY_API_KEY", + "description": "API key for the Tavily federation source (gh_research_tavily_search / gh_research_tavily_research). Required only if those research ops are invoked.", + "secret_like": true + } + ], + "source_code_url": "https://github.com/Kirchlive/github-inside-claude-code", + "added": false + }, + { + "source": "official", + "id": "io.github.sarim-aliii/github-issue-mcp", + "title": "GitHub Issue MCP", + "publisher": "sarim-aliii", + "verified": true, + "official": true, + "description": "AI-powered MCP server for generating GitHub issues and detecting semantic duplicates.", "transport": "stdio", "install": { "command": "npx", "args": [ - "issue-bridge" + "github-issue-mcp@1.1.2" ] }, + "required_inputs": [ + { + "name": "GEMINI_API_KEY", + "description": "Google Gemini API key used for AI-powered issue generation and semantic duplicate analysis.", + "secret_like": true + }, + { + "name": "GITHUB_OWNER", + "description": "GitHub repository owner or organization.", + "secret_like": false + }, + { + "name": "GITHUB_REPO", + "description": "GitHub repository name.", + "secret_like": false + }, + { + "name": "GITHUB_TOKEN", + "description": "GitHub personal access token used to access the configured repository.", + "secret_like": true + } + ], + "source_code_url": "https://github.com/sarim-aliii/github-issue-mcp", "added": false }, { - "source": "community", - "id": "acme/weather", - "title": "Weather", - "publisher": "Community", - "verified": false, - "official": false, - "description": "Forecasts; mentions github once", + "source": "official", + "id": "io.github.paarths-collab/github-pr-context-mcp", + "title": "GitHub PR Context", + "publisher": "paarths-collab", + "verified": true, + "official": true, + "description": "Retrieves GitHub PR history as context material; the connected IDE agent does the reasoning.", + "transport": "stdio", + "install": {}, + "source_code_url": "https://github.com/paarths-collab/github-pr-context-mcp", + "added": false + }, + { + "source": "official", + "id": "io.github.varvararatta/github_public_repos_mcp", + "title": "Github Public Repos Mcp", + "publisher": "varvararatta", + "verified": true, + "official": true, + "description": "Dive into the world of open-source with the GitHub Repo Explorer! Utilize the powerful GitHub", + "transport": "http", + "install": { + "url": "https://github-public-repos-mcp.mcpize.run/mcp" + }, + "source_code_url": "https://github.com/varvararatta/botfactory-mcp", + "added": false + }, + { + "source": "official", + "id": "io.github.sadri-dridi/github-repo-shape", + "title": "GitHub Repo Shape", + "publisher": "sadri-dridi", + "verified": true, + "official": true, + "description": "Parse owner and repo from a GitHub URL. Path discarded.", "transport": "http", "install": { - "url": "https://weather.example.com/mcp" + "url": "https://agent-observatory-sensor.nolimit-observatory.workers.dev/s/github-repo-shape/mcp" + }, + "source_code_url": "https://github.com/sadri-dridi/named-mcp-utilities", + "added": false + }, + { + "source": "official", + "id": "io.github.heisencodex-jpg/github-repo-intel-mcp", + "title": "GitHub Repository Intelligence", + "publisher": "heisencodex-jpg", + "verified": true, + "official": true, + "description": "Repo intel for AI coding agents: overview, PRs, contributors, hot files, CI, deps. Remote MCP.", + "transport": "http", + "install": { + "url": "https://cg-nguyen--github-repo-intel-mcp.apify.actor/mcp" + }, + "required_inputs": [ + { + "name": "Authorization", + "description": "Apify API token, formatted as: Bearer apify_api_xxx. Get a free token at https://console.apify.com/account/integrations.", + "secret_like": true + } + ], + "source_code_url": "https://github.com/heisencodex-jpg/github-repo-intel-mcp", + "added": false + }, + { + "source": "official", + "id": "io.github.nirholas/github-to-mcp", + "title": "GitHub to MCP Converter", + "publisher": "nirholas", + "verified": true, + "official": true, + "description": "Convert any GitHub repo to MCP server - automatic tool generation API wrap", + "transport": "stdio", + "install": {}, + "source_code_url": "https://github.com/nirholas/github-to-mcp", + "added": false + }, + { + "source": "official", + "id": "io.github.ofershap/github-actions", + "title": "github-actions", + "publisher": "ofershap", + "verified": true, + "official": true, + "description": "GitHub Actions MCP — view runs, read logs, re-run jobs, and manage CI/CD.", + "transport": "stdio", + "install": { + "command": "npx", + "args": [ + "mcp-server-github-actions@1.0.1" + ] + }, + "source_code_url": "https://github.com/ofershap/mcp-server-github-actions", + "added": false + }, + { + "source": "official", + "id": "io.github.TheNextGenNexus/github-analytics-mcp-server", + "title": "github-analytics-mcp-server", + "publisher": "TheNextGenNexus", + "verified": true, + "official": true, + "description": "Get GitHub repository stats, search repos, analyze languages, and compare projects", + "transport": "stdio", + "install": { + "command": "npx", + "args": [ + "@thenextgennexus/github-analytics-mcp-server@1.0.1" + ] + }, + "required_inputs": [ + { + "name": "APIFY_TOKEN", + "description": "Your Apify API token for accessing web scraping actors", + "secret_like": true + } + ], + "source_code_url": "https://github.com/TheNextGenNexus/ai-analytics-mcp-servers", + "added": false + }, + { + "source": "official", + "id": "io.github.ofershap/github-gist", + "title": "github-gist", + "publisher": "ofershap", + "verified": true, + "official": true, + "description": "MCP server to create, read, update, list, and search GitHub Gists from your IDE", + "transport": "stdio", + "install": { + "command": "npx", + "args": [ + "mcp-server-github-gist@1.0.2" + ] }, + "source_code_url": "https://github.com/ofershap/mcp-server-github-gist", "added": false } ] diff --git a/internal/registries/testhooks.go b/internal/registries/testhooks.go index a20d9ca08..1dd9d63bc 100644 --- a/internal/registries/testhooks.go +++ b/internal/registries/testhooks.go @@ -1,5 +1,14 @@ package registries +import ( + "encoding/json" + "net/http" + "sort" + "strconv" + "strings" + "time" +) + // This file exposes a narrowly-scoped test seam so that packages OTHER than // registries (notably internal/httpapi, for the FR-007 caller-scoping tests // on GET /catalog/search) can drive SearchAll against a small, deterministic, @@ -64,3 +73,89 @@ func ResetListingCacheForTest() { defer listingCache.mu.Unlock() listingCache.m = make(map[string]listingCacheEntry) } + +// SetCatalogWarmBehindForTest overrides the warm-behind background timeout and +// the per-source slot count (Spec 109 D36.11) and returns a restore func. +func SetCatalogWarmBehindForTest(timeout time.Duration, slots int) (restore func()) { + warmBehind.mu.Lock() + prevTimeout, prevSlots := warmBehind.timeout, warmBehind.slots + warmBehind.timeout, warmBehind.slots = timeout, slots + warmBehind.mu.Unlock() + return func() { + warmBehind.mu.Lock() + warmBehind.timeout, warmBehind.slots = prevTimeout, prevSlots + warmBehind.mu.Unlock() + } +} + +// RecordedRegistryHandlerForTest serves an official-protocol v0.1 registry +// (GET /v0.1/servers) from a recorded corpus of wrapped {server, _meta} items +// (Spec 109 D36.12). It reproduces the live registry's search semantics, which +// is what makes the catalog bug reproducible offline: `search` is a +// case-insensitive substring of server.name only, results are in byte order of +// the name, `version=latest` keeps only isLatest entries, `limit` defaults to +// 100, and `cursor` is the last name of the previous page (exclusive). +func RecordedRegistryHandlerForTest(corpus []json.RawMessage) http.Handler { + type row struct { + name string + isLatest bool + raw json.RawMessage + } + rows := make([]row, 0, len(corpus)) + for _, raw := range corpus { + var item struct { + Server struct { + Name string `json:"name"` + } `json:"server"` + Meta map[string]struct { + IsLatest *bool `json:"isLatest"` + } `json:"_meta"` + } + if err := json.Unmarshal(raw, &item); err != nil { + continue + } + latest := true + if m, ok := item.Meta[officialMetaKey]; ok && m.IsLatest != nil { + latest = *m.IsLatest + } + rows = append(rows, row{name: item.Server.Name, isLatest: latest, raw: raw}) + } + sort.SliceStable(rows, func(i, j int) bool { return rows[i].name < rows[j].name }) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + search := strings.ToLower(q.Get("search")) + latestOnly := q.Get("version") == "latest" + cursor := q.Get("cursor") + limit := 100 + if n, err := strconv.Atoi(q.Get("limit")); err == nil && n > 0 { + limit = n + } + + page := []json.RawMessage{} + lastName, more := "", false + for _, rw := range rows { + if latestOnly && !rw.isLatest { + continue + } + if search != "" && !strings.Contains(strings.ToLower(rw.name), search) { + continue + } + if cursor != "" && rw.name <= cursor { + continue + } + if len(page) == limit { + more = true // more remain: this page's last name is the cursor + break + } + page = append(page, rw.raw) + lastName = rw.name + } + meta := map[string]interface{}{"count": len(page)} + if more { + meta["nextCursor"] = lastName + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"servers": page, "metadata": meta}) + }) +} diff --git a/internal/registries/types.go b/internal/registries/types.go index 99d3edc94..12a75e312 100644 --- a/internal/registries/types.go +++ b/internal/registries/types.go @@ -54,6 +54,18 @@ type ServerEntry struct { // which stays unchanged. BuildCatalogHit copies it into CatalogHit and // layers in GitHub stars from the popularity provider's cache. Popularity *Popularity `json:"-"` + + // Title is the source's own display title (server.json "title" for the + // official protocol), and Version the entry's published version. Both are + // `json:"-"` like Popularity: ServerEntry's wire JSON is unchanged. The + // catalog reads Title for display and matching (Spec 109 D36.10) and + // collapseOfficialVersions reads Version. + Title string `json:"-"` + Version string `json:"-"` + + // isLatest records an explicit isLatest:true from the official registry's + // publication metadata, so collapseOfficialVersions can prefer it. + isLatest bool } // RequiredInput declares a single env var / key a server needs before it will diff --git a/internal/server/spec109_catalog_order_test.go b/internal/server/spec109_catalog_order_test.go index c1638c6ed..fb3ea851f 100644 --- a/internal/server/spec109_catalog_order_test.go +++ b/internal/server/spec109_catalog_order_test.go @@ -25,6 +25,8 @@ type p109CatalogOrder struct { ID string `json:"id"` Name string `json:"name"` Provenance string `json:"provenance"` + Protocol string `json:"protocol"` + Corpus []json.RawMessage `json:"corpus"` Servers []json.RawMessage `json:"servers"` } `json:"sources"` IDs []string `json:"ids"` @@ -42,21 +44,27 @@ func TestSearchServers_CatalogOrderMatchesTheRESTGolden(t *testing.T) { var entries []registries.RegistryEntry for _, src := range f.Sources { - body, err := json.Marshal(src.Servers) - require.NoError(t, err) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(body) - })) + var h http.Handler + if src.Protocol == "modelcontextprotocol/registry" { + h = registries.RecordedRegistryHandlerForTest(src.Corpus) + } else { + body, err := json.Marshal(src.Servers) + require.NoError(t, err) + h = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }) + } + srv := httptest.NewServer(h) t.Cleanup(srv.Close) - entries = append(entries, registries.RegistryEntry{ID: src.ID, Name: src.Name, ServersURL: srv.URL, Provenance: src.Provenance}) + entries = append(entries, registries.RegistryEntry{ID: src.ID, Name: src.Name, ServersURL: srv.URL + "/v0.1/servers", Protocol: src.Protocol, Provenance: src.Provenance}) } t.Cleanup(registries.AllowPrivateRegistryFetchForTest()) t.Cleanup(registries.SetRegistriesForTest(entries)) proxy := createTestMCPProxyServer(t) result, err := proxy.handleSearchServers(context.Background(), mcp.CallToolRequest{Params: mcp.CallToolParams{ - Name: "search_servers", Arguments: map[string]interface{}{"search": f.Query}, + Name: "search_servers", Arguments: map[string]interface{}{"search": f.Query, "limit": 20}, }}) require.NoError(t, err) require.False(t, result.IsError, "%+v", result.Content) @@ -65,6 +73,7 @@ func TestSearchServers_CatalogOrderMatchesTheRESTGolden(t *testing.T) { Servers []struct { ID string `json:"id"` Source string `json:"source"` + Title string `json:"title"` } `json:"servers"` } require.NoError(t, json.Unmarshal([]byte(toolResultText(t, result)), &payload)) @@ -75,4 +84,5 @@ func TestSearchServers_CatalogOrderMatchesTheRESTGolden(t *testing.T) { assert.Equal(t, f.IDs, got, "MCP search_servers order must equal the REST golden") require.NotEmpty(t, got) assert.Equal(t, "official:io.github.github/github-mcp-server", got[0], "SC-008: the official GitHub server is first") + assert.Equal(t, "GitHub", payload.Servers[0].Title, "the server.json title, not the reverse-DNS name") } From efc8aee99d746fe06cfbb82c7332eccda4717825 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 2 Oct 2026 16:54:20 +0300 Subject: [PATCH 2/5] feat(catalog): cards show Verified, the publisher and popularity instead of an Official badge (Spec fix-catalog-rank) --- frontend/src/components/CatalogSearch.vue | 31 ++++- .../tests/unit/add-server-catalog.spec.ts | 4 + .../tests/unit/catalog-card-signals.spec.ts | 119 ++++++++++++++++++ .../tests/unit/catalog-order-parity.spec.ts | 6 +- .../MCPProxy/MCPProxy/Views/CatalogView.swift | 14 ++- .../CatalogOrderParityTests.swift | 3 + .../MCPProxy/MCPProxyTests/CatalogTests.swift | 15 +++ 7 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 frontend/tests/unit/catalog-card-signals.spec.ts diff --git a/frontend/src/components/CatalogSearch.vue b/frontend/src/components/CatalogSearch.vue index a906394ae..7f5e4b1b2 100644 --- a/frontend/src/components/CatalogSearch.vue +++ b/frontend/src/components/CatalogSearch.vue @@ -339,6 +339,24 @@ async function openPreviouslyAdded(result: CatalogResult): Promise { : 'More than one installed server matches this catalog entry. Open the intended server from Servers.' } +// formatCount renders a popularity count compactly: 950, 1.2k, 21k, 1.2M. +function formatCount(n: number): string { + if (n < 1000) return String(n) + const compact = (v: number, suffix: string) => `${v >= 10 ? Math.round(v) : Math.round(v * 10) / 10}${suffix}` + if (Math.round(n / 1000) < 1000) return compact(n / 1000, 'k') + return compact(n / 1_000_000, 'M') +} + +// popularityLabel is the card's popularity signal (FR-061): GitHub stars when +// known ("★ 21k"), else source-native installs ("1.2M installs"), else nothing. +function popularityLabel(r: CatalogResult): string { + const p = r.popularity + if (!p) return '' + if (p.stars && p.stars > 0) return `★ ${formatCount(p.stars)}` + if (p.installs && p.installs > 0) return `${formatCount(p.installs)} installs` + return '' +} + // CatalogResultCard is a small local functional-ish component (kept in this // file rather than a separate SFC: it is presentational-only and has no // reason to be reused outside CatalogSearch). @@ -368,10 +386,19 @@ const CatalogResultCard = defineComponent({ // interpolation already escapes this. h('h3', { class: 'font-semibold truncate', 'data-test': 'catalog-result-title' }, r.title), h('p', { class: 'text-xs text-base-content/60 font-mono truncate' }, r.id), + r.publisher || popularityLabel(r) + ? h('p', { class: 'text-xs text-base-content/60 mt-0.5 flex gap-2' }, [ + r.publisher ? h('span', { class: 'truncate', 'data-test': 'catalog-result-publisher' }, `by ${r.publisher}`) : null, + popularityLabel(r) ? h('span', { class: 'shrink-0', 'data-test': 'catalog-result-popularity' }, popularityLabel(r)) : null, + ]) + : null, ]), h('div', { class: 'flex gap-1 shrink-0' }, [ - r.official ? h('span', { class: 'badge badge-sm badge-primary' }, 'Official') : null, - r.verified && !r.official ? h('span', { class: 'badge badge-sm badge-success' }, 'Verified') : null, + // Spec 109 D36.6: no per-card "Official" badge. Every default + // source is official, so it carried no signal; the Official + // section heading says it once. Verified means the publisher + // owns the source repository (D36.5). + r.verified ? h('span', { class: 'badge badge-sm badge-success' }, 'Verified') : null, r.from_cache ? h('span', { class: 'badge badge-sm badge-warning badge-outline', title: 'The source\u2019s live search is unavailable; this entry is from its cached list.', 'data-test': `catalog-from-cache-${r.source}-${r.id}` }, 'From cached list') : null, diff --git a/frontend/tests/unit/add-server-catalog.spec.ts b/frontend/tests/unit/add-server-catalog.spec.ts index 00071bca1..165076432 100644 --- a/frontend/tests/unit/add-server-catalog.spec.ts +++ b/frontend/tests/unit/add-server-catalog.spec.ts @@ -79,6 +79,10 @@ describe('CatalogSearch', () => { vi.mocked(api.addServerFromRegistry).mockResolvedValue({ success: true, server: { name: 'github' } as never }) const wrapper = await mountCatalog() + expect(wrapper.find('[data-test="catalog-result-title"]').text()).toBe('GitHub') + expect(wrapper.text()).toContain('io.github.github/github-mcp-server') + expect(wrapper.find('[data-test="catalog-result-publisher"]').text()).toBe('by github') + const button = wrapper.find(githubAddSelector) expect(button.text()).toBe('Add to MCPProxy') await button.trigger('click') diff --git a/frontend/tests/unit/catalog-card-signals.spec.ts b/frontend/tests/unit/catalog-card-signals.spec.ts new file mode 100644 index 000000000..af75f324c --- /dev/null +++ b/frontend/tests/unit/catalog-card-signals.spec.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createRouter, createWebHistory } from 'vue-router' +import CatalogSearch from '@/components/CatalogSearch.vue' + +// Spec 109 fix-catalog-rank T173 (D36.6, FR-061): a catalog card shows Verified +// (not a per-card "Official" badge: the Official section carries that meaning), +// the publisher, and a popularity signal. + +vi.mock('@/services/api', () => ({ + default: { + catalogSearch: vi.fn(), + getConfigSecrets: vi.fn(), + addServerFromRegistry: vi.fn(), + getSecretRefs: vi.fn(), + setSecret: vi.fn(), + deleteSecret: vi.fn(), + getServers: vi.fn(), + }, +})) +import api from '@/services/api' + +const router = createRouter({ + history: createWebHistory(), + routes: [{ path: '/', component: { template: '
' } }, { path: '/servers/:serverName', component: { template: '
' } }], +}) + +function result(overrides: Record = {}) { + return { + source: 'official', + id: 'io.github.github/github-mcp-server', + title: 'GitHub', + publisher: 'github', + verified: true, + official: true, + description: 'GitHub from your MCP client', + transport: 'http', + install: { url: 'https://api.githubcopilot.com/mcp/' }, + added: false, + ...overrides, + } +} + +async function search(results: Record[]) { + vi.mocked(api.getConfigSecrets).mockResolvedValue({ + success: true, + data: { secrets: [], environment_vars: [], total_secrets: 0, total_env_vars: 0, keyring_available: true }, + }) + vi.mocked(api.catalogSearch).mockImplementation(async (params: { q?: string }) => ({ + success: true, + data: params.q === 'github' + ? { query: 'github', results, sections: null, unavailable: [] } + : { query: params.q ?? '', results: [], sections: { official: [], popular: [] }, unavailable: [] }, + }) as never) + const wrapper = mount(CatalogSearch, { global: { plugins: [router] } }) + await flushPromises() + await wrapper.find('[data-test="catalog-search-input"]').setValue('github') + await flushPromises() + await new Promise(r => setTimeout(r, 400)) // the input is debounced + await flushPromises() + return wrapper +} + +describe('catalog card signals (FR-061)', () => { + beforeEach(() => { + vi.mocked(api.catalogSearch).mockReset() + vi.mocked(api.getConfigSecrets).mockReset() + }) + + it('shows no Official badge on a card, even when official is true', async () => { + const wrapper = await search([result()]) + expect(wrapper.text()).not.toContain('Official') + }) + + it('shows Verified when the publisher is verified, and not otherwise', async () => { + const verified = await search([result()]) + expect(verified.text()).toContain('Verified') + const unverified = await search([result({ verified: false })]) + expect(unverified.text()).not.toContain('Verified') + }) + + it('shows the publisher line "by github"', async () => { + const wrapper = await search([result()]) + expect(wrapper.find('[data-test="catalog-result-publisher"]').text()).toBe('by github') + }) + + it('shows no publisher line when the hit has none', async () => { + const wrapper = await search([result({ publisher: undefined })]) + expect(wrapper.find('[data-test="catalog-result-publisher"]').exists()).toBe(false) + }) + + it('renders the publisher as text, never as markup (D19)', async () => { + const wrapper = await search([result({ publisher: '' })]) + const line = wrapper.find('[data-test="catalog-result-publisher"]') + expect(line.text()).toBe('by ') + expect(line.find('img').exists()).toBe(false) + }) + + it('shows stars as "★ 21k" and installs as "1.2M installs"', async () => { + const stars = await search([result({ popularity: { stars: 21345 } })]) + expect(stars.find('[data-test="catalog-result-popularity"]').text()).toBe('★ 21k') + const installs = await search([result({ popularity: { installs: 1234567 } })]) + expect(installs.find('[data-test="catalog-result-popularity"]').text()).toBe('1.2M installs') + }) + + it('prefers stars when both signals are present, and formats small counts as-is', async () => { + const both = await search([result({ popularity: { stars: 950, installs: 5 } })]) + expect(both.find('[data-test="catalog-result-popularity"]').text()).toBe('★ 950') + const thousands = await search([result({ popularity: { stars: 1234 } })]) + expect(thousands.find('[data-test="catalog-result-popularity"]').text()).toBe('★ 1.2k') + }) + + it('shows no popularity element when there is no signal', async () => { + const none = await search([result()]) + expect(none.find('[data-test="catalog-result-popularity"]').exists()).toBe(false) + const empty = await search([result({ popularity: {} })]) + expect(empty.find('[data-test="catalog-result-popularity"]').exists()).toBe(false) + }) +}) diff --git a/frontend/tests/unit/catalog-order-parity.spec.ts b/frontend/tests/unit/catalog-order-parity.spec.ts index 292bc7874..051e7bc2c 100644 --- a/frontend/tests/unit/catalog-order-parity.spec.ts +++ b/frontend/tests/unit/catalog-order-parity.spec.ts @@ -62,6 +62,10 @@ describe('catalog order parity on the Web UI (SC-008)', () => { const titles = wrapper.findAll('[data-test="catalog-result-title"]').map(t => t.text()) expect(titles).toEqual(golden.results.map(r => r.title)) expect(titles[0]).toBe('GitHub') - expect(wrapper.findAll('[data-test^="catalog-result-"]').filter(n => n.attributes('data-test') !== 'catalog-result-title').length).toBe(golden.ids.length) + // One card per result: the per-card detail ids (title, publisher, + // popularity) are not cards. + const detailIds = new Set(['catalog-result-title', 'catalog-result-publisher', 'catalog-result-popularity']) + expect(wrapper.findAll('[data-test^="catalog-result-"]').filter(n => !detailIds.has(n.attributes('data-test') ?? '')).length).toBe(golden.ids.length) + expect(wrapper.find('[data-test="catalog-result-publisher"]').text()).toBe('by github') }) }) diff --git a/native/macos/MCPProxy/MCPProxy/Views/CatalogView.swift b/native/macos/MCPProxy/MCPProxy/Views/CatalogView.swift index 6c221ec41..c980bf1d7 100644 --- a/native/macos/MCPProxy/MCPProxy/Views/CatalogView.swift +++ b/native/macos/MCPProxy/MCPProxy/Views/CatalogView.swift @@ -145,6 +145,14 @@ struct CatalogView: View { return order } + /// The one trust badge a catalog card shows (Spec 109 D36.6). A per-card + /// "Official" badge is gone: every default source is official, so it carried + /// no signal, and the Official section heading says it. Verified means the + /// publisher owns the source repository (D36.5). Mirrors CatalogSearch.vue. + static func trustBadge(_ r: CatalogResult) -> String? { + r.verified ? "Verified" : nil + } + @ViewBuilder private func sectionBlock(title: String, items: [CatalogResult], testID: String) -> some View { if !items.isEmpty { @@ -179,10 +187,8 @@ struct CatalogView: View { } Spacer() HStack(spacing: 4) { - if r.official { - badge("Official", tint: .accentColor) - } else if r.verified { - badge("Verified", tint: .green) + if let trust = Self.trustBadge(r) { + badge(trust, tint: .green) } if r.fromCache { badge("From cached list", tint: .orange) diff --git a/native/macos/MCPProxy/MCPProxyTests/CatalogOrderParityTests.swift b/native/macos/MCPProxy/MCPProxyTests/CatalogOrderParityTests.swift index b6f773ad7..9e17286c2 100644 --- a/native/macos/MCPProxy/MCPProxyTests/CatalogOrderParityTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/CatalogOrderParityTests.swift @@ -34,6 +34,9 @@ final class CatalogOrderParityTests: XCTestCase { XCTAssertEqual(first.catalogID, "io.github.github/github-mcp-server") XCTAssertTrue(first.official) XCTAssertTrue(first.verified) + XCTAssertEqual(first.title, "GitHub") + XCTAssertEqual(first.publisher, "github") + XCTAssertEqual(CatalogView.trustBadge(first), "Verified") } func testEveryRowHasADistinctListIdentity() throws { diff --git a/native/macos/MCPProxy/MCPProxyTests/CatalogTests.swift b/native/macos/MCPProxy/MCPProxyTests/CatalogTests.swift index 29e3aa349..41e90c463 100644 --- a/native/macos/MCPProxy/MCPProxyTests/CatalogTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/CatalogTests.swift @@ -22,6 +22,21 @@ final class CatalogTests: XCTestCase { """) } + // MARK: - Card trust badge (Spec 109 fix-catalog-rank, D36.6) + + /// A card shows Verified, never a per-card Official badge: every default + /// source is official, so the badge carried no signal. Mirrors the Web card. + func testCardShowsVerifiedNotOfficialBadge() throws { + let verifiedOfficial = try sampleResult("filesystem") + XCTAssertEqual(CatalogView.trustBadge(verifiedOfficial), "Verified") + + let officialOnly = try decode(CatalogResult.self, from: """ + {"source": "official", "id": "x", "title": "X", "verified": false, "official": true, + "description": "d", "transport": "stdio", "install": {"command": "npx"}, "added": false} + """) + XCTAssertNil(CatalogView.trustBadge(officialOnly), "official alone shows no badge") + } + /// Popular is rendered first when it has entries, then Official; an empty /// section is never listed (same order as the Web UI and the CLI table). func testPopularSectionRendersFirst() throws { From ebbb9245d4bdcf8c58a731ae70306047c018f889 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 2 Oct 2026 16:54:20 +0300 Subject: [PATCH 3/5] docs(catalog): spec 109 fix-catalog-rank decisions D36, tasks T166-T175 and rank wording (Spec fix-catalog-rank) --- ROADMAP.md | 2 +- docs/api/rest-api.md | 2 +- docs/cli/catalog-commands.md | 2 +- docs/features/catalog-popularity.md | 2 ++ .../acceptance-index.json | 6 ++-- .../contracts/mcp-tools.md | 2 +- .../contracts/rest-api.md | 4 ++- .../data-model.md | 7 +++- .../parity-matrix.json | 8 +++-- specs/109-ux-navigation-consistency/plan.md | 1 + .../quickstart.md | 1 + .../109-ux-navigation-consistency/research.md | 33 ++++++++++++++++++- specs/109-ux-navigation-consistency/spec.md | 13 ++++---- specs/109-ux-navigation-consistency/tasks.md | 19 ++++++++++- specs/110-catalog-popularity/spec.md | 1 + 15 files changed, 85 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 78ac342af..cb1175694 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1036,6 +1036,6 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—` | [106-security-residual-fixes](./specs/106-security-residual-fixes/) | `shipped` | 18/19 (95%) | | [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | `shipped` | 126/126 (100%) | | [108-profiles-v3](./specs/108-profiles-v3/) | `shipped` | 185/186 (99%) | -| [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | `shipped` | 200/201 (100%) | +| [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | `shipped` | 210/211 (100%) | | [110-catalog-popularity](./specs/110-catalog-popularity/) | `in-flight` | 19/23 (83%) | | [112-client-header-forwarding](./specs/112-client-header-forwarding/) | `shipped` | 38/40 (95%) | diff --git a/docs/api/rest-api.md b/docs/api/rest-api.md index 2c009c5d7..e5faada12 100644 --- a/docs/api/rest-api.md +++ b/docs/api/rest-api.md @@ -1001,7 +1001,7 @@ Search every enabled catalog source (registry) at once. Both editions; open to a } ``` -Results are ranked: official source first, then verified publishers, then popularity (a missing value counts as zero), then text relevance, then title and id. The order is identical on the Web UI, macOS, the CLI (`mcpproxy catalog search`) and the MCP `search_servers` tool. A source that fails or times out is listed in `unavailable` and the other sources' results are still returned. When the daemon has a recent listing of that source (at most 24 hours old, kept in memory and filled by every successful fetch), the matches come from it instead: those results carry `from_cache: true` and the `unavailable` entry gains `fallback: "cached_listing"` and `cached_at`, so the source still reads as unavailable. An empty `q` lists `popular` before `official` in every surface, and `official` starts with the curated reference servers. `added` is true when a configured server visible to the caller has the same source and install target, and then `added_server_name` names it. Adding an entry stays `POST /api/v1/registries/{id}/servers/{serverId}/add`, which always quarantines the new server; see [Registry Add](../features/registry-add.md). +Results are ranked by how well the name matches the query first (the publisher equals the query, then an exact name, a name prefix, a name word, a substring or description, and last a match through the namespace alone, which is how `io.github.*` entries match), then official source, verified publisher, popularity (a missing value counts as zero), title and id. `verified` means the publisher owns the source repository, `official` means the entry comes from a built-in source, and `title` is the server's own title when it has one. The order is identical on the Web UI, macOS, the CLI (`mcpproxy catalog search`) and the MCP `search_servers` tool. A source that fails or times out is listed in `unavailable` and the other sources' results are still returned. When the daemon has a recent listing of that source (at most 24 hours old, kept in memory and filled by every successful fetch), the matches come from it instead: those results carry `from_cache: true` and the `unavailable` entry gains `fallback: "cached_listing"` and `cached_at`, so the source still reads as unavailable. An empty `q` lists `popular` before `official` in every surface, and `official` starts with the curated reference servers. `added` is true when a configured server visible to the caller has the same source and install target, and then `added_server_name` names it. Adding an entry stays `POST /api/v1/registries/{id}/servers/{serverId}/add`, which always quarantines the new server; see [Registry Add](../features/registry-add.md). ### Registries diff --git a/docs/cli/catalog-commands.md b/docs/cli/catalog-commands.md index 36b9ec96d..0b6907c5e 100644 --- a/docs/cli/catalog-commands.md +++ b/docs/cli/catalog-commands.md @@ -34,7 +34,7 @@ mcpproxy catalog search # browse the Official and Popular sec | `--limit`, `-l ` | Maximum results (default 20, maximum 50) | | `--tag`, `-t` | Not supported: catalog entries carry no tags, so a non-empty value is rejected | -Results from every source are merged and ranked: the official source first, then verified publishers, then popularity (stars or installs when the source provides them; a missing value counts as zero), then text relevance, then title. A source that times out is reported as unavailable and the other sources' results still print. The order is identical on REST, the Web UI, macOS and the MCP `search_servers` tool. +Results from every source are merged and ranked: by how well the name matches the query first (the publisher equals the query, an exact name, a name prefix, a name word, then a substring or description, with a match through the namespace alone last), then official source, verified publisher (the publisher owns the source repository), popularity (stars or installs when the source provides them; a missing value counts as zero) and title. A source that times out is reported as unavailable and the other sources' results still print. The order is identical on REST, the Web UI, macOS and the MCP `search_servers` tool. ``` SOURCE ID TITLE TRANSPORT ADDED diff --git a/docs/features/catalog-popularity.md b/docs/features/catalog-popularity.md index 640cb328c..142731102 100644 --- a/docs/features/catalog-popularity.md +++ b/docs/features/catalog-popularity.md @@ -18,6 +18,8 @@ combine counts from different services: `docker-mcp-catalog` listing. Docker's `star_count` is ignored; it is not comparable to GitHub stars. +GitHub stars count only for the publisher's own repository: an official-registry entry that names someone else's repository keeps no stars. For a typed query MCPProxy asks GitHub about at most as many repositories as the search returns (`limit`), the best-ranked first, so one search cannot spend the hourly budget. Popularity breaks ties inside a match tier; how well the name matches the query ranks first. + On a cold cache, a catalog search waits for at most 800 ms by default. Missing GitHub values are fetched in the background and can appear in a later search. GitHub is not a catalog source, so GitHub request failures do not add an entry diff --git a/specs/109-ux-navigation-consistency/acceptance-index.json b/specs/109-ux-navigation-consistency/acceptance-index.json index 23dca7dba..80eb06f9d 100644 --- a/specs/109-ux-navigation-consistency/acceptance-index.json +++ b/specs/109-ux-navigation-consistency/acceptance-index.json @@ -391,14 +391,16 @@ ] }, "SC-008": { - "summary": "Catalog search github ranks the official GitHub server first, same order everywhere", + "summary": "Catalog search github on a fixture recorded from the live official registry ranks the official GitHub server first, same order everywhere", "tests": [ "golden:internal/registries/testdata/catalog_github_order.json", "go:internal/httpapi/spec109_catalog_order_test.go#TestCatalogOrderParity_RESTWritesTheGolden", "go:internal/server/spec109_catalog_order_test.go#TestSearchServers_CatalogOrderMatchesTheRESTGolden", "go:cmd/mcpproxy/catalog_order_parity_test.go#TestCatalogOrderParityCLI", "vitest:frontend/tests/unit/catalog-order-parity.spec.ts", - "xctest:native/macos/MCPProxy/MCPProxyTests/CatalogOrderParityTests.swift" + "xctest:native/macos/MCPProxy/MCPProxyTests/CatalogOrderParityTests.swift", + "go:internal/registries/catalog_typed_test.go#TestSearchAll_RecordedRegistry_GitHubFirst", + "go:internal/registries/recorded_registry_test.go#TestCatalogGithubFixture_MainPageLacksGitHubServer" ] }, "SC-009": { diff --git a/specs/109-ux-navigation-consistency/contracts/mcp-tools.md b/specs/109-ux-navigation-consistency/contracts/mcp-tools.md index 66a2957b1..146e8ec24 100644 --- a/specs/109-ux-navigation-consistency/contracts/mcp-tools.md +++ b/specs/109-ux-navigation-consistency/contracts/mcp-tools.md @@ -6,7 +6,7 @@ Small by design. MCP is a client surface with per-tool authorization. Search and |---|---|---|---| | `upstream_servers` `list` | each server's `health` gains `status`, `usable`, `actions` (shared struct) | 109-c | list-output goldens only (the tool schema is unchanged) | | `quarantine_security` `inspect_quarantined`, `inspect_tools` | each tool gains `tier`, `annotations`, `scan_verdict` with the names and values of `GET /servers/{id}/review`; changed tools gain `previous` + `diff`; any server `command`/`url` in the output comes from the same redacted review composer (FR-021), never raw config. **The live inspection is kept**: when the composer reports `definitions_captured: false`, `inspect_quarantined` still performs today's temporary-exemption connect + `ListTools()` (`internal/server/mcp.go` ~4879–5018) and decorates those live tools with `tier` from `contracts.AnnotationTier` over their live annotations and `scan_verdict: "not_scanned"`, plus `definitions_source: "live"` (`"captured"` otherwise) — it never degrades to the composer's `tools: []` | 109-f | output goldens only | -| `search_servers` | `registry` becomes optional (omitted = all sources through `registries.SearchAll`); results gain `title`, `publisher`, `verified`, `official`, `popularity`, `source`, in the FR-060 order, and `from_cache: true` when a source's cached listing answered (its `unavailable[]` entry then carries `fallback` and `cached_at`, D35); descriptions say "catalog" and "catalog source". The retained `tag` parameter accepts only an empty value; a non-empty value returns a visible error because catalog entries carry no tags. | 109-j | **schema golden changes** (`registry` no longer `required`; description text). Declared in the 109-j PR body | +| `search_servers` | `registry` becomes optional (omitted = all sources through `registries.SearchAll`); results gain `title`, `publisher`, `verified`, `official`, `popularity`, `source`, in the FR-060 order (research D36; the tool description text still says "official-first" and is frozen by the schema goldens, so changing it is a follow-up), and `from_cache: true` when a source's cached listing answered (its `unavailable[]` entry then carries `fallback` and `cached_at`, D35); descriptions say "catalog" and "catalog source". The retained `tag` parameter accepts only an empty value; a non-empty value returns a visible error because catalog entries carry no tags. | 109-j | **schema golden changes** (`registry` no longer `required`; description text). Declared in the 109-j PR body | | `list_registries` | description wording "catalog sources" | 109-j | schema golden (description text) | ## Unchanged, deliberately diff --git a/specs/109-ux-navigation-consistency/contracts/rest-api.md b/specs/109-ux-navigation-consistency/contracts/rest-api.md index 5dcc6f42d..1b69349a8 100644 --- a/specs/109-ux-navigation-consistency/contracts/rest-api.md +++ b/specs/109-ux-navigation-consistency/contracts/rest-api.md @@ -254,7 +254,9 @@ No new route: the Web UI and macOS write each secret with the existing `POST /se - Result objects are the `CatalogResult` **DTO** (data-model §9), built from the internal `CatalogHit` by `toCatalogResult`; the Spec 070 `registries.ServerEntry` JSON (`url`, `installCmd`, `registry`, `required_inputs[].secret`) is not renamed and keeps serving `GET /registries/{id}/servers` and MCP `search_servers` unchanged (codex round 3: embedding `ServerEntry` could not produce this example). Golden test T101a. - Empty `q` → `results: []`, `sections: {"official": [...], "popular": [...]}` (≤ 12 each). The keys are not ordered; every surface renders Popular first when it is non-empty, then Official (curated reference servers first, then round-robin across sources, never popularity-ordered; research D35 A9). - **Cached fallback (D35).** A source whose live fetch fails (timeout or any error except a missing API key) is answered from its per-source listing cache when that holds a listing refreshed within 24 h: the hits match the trimmed query as a case-insensitive substring of name, description or id, carry `from_cache: true`, and rank like any other hit. The source stays in `unavailable[]` with `fallback: "cached_listing"` and `cached_at`, so a consumer that only reads `unavailable[]` is unchanged. A source with nothing cached has neither field. The cache is in memory, so a daemon restart empties it. -- Ranking (pure `registries.Rank`): `official` desc, `verified` desc, popularity desc (missing = 0), relevance desc, `title` asc, `id` asc. +- Ranking (pure `registries.Rank`, research D36.1): match tier desc (5 the publisher equals the query, 4 the name segment or title equals it, 3 a name starts with it, 2 a whole word of the name equals it, 1 substring or description, 0 namespace-only or no match), then `official` desc, `verified` desc, popularity desc (missing = 0), `title` asc, `id` asc. An empty `q` is tier 0 for every hit. A typed query is fetched wide (one page per source plus, for the official protocol, an owner and a name-prefix query) and ranked before it is cut to `limit`, so a namespace-only match (`io.github.*`) never displaces a name match. +- `verified` is true when a built-in official-protocol entry's namespace owns its source repository (`io.github.` with repo owner `x`, or a domain label contained in the repo owner); built-in reference and Docker entries stay verified as trusted sources; a custom source is never verified. `official` still means "from a built-in source" and is shown as the Official section, not as a per-card badge; Web and macOS cards show Verified, the publisher and popularity. +- `title` is the source's own title when it has one (server.json `title`), else the name segment after the namespace. `description` is `""` when the source only had a placeholder. GitHub stars count only when the publisher owns the repository the entry names. - `added: true` when a configured server has `source_registry_id == source` **and** the same install target (`install.url`, or the command + args) as this result → surfaces render "Added ✓ · Open". The config does not store the registry's own server `id`, so `id` is not part of the join (data-model.md §9). A manually added server matches on the install target alone. When exactly one matching server is visible to the caller, the optional `added_server_name` identifies it; clients use that authoritative name before any comparison with redacted `GET /servers` URL/argv fields. It is omitted for ambiguous matches. - Adding stays `POST /registries/{id}/servers/{serverId}/add` (Spec 070), always quarantined. diff --git a/specs/109-ux-navigation-consistency/data-model.md b/specs/109-ux-navigation-consistency/data-model.md index 4bfe8b30d..e5b317156 100644 --- a/specs/109-ux-navigation-consistency/data-model.md +++ b/specs/109-ux-navigation-consistency/data-model.md @@ -160,7 +160,12 @@ type CatalogHit struct { Curated bool // hit of the built-in reference source; listed first in the Official section (D35 A9) FromCache bool // served from the source's cached listing because its live fetch failed (D35 A1/A2) Popularity *Popularity // {stars?, installs?} + // unexported starsBorrowed: the publisher does not own the repository the entry names, so GitHub stars are not attributed to it (D36.7) } +// Title: server.json title, then the name segment after the namespace, then the name, then the id (D36.10). +// Verified: for a built-in official-protocol entry, the namespace owns the repository (D36.5); built-in reference/Docker entries stay "trusted source". +// Description: "" when the source only had the "No description available" placeholder (D36.9). +// ServerEntry gains Title and Version, both json:"-" (the Popularity precedent), so its wire JSON is unchanged. // REST response DTO of GET /catalog/search — a distinct type, built by toCatalogResult(hit, added). type CatalogResult struct { @@ -183,7 +188,7 @@ type CatalogResult struct { // unavailable[] entry: {source, reason, fallback?: "cached_listing", cached_at?: RFC 3339} type SearchOptions struct{ SourceTimeout time.Duration } // default 5 s; only tests set another value (T109a) func SearchAll(ctx, q, tag string, limit int, opts SearchOptions) (results []CatalogHit, sections *CatalogSections, unavailable []SourceError) -func Rank(a, b CatalogHit, q string) bool // pure, deterministic +func Rank(a, b CatalogHit, q string) bool // pure, deterministic: match tier desc (matchTier, D36.1), official desc, verified desc, popularity desc, title asc, id asc; empty q is tier 0 for every hit func toCatalogResult(h CatalogHit, added bool) CatalogResult // REST only; golden-tested against the contracts/rest-api.md#catalog example ``` diff --git a/specs/109-ux-navigation-consistency/parity-matrix.json b/specs/109-ux-navigation-consistency/parity-matrix.json index b15377c02..ae7e7441c 100644 --- a/specs/109-ux-navigation-consistency/parity-matrix.json +++ b/specs/109-ux-navigation-consistency/parity-matrix.json @@ -769,7 +769,8 @@ "vitest:frontend/tests/unit/add-server-catalog.spec.ts", "vitest:frontend/tests/unit/catalog-order-parity.spec.ts", "vitest:frontend/tests/unit/catalog-cached-fallback.spec.ts", - "vitest:frontend/tests/unit/catalog-browse-order.spec.ts" + "vitest:frontend/tests/unit/catalog-browse-order.spec.ts", + "vitest:frontend/tests/unit/catalog-card-signals.spec.ts" ] }, "macos": { @@ -813,7 +814,10 @@ ], "tests": [ "go:internal/httpapi/spec109_catalog_order_test.go#TestCatalogOrderParity_RESTWritesTheGolden", - "go:internal/httpapi/spec109_catalog_cached_fallback_test.go#TestCatalogCachedFallback_RESTWritesTheGolden" + "go:internal/httpapi/spec109_catalog_cached_fallback_test.go#TestCatalogCachedFallback_RESTWritesTheGolden", + "go:internal/registries/recorded_registry_test.go", + "go:internal/registries/catalog_rank_tier_test.go", + "go:internal/registries/catalog_typed_test.go" ] } } diff --git a/specs/109-ux-navigation-consistency/plan.md b/specs/109-ux-navigation-consistency/plan.md index 5f1da8f93..2c8fc044b 100644 --- a/specs/109-ux-navigation-consistency/plan.md +++ b/specs/109-ux-navigation-consistency/plan.md @@ -132,6 +132,7 @@ Merge order: `(a ∥ b ∥ c) → (f ∥ j ∥ k) → (d ∥ e) → (g ∥ h) | 109-j | `109-j-catalog-add-server` | `registries.SearchAll` + `Rank` over an internal `CatalogHit`; `GET /catalog/search` returning the distinct `CatalogResult` DTO (`toCatalogResult`; `ServerEntry` JSON untouched); `/add-server` (tabs Catalog · Paste · Import · Manual via `?tab=`; `?source=` stays the catalog-source filter); paste URL/command detection; secret toggle (per-kind keyring names `-env-`/`-header-`, never overwriting an existing entry; `secret_like` = registry flag OR name rule) + keyring availability; `/repositories` redirect; Settings → Catalog sources; macOS Add Server sheet with Catalog/Paste + toggle; CLI `catalog` group, `upstream add --secret-env/--secret-header`, `registry search|add` deprecation; MCP `search_servers` registry optional | FR-007 (catalog), 060–067; N3, C1, S7, X4 | a | all | | 109-k | `109-k-activity-scope-filters` | `useScopeQuery` with **all** parameters (`profile`/`client`/`token` registered hidden until `features.scope_filters` — merged here from Spec 108's former 108-j), the full link map incl. its hidden 108-target rows; Activity views + `/sessions` redirect + folding + column hiding + block reason + Duration; Tools/Usage/Servers scope-aware (Review follows in 109-g); chart-bar and Tools-row deep links (server-card links are 109-e's, Home-strip links 109-d's); token-savings estimate; macOS `ScopeFilter` + Activity segments/filters; CLI `--view`, `--from/--to` (sole owner; Spec 108-e does not register them); backend `unsupported_scope_filter` gate for `profile`/`client`/`token` (and `agent` where no handler honours it) until Spec 108-e fills the supported list (`internal/httpapi/scope_filters.go`; list and gate function created by whichever of 109-k/108-e merges first); `tool` URL value split into REST `server` + bare `tool`; `session` routed by the `ws-` prefix | FR-016 (Activity), 070–075, 080–083, FR-080a; A1, A2, N5; M3, C3 | a | Web, macOS, CLI, REST | | 109-m | `109-m-parity-docs` | Terminology parity test (Go enums → `contracts.ts` → Swift fixtures → CLI `--help-json`); SC-002 attention parity test; SC-003 forbidden-rendering test; SC-005 `unquarantine` grep test; contradiction-register check; SC-001 traceability check (T148a); FR-091 parity-matrix walk (T148b); Playwright `navigation-consistency.spec.ts` into the release-gate sweep; docs; live run of every story on one isolated instance | FR-090–092; SC-001–012 | a–k | all | +| fix-catalog-rank | `fix-catalog-live-ranking` | Catalog search ranks the real server first against the live registry (audit C1): match tier first in `Rank`, owner and name-prefix expansion queries for the official protocol, ranking before truncation, version collapse, `verified` = the publisher owns the repository, stars only for the publisher's own repo, Popular de-dup by title, placeholder description empty, warm-behind for a slow source; the SC-008 fixture is recorded from the live registry; cards show Verified, publisher and popularity | FR-060, 061; SC-008; C1 | demo-ux-fixes | Go core/REST, MCP, CLI, Web, macOS | | 109-l | `109-l-profiles-integration` | 108 warnings as attention kinds (Spec 108's names); end-to-end un-hiding tests for the parameters, link rows, sidebar entry and header slot wired in 109-i/109-k; parity rows (the Viewing chip and Clients-row profile controls are Spec 108's) | FR-093 | 108-f, 108-i, 108-j, 108-k, 109-i | Web, macOS, Go (attention kinds) | | 109-leftovers | `109-leftovers-polish` | Residual gaps in the merged a/c/g/h/k PRs: one macOS status line from the label table (row, tray submenu, detail header), Tools-row Review link + label badge, `tools list --approval` help labels, `activity export` `--format` vs `-o` help, bulk Connect All preview shows each target path, `ConnectModal.vue` shim deleted, review docs per surface + `mcpproxy review` CLI page, presence/initialize coverage tests, tasks.md reconciliation | FR-011, FR-014, FR-022 docs, FR-027, FR-032, C6; S4, S5, T1 | a..k merged | Web, macOS, CLI, docs | diff --git a/specs/109-ux-navigation-consistency/quickstart.md b/specs/109-ux-navigation-consistency/quickstart.md index 6e0b4ec21..4e54e1d6c 100644 --- a/specs/109-ux-navigation-consistency/quickstart.md +++ b/specs/109-ux-navigation-consistency/quickstart.md @@ -127,6 +127,7 @@ Every REST call below carries the admin key unless it names another credential: | 109-m | Run every story's independent test on one instance across all four surfaces: US1 attention order on Web, macOS tray and Home, `mp attention`, `status` and `doctor`; US2 the 14-tool review on Web, macOS, `mp review show --full` and MCP `inspect_quarantined`, then approve with `--except`; US3 Clients rows and Connect in at most two clicks; US4 the status word per fixture state on card, detail, macOS row, tray first line and `mp upstream list`; US5 `github` in Web, macOS, `mp catalog search github -o json` and MCP `search_servers`; US6 header widths 1440/1100/900/390 in both themes, ⌘K, redirects and deep-link network logs; US7 the wizard in a scratch HOME. Then SC-010 (the A1 fixture), the SC-011 benchmark (`go test -run XXX -bench Attention -benchtime 2000x ./internal/runtime/`) and the release-gate sweep (`MCPPROXY_BINARY_PATH=$PWD/mcpproxy MCPPROXY_FIXTURE_PATH= ./scripts/run-web-smoke.sh`) | SC-001 to SC-012; every parity test green; each story's independent test passes on all four surfaces | | 109-l | The "before Spec 108" half cannot run at 109-l's own merge point (its prerequisites include 108-f, which follows 108-e, so `features.scope_filters` is already listed); it is run on the build right after 109-k, before any Spec 108 PR — `/activity?client=cursor` keeps the parameter in the URL but shows no chip and sends no `client` to REST — and at every later build by T111/T116 with a status stub. At 109-l (Spec 108-f/i/j/k merged): bind Cursor, then use the Clients row links, the Viewing chip in the header slot, and hand-edit `anonymous_profile` away so Spec 108's binding guard warns | the hidden parameters and links appear without code changes once `features.scope_filters` is present; `?client=cursor` links work; the attention list shows `anonymous_denied_by_binding_guard` first and `client_holds_admin_key` for a seeded admin-key config (open `GET /clients/cursor`, or the Clients row, once before expecting `client_holds_admin_key`: the item needs an observed credential state, FR-093); the expanded Clients row shows Activity · Sessions · Tools it sees · Usage, the Tokens tab shows Activity · Usage on agent rows, and `/ui/servers?profile=

` lists only that profile's servers with a removable chip; all at 900 and 390 px | | demo-ux-fixes (109 half) | Scratch config adds `allow_private_registry_fetch`, a `slowreg` registry served by a node stub that sleeps 8 s while `$RUN/slow` exists, and no `quarantine_enabled`; prime Web Add server -> Catalog (empty query), `touch $RUN/slow`, search "github" on Web, `mp catalog search github -o json`, MCP `search_servers {search:"github"}` and macOS Catalog; Settings -> Security; Settings header; ⌘K "work", "cursor", "qa"; Catalog with an empty query on a cold and a warm popularity cache | The `slow/github-a` hit appears in the same order on every surface and is marked "From cached list" / `from_cache` / `(cached)`, the notice reads "slowreg: live search unavailable (timeout after 5s); showing matches from its cached list", the answer returns in at most 5.5 s, and a second search after `rm $RUN/slow` has no marker; the Quarantine toggle is ON and agrees with the posture chip, toggling it asks for confirmation and PATCHes `quarantine_enabled:false` only; the header names "Save changes"; the palette shows one `/profiles`, `/clients` and `/tokens` request after the first keystroke and none on open, and Enter lands on the profile editor, `/clients?client=cursor` and `/clients?tab=tokens&token=qa-ro`; cold Official starts with filesystem, memory, everything, then alternates official and docker, warm shows Popular above Official (Web, macOS and `mp catalog search`) | +| fix-catalog-rank | Isolated instance (high port, scratch HOME and data dir, `MCPPROXY_TELEMETRY=false`, default registries) built with `make build`; Web Add server -> Catalog, type `github` (if `official` reports `timeout after 5s`, search again within 30 s); `curl -H "X-API-Key: $KEY" "$M/api/v1/catalog/search?q=github&limit=20"`; `mp catalog search github --limit 20 -o json`; MCP `search_servers {"search":"github","limit":20}`; macOS Catalog; then `notion`, `stripe`, `time` and `github actions`; the debug log for request counts; an empty query; Add on the GitHub result | The first card is "GitHub" (`io.github.github/github-mcp-server`, "by github", Verified, no Official badge), the other exact `…/github` names come before the `github-*` prefixes, no namespace-only entry (`io.github.06ketan/slideshot`) is in the 20, no id repeats and no card says "No description available"; REST, CLI and MCP list the same `source:id` order as the Web page and `servers[0].title` is "GitHub"; `notion` and `stripe` lead with their own `com.*` entries when the registry has them, `time` lists the reference and Docker `time` before `*-time-*`, `github actions` leads with `*/github-actions*`; exactly 3 official requests per typed search (`search=github`, `.github/`, `/github`) and no `cursor=`; the popularity log shows at most 20 star fetches per typed search and none for borrowed-repo hits; with an empty query Popular has no title twice and no `agency.ottobot/*`, Official still starts filesystem, memory, everything; the `slowreg` step of demo-ux-fixes still marks the cached hit and clears 30 s after `rm $RUN/slow`; Add on the GitHub result adds it quarantined and the card flips to "Added ✓ · Open" | | 109-leftovers | `mp tools list --help`, `mp activity export --help`, `mp activity export -o json`, `mp --help`; edit `$TD/notes.json` (description → `changed`, add `search_notes_2` → `pending`) and open Web `/tools`; Web `/clients` → Connect N clients (scratch HOME with `.cursor/mcp.json`, `.codex/config.toml`); `initialize` with `clientInfo.name: "cursor"` while telemetry is off, restart the core; macOS Servers + tray + detail; `website/build/cli/review-commands/index.html` | `--approval` help names the three labels; export help explains `--format` vs `-o`, and `-o json` still fails with `unknown shorthand flag: 'o'`; Tools rows read "Changed, needs review" / "New, needs review" with a Review link to `/review/notes?change=…`, approved rows have none; the bulk preview lists each client with its `~/…` path, entry and backup notice, no `POST /connect/*` before Confirm, and Cancel leaves both files byte-identical; `client_last_seen.cursor` is set and survives the restart, a reconnect clears it until the next `initialize`; a connected server needing sign-in reads "Sign-in required" (never "Connected") in the row, the tray submenu's first line and the detail header; the review CLI page exists and matches `mp review list` output | For **109-h**, also inspect the API payload and network requests: `GET /clients` must carry `active_sessions` counts and no `sessions` key on any row; expanding Claude Code must issue one `GET /clients/claude-code` and show its session rows, with no detail fetch for collapsed rows. The personal-edition sidebar must already link to Clients in its current grouping. Seed an unknown `clientInfo.name` such as `zed`: the observed `other:zed` row appears separately from the always-available manual "Other client" snippet, which does not appear in the API payload. After Cursor has been seen, successfully reconnect it; before its next `initialize`, assert `connected_never_seen`, no current-generation session count, and no Cursor `client_last_seen` alias, while a failed reconnect would leave its prior state intact. A subsequent Cursor `initialize` must change it to `connected_seen`. If 108-c merged first, verify the extracted `ClientConnectList` still shows the profile picker and mode choice, masked credential, management notice, and the administrator-only connect reads; run `connect-profile-fields.spec.ts` and `connect_profile_flags_test.go` to preserve the CLI's profile/lock/switchable/keyless flags. diff --git a/specs/109-ux-navigation-consistency/research.md b/specs/109-ux-navigation-consistency/research.md index 3484e6f90..ba43073c7 100644 --- a/specs/109-ux-navigation-consistency/research.md +++ b/specs/109-ux-navigation-consistency/research.md @@ -85,6 +85,8 @@ Annotations are excluded from the approval hash (`tool_quarantine.go:26`), so an ## D12 — Catalog: fan-out and one ranking function +**Amended by D36 (fix-catalog-rank, 2026-10-02):** the rank order is now match tier first, then official, verified, popularity, title; "verified" is the second clause only (the publisher's namespace owns the source repository). + **Decision**: `internal/registries/catalog.go`: `SearchAll(ctx, q, tag, limit)` runs `SearchServers` per enabled source concurrently (errgroup-free: goroutines + buffered channel, per-source `context.WithTimeout(5s)`). It merges, de-duplicates by (source, id), and sorts with `Rank(a, b)`, which is pure and deterministic: official source > verified publisher (the official registry's namespace verification, or `publisher` equal to the repository owner) > popularity desc > text relevance (title/id/description token match score) > title asc. Empty `q` returns sections `official` (official-source entries) and `popular` (top popularity across sources), each capped at 12. `unavailable[]` lists failed sources with the reason. REST `GET /api/v1/catalog/search`; MCP `search_servers` with `registry` omitted calls the same function; CLI `catalog search` calls REST. **Why**: C1 ("forks first") is a ranking problem. Ranking must live in one place so every surface agrees (SC-008). **Rejected**: ranking in the frontend (macOS and CLI would diverge); requiring a source pick (N3's complaint). @@ -321,7 +323,7 @@ A live demo of `main` at `b3191a059` found nine gaps. These are the five Spec 10 **A3 matcher.** Case-insensitive substring of the trimmed query in name, description or id. The live path's filter skips the id, but the official registry's own search matches names, so including the id is what lets "github" find `io.github.*`. -**A4 ranking.** Cached hits go through the same `BuildCatalogHit` and `Rank` path; `Rank`'s keys are unchanged. One golden, `internal/registries/testdata/catalog_cached_fallback_order.json`, is written by the REST test and replayed by MCP, CLI, vitest and XCTest (parity row 14). +**A4 ranking.** Cached hits go through the same `BuildCatalogHit` and `Rank` path. (`Rank`'s keys changed in D36: the match tier is now first.) One golden, `internal/registries/testdata/catalog_cached_fallback_order.json`, is written by the REST test and replayed by MCP, CLI, vitest and XCTest (parity row 14). **A5 nullable booleans (finding 2).** A `*bool` that is nil resolves to a concrete value in Go, but the Settings toggle read `!!undefined` and showed OFF while the posture chip said ON. `SettingField.defaultValue` now accepts a boolean, and `defaultFor(cfg, ctx)` covers the keys whose default depends on the config or the edition. `quarantine_enabled`, `telemetry.enabled`, `audit_log.enabled` (when the block exists) and `audit_log.compress` default to true. `audit_log.enabled` and `audit_log.stdout` for an absent block follow the edition: the server edition resolves enabled with a stdout sink, the personal edition leaves audit logging off (`EffectiveAuditLog`). The edition is read from `/status`; if it arrives after the config, `refreshEditionDefaults` re-resolves the untouched keys in both copies. Defaults are computed from an untouched snapshot first and applied second, otherwise materialising `audit_log.enabled` would create the block and flip `stdout`'s rule. They go into both `working` and `original`, so nothing reads dirty and an untouched key is never PATCHed. One fixture, `internal/config/testdata/settings_nullable_defaults.json`, is pinned to the Go resolvers by a Go test, which also reflects over `Config` and fails when a `*bool` path is in neither set, and to `fields.ts` by vitest. Deviation from the plan, found while building: the audit-log rows live in an Advanced accordion that both editions show (the plan said server-edition-only), so the personal edition had to read OFF for an absent block. @@ -330,3 +332,32 @@ A live demo of `main` at `b3191a059` found nine gaps. These are the five Spec 10 **A9 browse order (finding 6).** The empty-query Official section was the merged pool's first twelve official hits in source order. The official source paginates alphabetically by reverse-DNS id, so it filled with obscure `ac.inference.sh/...` entries and the curated reference servers never appeared. Official is now: the curated source's hits first (`CatalogHit.Curated`, set from the built-in reference protocol), then the remaining official hits round-robin across sources in registry-list order, each keeping its native order, capped at 12. It is still never popularity-ordered, so Spec 110's "Popular differs from Official" guarantee holds. Every surface renders Popular first when it has entries and Official after it; an empty section is not printed (the CLI used to print an empty table). Amends Spec 110 FR-005 and US1-3. **A10 palette (finding 5).** The palette gains Profiles, Clients and Agent tokens groups, each needle-only. One `Promise.allSettled` of `GET /profiles`, `GET /clients` (direct, unscoped: the `clients` store is page-scoped) and `GET /tokens` runs on the first non-empty input after opening, never on open, and is cached for that open. A failure gives an empty group. Links come from `frontend/src/utils/scopeLinks.ts` and carry no sticky params: a palette jump is a fresh navigation, and a sticky `profile` filter could hide the target row. A client opens `/clients?client=` (or `?focus=` without scope filters), an agent token `/clients?tab=tokens&token=`, a profile its editor. Client credentials and revoked tokens are not listed. A tenant and the server edition get none of the three groups. + + +## D36 - fix-catalog-rank decisions (catalog search puts the real server first; audit C1; 2026-10-02) + +Audit finding C1 was marked fixed by 109-j but the live registry still buried GitHub's own server. Every number below was probed against `registry.modelcontextprotocol.io` on 2026-10-02. The official registry's `?search=` is a case-insensitive substring of `server.name` only, in byte order, 100 per page. `search=github` therefore returns 100 alphabetical `io.github.*` names, and `io.github.github/github-mcp-server` lies thousands of entries later. `search=.github/` returns exactly that one server, `search=/github` returns the 39 names whose server segment starts with `github`, and `search=github mcp` (with a space) returns none. Cold queries took 4-25 s. + +**D36.1 Relevance tier ranks first.** `matchTier(hit, q)` lower-cases q and the fields and collapses runs of `- _ . /` and whitespace to one space. Tier 5: the namespace owner equals q. The owner is the user of `io.github.`, otherwise the registrable-domain label (the second label: `com.notion` gives notion, `com.quranmajeed.time` gives quranmajeed, `uk.co.acme` gives acme), and never the registry-name fallback. Deviation found in live QA: the plan took the LAST label, which made `com.quranmajeed.time/prayer-times` an owner match for `time` and ranked it above the reference and Docker `time`; the same rule feeds the card's publisher line (`derivePublisher`). 4: the name segment or the title equals q. 3: either starts with q at a token boundary. 2: a whole word of either equals q. 1: q is a substring of either, or of the description. 0: no match, or the only match is the namespace. `Rank` is now tier desc, official desc, verified desc, popularity (Spec 110 FR-004), title asc, id asc. An empty q is tier 0 for every hit, so browse order is unchanged. Tier-0 hits are ranked last, not dropped; the `limit` cut removes them. + +**D36.2 Query expansion, official protocol only.** `officialExpansionQueries(q)`: for a q whose hyphen-joined form matches `^[A-Za-z0-9][A-Za-z0-9._-]{1,63}$`, `.q/` (owner) and `/q` (segment prefix), plus the hyphenated phrase for a multi-word q. One page per query, concurrently, merged owner, segment, phrase, main, de-duplicated by name; at most 300 entries per source (`typedFetchCap`). The main query decides availability, so the FR-060 `unavailable[]` contract is unchanged. Expansion hits are best-effort: when the main fetch fails or times out they are still shown (live, not cached), ahead of the cached-fallback matches. + +**D36.3 No truncation before ranking.** `SearchAll` calls `searchCatalogSource` for a typed q, which returns the whole filtered fetch; `Rank` sorts, then the result is cut to `limit`. `SearchServers` (per-registry REST and MCP `search_servers` with `registry`) and the empty-q browse path are unchanged, so the D35 browse goldens stay put. + +**D36.4 Version collapse.** `ServerEntry` gains `Title` and `Version`, both `json:"-"` (the `Popularity` precedent). `collapseOfficialVersions` keeps one entry per name at the position of its first occurrence: an explicit `isLatest:true`, else the highest dotted-numeric version (prerelease suffix ignored), else the last seen. Without `version=latest` the registry returns one row per published version for some names. + +**D36.5 Verified means the publisher owns the source repository** (narrows D12's second clause). For a built-in official-protocol entry: namespace `io.github.` with repo owner `x`, or a domain namespace whose owner label (at least 3 characters) is contained in the repo owner (`com.notion` with `makenotion`). A re-publisher (`ai.smithery/*`), a borrowed repo URL (`agency.ottobot/*` naming `modelcontextprotocol/registry`) and a missing repository are not verified. Built-in reference and Docker entries stay "trusted source"; a custom source is never verified. + +**D36.6 `official` keeps its wire meaning; the per-card badge goes.** Redefining `official` as reference-only would empty the Official section down to the 7 curated servers and move sections and goldens in about eight test files (FR-060/D35 A9 shipped yesterday). So `official` stays "from a built-in source" and keeps feeding `Rank` and the Official section. Web and macOS cards no longer show an "Official" badge: every default source is official, so it carried no signal, and the section heading says it. Cards show Verified, the publisher line and popularity (FR-061). The CLI table has no badge column. + +**D36.7 Stars count only when they belong to the publisher.** A hit that borrows another project's repo (`starsBorrowed`, the negation of "eligible" so a hand-built hit stays eligible) keeps its source-native signal but gets no GitHub stars, in `applyCachedStars` and in `resolvePopularity`. For a typed q `resolvePopularity` enqueues at most `limit` keys, the top eligible hits in Rank order; the wide fetch would otherwise spend GitHub's 50 requests an hour in one search. The empty-q landing keeps its uncapped prefetch because Popular needs stars across its pool. Spec 110 FR-005, FR-007 and FR-009(e) carry an amendment line. + +**D36.8 Popular de-dup.** A hit is skipped when its repo key or its normalized title (lower-case, `mcp/` prefix stripped, nothing else) was already taken by a higher-ranked Popular entry: reference `fetch` plus Docker `mcp/fetch` is one entry; `fetch-mcp` stays separate. + +**D36.9 Placeholder description.** `BuildCatalogHit` turns "No description available" into `""` in `Entry.Description`, so REST, MCP and the CLI agree and the cards (which hide an empty description) print nothing. Per-registry `ServerEntry` output is unchanged. + +**D36.10 Title.** `officialServerToEntry` reads server.json `title` (55 of the 100 live `github` hits have one; GitHub's is "GitHub"). `BuildCatalogHit` picks `Entry.Title`, then for an official-protocol entry the name segment after `/`, then the name, then the id. The query filter and the cached-listing matcher also match on the title. + +**D36.11 Warm-behind.** The 5 s per-source budget is shorter than a cold registry search, and a timed-out fetch used to be cancelled, so nothing was cached and the next search started cold again. Each network fetch in `SearchAll` now runs under its own context (`context.WithoutCancel(ctx)` plus a 30 s timeout) while `SearchAll` still waits only the 5 s budget and reports `timeout after 5s` as before. A fetch that lands later calls `cacheListing`, so the next search that times out is answered from the warmed listing (D35) and a warm registry answers live. Bounded: at most 2 background fetches per source (a mutex-guarded counter keyed by `listingKey`); a source holding both falls back to cancel-at-budget; the reference source never runs in the background; the goroutine copies the registry entry rather than reading the unsynchronised registry list. The caller's cancellation no longer stops a fetch that is already running; that is intended and bounded by 30 s and 2 slots. Test seam: `SetCatalogWarmBehindForTest(timeout, slots)`. + +**D36.12 Fixture.** `catalog_github_order.json` is registry-shaped: the official source carries `protocol` and a `corpus` of wrapped `{server,_meta}` items, the union of three real responses recorded on 2026-10-02 (`search=github` page 1, `search=.github/`, `search=/github`), sanitized to the fields the catalog reads. `RecordedRegistryHandlerForTest` (in `testhooks.go`, `net/http` only) reproduces the live search semantics, so the corpus answers `search=github` with exactly the recorded page 1, which lacks GitHub's server, and answers both expansion queries. The MCP leg now passes `limit: 20` like REST and the CLI (it used the default 10, harmless with 5 results). Re-recording: `RECORD_LIVE_REGISTRY=1 go test ./internal/registries -run TestRecordCatalogGithubFixture`. Deviation found while building: the Web parity spec counted every `catalog-result-*` node as a card, so its filter now also excludes the new publisher and popularity ids. diff --git a/specs/109-ux-navigation-consistency/spec.md b/specs/109-ux-navigation-consistency/spec.md index a2a4b045d..7337054c0 100644 --- a/specs/109-ux-navigation-consistency/spec.md +++ b/specs/109-ux-navigation-consistency/spec.md @@ -161,12 +161,12 @@ A user clicks "+ Add ▾ → Server" (or Servers → Add). The flow opens on a c **Why this priority**: Fixes N3, C1, C2, S7 and contradiction X4. It is not blocking (adding works today with effort), but it is the most common growth path. -**Independent Test**: Registry fixtures (the official registry plus a Smithery-style registry containing `ai.smithery/*github*` forks). Search "github" through the Web UI, macOS, `mcpproxy catalog search github -o json`, and MCP `search_servers` without `registry`. Assert the official GitHub server ranks first on every surface with identical order. Paste `npx -y @modelcontextprotocol/server-filesystem /tmp`, `https://api.githubcopilot.com/mcp/`, and a JSON snippet with `GITHUB_TOKEN`. Assert the detected transport, and assert that the token is stored as `${keyring:…}` when "Secret" is on. +**Independent Test**: Registry fixtures **recorded from the live official registry** (its `github` search, the owner query and the name-prefix query) plus a Smithery-style registry containing `ai.smithery/*github*` forks. Search "github" through the Web UI, macOS, `mcpproxy catalog search github -o json`, and MCP `search_servers` without `registry`. Assert the official GitHub server ranks first on every surface with identical order. Paste `npx -y @modelcontextprotocol/server-filesystem /tmp`, `https://api.githubcopilot.com/mcp/`, and a JSON snippet with `GITHUB_TOKEN`. Assert the detected transport, and assert that the token is stored as `${keyring:…}` when "Secret" is on. **Acceptance Scenarios**: 1. **Given** no query, **When** the Catalog opens, **Then** it shows "Official" and "Popular" sections from all sources, each result with its human title (id as secondary text), publisher, verified badge, and stars or installs when the source provides them. -2. **Given** the query "github", **Then** results from every source are merged and ranked by [contracts/rest-api.md](contracts/rest-api.md#catalog) (official source, then verified publisher, then popularity, then text relevance, then title). Unreachable sources are listed as "unavailable" without failing the search (Spec 070 FR-008). +2. **Given** the query "github", **Then** results from every source are merged and ranked by [contracts/rest-api.md](contracts/rest-api.md#catalog) (match tier: publisher equals the query > exact name > name prefix > name token > substring or description > namespace-only; then official source, verified publisher, popularity, title). Unreachable sources are listed as "unavailable" without failing the search (Spec 070 FR-008). 3. **Given** a result, **Then** its button reads "Add to MCPProxy". After adding, it reads "Added ✓ · Open", and the server lands quarantined (Spec 070 FR-009). The CLI prints "Added to MCPProxy (quarantined for review)", and macOS uses the same labels. 4. **Given** the paste field, **When** the user pastes a URL, a command line, or a JSON/TOML snippet, **Then** the transport is detected and a preview form is filled. Nothing is added until the user presses Add, and a pasted command is never executed during preview. 5. **Given** an env var named like a secret (`*_TOKEN`, `*_KEY`, `*SECRET*`, `*PASSWORD*`), **Then** its row defaults to "Secret". On Add the value goes to the OS keyring and the config stores `${keyring:-env-}` (FR-065 naming; a header of the same name gets its own `-header-` entry, and an existing keyring entry is never overwritten). The value is never written to `mcp_config.json` or echoed back. @@ -221,7 +221,8 @@ A first-time user connects a client and imports servers in the wizard. Import ro - **Attention flapping (a server connecting)**: `connecting` is not an item until it has lasted 60 s. Items are recomputed on events and debounced (250 ms). - **Client never seen, but the user connected it seconds ago**: `client_never_seen` appears only 5 minutes after the connect write, and its fix is the reload hint. - **Unknown `clientInfo.name`** (a client not in the registry): it appears on Clients as "Other: " with its sessions. It is never mapped to a supported client by guesswork. Aliases are an explicit list per client. The name is untrusted wire input and is rendered inertly everywhere (research D19: escaped, control/ANSI sequences stripped, capped at 64 characters). -- **Catalog source timeout**: that source is marked unavailable for that query and is not retried within the request. Ranking uses the sources that answered. +- **Catalog source timeout**: that source is marked unavailable for that query and is not retried within the request. Ranking uses the sources that answered. The fetch itself is not abandoned: it finishes in the background (at most 30 s, at most 2 per source) and refreshes the listing cache, so the next search is answered from it (research D36). +- **A query that matches only a namespace** (`github` against `io.github.06ketan/slideshot`): the entry ranks last, after every name match, and the `limit` cut removes it first. It is never promoted by its `io.github.` namespace. - **Pasted snippet with several servers**: the preview lists each one. Add adds only the checked ones, each quarantined. - **Secret toggle when the keyring is unavailable** (headless Linux): the toggle is disabled with the reason, and the value stays a plain env var only after an explicit confirmation. - **Old bookmarks**: `/overview`, `/repositories`, `/security`, `/sessions`, `/tokens` redirect, and the query is kept (navigation-map contract). @@ -302,8 +303,8 @@ A first-time user connects a client and imports servers in the wizard. Import ro **G. Catalog and adding servers (N3, C1, C2, S7, X4)** -- **FR-060**: `GET /api/v1/catalog/search?q=&source=&tag=&limit=` MUST fan out to every enabled catalog source in parallel (timeout 5 s per source; a source whose live fetch fails is answered from its per-source listing cache, marked `from_cache`, and stays in `unavailable[]` with `fallback` and `cached_at`, research D35), merge, de-duplicate by (source, id), rank with one pure function (official source > verified publisher > popularity > text relevance > title), and return `unavailable[]` for sources that failed. An empty `q` returns `official` and `popular` sections; every surface renders Popular first when it has entries, then Official (curated servers first, research D35 A9). -- **FR-061**: Catalog results MUST carry `title`, `id`, `publisher`, `verified`, `official`, `popularity` (stars or installs when provided), `source` and the Spec 070 add fields. Every surface shows the title first and the id as secondary text. +- **FR-060**: `GET /api/v1/catalog/search?q=&source=&tag=&limit=` MUST fan out to every enabled catalog source in parallel (timeout 5 s per source; a source whose live fetch fails is answered from its per-source listing cache, marked `from_cache`, and stays in `unavailable[]` with `fallback` and `cached_at`, research D35), merge, de-duplicate by (source, id), rank with one pure function (match tier: publisher equals the query > exact name > name prefix > name token > substring or description > namespace-only; then official source > verified publisher > popularity > title), and return `unavailable[]` for sources that failed. A typed query fetches one page per source plus, for the official protocol, owner and name-prefix expansion queries, and ranks before truncating to `limit`; a fetch that outlives the 5 s budget finishes in the background (≤ 30 s, ≤ 2 per source) and refreshes the listing cache (research D36). An empty `q` returns `official` and `popular` sections; every surface renders Popular first when it has entries, then Official (curated servers first, research D35 A9). +- **FR-061**: Catalog results MUST carry `title`, `id`, `publisher`, `verified`, `official`, `popularity` (stars or installs when provided), `source` and the Spec 070 add fields. Every surface shows the title first and the id as secondary text. `verified` = the publisher's namespace owns the source repository (research D36.5); `official` = from a built-in catalog source and is shown as the Official section, not as a per-card badge; `title` prefers the source's own title; a placeholder description is empty. - **FR-062**: Adding a server MUST start at `/add-server` (outside the `/servers/:serverName` path space, so no server name — `add` included — can be shadowed by a static route; no server name is reserved) with the tabs Catalog (default) · Paste · Import · Manual, selected by `?tab=catalog|paste|import|manual` (FR-016). `?source=` on that page keeps its contract meaning (a catalog source id that narrows the Catalog tab, mapped to REST `source`). `/repositories` redirects to `/add-server?tab=catalog`, and catalog-source management moves to Settings → Catalog sources. macOS: the Add Server sheet gets the same four tabs and the "Registries" sidebar item is removed. - **FR-063**: The add action MUST be labelled "Add to MCPProxy", and after success "Added ✓ · Open", on Web and macOS. The CLI prints "Added to MCPProxy (quarantined for review)". - **FR-064**: The Paste source MUST accept JSON/TOML (existing detector), an `http(s)://` URL (→ HTTP server) or a command line (→ stdio command + args), preview through `POST /servers/import/json?preview=true` (extended with `url` and `command` formats), and add nothing until confirmed. A preview never executes anything. @@ -458,7 +459,7 @@ Every contradiction from the four inventories (W = Web UI, M = macOS, C = CLI, R - **SC-005**: No first-party surface calls `POST /servers/{id}/unquarantine`: a grep test over the non-test sources in `frontend/src`, `native/macos`, `cmd/mcpproxy`, `cmd/mcpproxy-tray` and `internal/tray` (the Go tray binary) finds no reference to the `/unquarantine` path (`scripts/check-no-unquarantine-callers.sh`, with its self-test, run by the `No Unquarantine Callers` job of `unit-tests.yml`), because 109-f deletes the then-dead client methods (`APIClient.unquarantineServer`, `stores/servers.ts` `unquarantineServer`, the Go tray's `Client.UnquarantineServer`/`ServerAdapter.UnquarantineServer`) and repoints the Go tray's quarantine submenu (X12) instead of leaving them unused. - **SC-006**: From any page, a user reaches "connect a new client" in ≤ 2 clicks (sidebar Clients → Connect, or "+ Add ▾" → Client). - **SC-007**: The header shows every control without clipping or horizontal scroll at 1440, 1100, 900 and 390 px, in both themes (`visual-a11y-sweep`). -- **SC-008**: Catalog search "github" on the fixture ranks the official, verified GitHub server first, with identical order across REST, Web, macOS, CLI and MCP. +- **SC-008**: Catalog search "github" on a fixture recorded from the live official registry ranks GitHub's own server (`io.github.github/github-mcp-server`, publisher github, verified) first, with identical order across REST, Web, macOS, CLI and MCP. - **SC-009**: Every scope-aware page round-trips its URL (URL → state → URL is identity for contract parameters, tab included) and loads filtered without an unfiltered fetch (Playwright network assertion). - **SC-010**: On the A1 fixture (approve a 14-tool server, then 3 real calls), the default Activity view shows exactly the 3 calls; System events shows 1 folded row. - **SC-011**: `GET /api/v1/attention` p95 ≤ 20 ms at 100 servers / 1,000 tools (served from the in-memory snapshot); catalog search p95 ≤ 5.5 s with one source timing out. diff --git a/specs/109-ux-navigation-consistency/tasks.md b/specs/109-ux-navigation-consistency/tasks.md index 6adefae5f..d22aa92aa 100644 --- a/specs/109-ux-navigation-consistency/tasks.md +++ b/specs/109-ux-navigation-consistency/tasks.md @@ -375,6 +375,23 @@ Not new requirements: nine findings from a live demo of `main` at `b3191a059`, f --- +## Phase 16: PR fix-catalog-rank — catalog search ranks the real server first against the live registry (audit C1; 2026-10-02) + +Not a new requirement: the audit's C1 was marked fixed, but the live official registry returns names in byte order, so a typed `github` never reached GitHub's own server and the hand-written fixture hid it. FR-060, FR-061 and SC-008 now hold against a fixture recorded from the live registry. Decisions: research D36. + +- [x] T166 [P] Registry-shaped SC-008 fixture and fake registry (D36.12): `RecordedRegistryHandlerForTest` in `internal/registries/testhooks.go`, `internal/registries/recorded_registry_test.go` (`TestCatalogGithubFixture_MainPageLacksGitHubServer` proves the live bug, `TestRecordCatalogGithubFixture` re-records), the corpus in `internal/registries/testdata/catalog_github_order.json`, and the three Go legs (`internal/httpapi/spec109_catalog_order_test.go`, `internal/server/spec109_catalog_order_test.go`, `cmd/mcpproxy/catalog_order_parity_test.go`) serving it, the MCP leg now at `limit` 20 +- [x] T167 official.go (D36.2, D36.4, D36.10): server.json title and version, `collapseOfficialVersions`, `officialExpansionQueries`, `fetchOfficialCatalog` (one page per query, concurrent, main query decides availability): `internal/registries/official_catalog_test.go` +- [x] T168 search.go (D36.3): `searchCatalogSource` fetches without truncating before ranking (`typedFetchCap` 300), the query filter and the cached-listing matcher match the title; `SearchServers` unchanged: `internal/registries/search_catalog_test.go` +- [x] T169 catalog.go `BuildCatalogHit` signals (D36.5, D36.7, D36.9, D36.10): `verified` = the publisher owns the repository, `title` order, placeholder description empty, no stars for a borrowed repo: `internal/registries/catalog_hit_signals_test.go` +- [x] T170 catalog.go `Rank` (D36.1): `matchTier` first, `relevanceScore` removed: `internal/registries/catalog_rank_tier_test.go`, `internal/registries/rank_test.go` +- [x] T171 catalog.go `SearchAll` (D36.3, D36.7, D36.8): typed queries rank before truncating, the popularity prefetch is capped at `limit`, Popular de-dups by normalized title: `internal/registries/catalog_typed_test.go` (`TestSearchAll_RecordedRegistry_GitHubFirst`) +- [x] T172 catalog.go warm-behind (D36.11): a source that outlives the budget finishes in the background (30 s, 2 per source) and warms the listing cache: `internal/registries/catalog_warm_behind.go`, `internal/registries/catalog_warm_behind_test.go` (run under `-race -count=20` and `GOMAXPROCS=1`) +- [x] T173 [P] [US5] Web card (FR-061, D36.6): no Official badge, Verified, publisher line, popularity: `frontend/src/components/CatalogSearch.vue`, `frontend/tests/unit/catalog-card-signals.spec.ts`, `frontend/tests/unit/add-server-catalog.spec.ts`, `frontend/tests/unit/catalog-order-parity.spec.ts` +- [x] T174 [US5] macOS card badge parity (D36.6): `CatalogView.trustBadge`, `native/macos/MCPProxy/MCPProxyTests/CatalogTests.swift`, `native/macos/MCPProxy/MCPProxyTests/CatalogOrderParityTests.swift`; publisher and popularity on the macOS card are a follow-up +- [x] T175 [P] Docs and bookkeeping for T166–T174: `spec.md` (US5, FR-060, FR-061, SC-008, edge cases), `data-model.md` §9, `contracts/rest-api.md`, `contracts/mcp-tools.md`, `research.md` D36, `plan.md`, `quickstart.md` recipe `fix-catalog-rank`, `parity-matrix.json` row 14, `acceptance-index.json` SC-008, Spec 110 amendments, `docs/api/rest-api.md`, `docs/cli/catalog-commands.md`, `docs/features/catalog-popularity.md`, the `catalog search` help text + +--- + ## Dependencies & Execution Order ```text @@ -421,4 +438,4 @@ Spec 108-f, 108-i, 108-j, 108-k + 109-i ──> 109-l ## Task Count -201 tasks (the mechanical count of `- [ ] T…` lines under the phase headings; 195 before the demo-ux-fixes PR, 186 before 109-m; demo-ux-fixes added T160–T165); see the checklist above for phase totals and completion state (109-l added T152a, T154a, T157, T158, T159; 109-m added T144a, T145a, T145b, T147a, T148c, T149a–T149d; codex round 4 added T078c, T124a; codex round 3 added T011a, T076a, T101a, T125a, T148a, T148b; codex round 1 added T069a, T077b, T078b, and the threshold-timer test inside T053; Spec 108's duplicated scope-filter and Clients-shell tasks were merged into T111/T116/T117/T122/T131). +211 tasks (the mechanical count of `- [ ] T…` lines under the phase headings; 201 before the fix-catalog-rank PR, which added T166–T175; 195 before the demo-ux-fixes PR, 186 before 109-m; demo-ux-fixes added T160–T165); see the checklist above for phase totals and completion state (109-l added T152a, T154a, T157, T158, T159; 109-m added T144a, T145a, T145b, T147a, T148c, T149a–T149d; codex round 4 added T078c, T124a; codex round 3 added T011a, T076a, T101a, T125a, T148a, T148b; codex round 1 added T069a, T077b, T078b, and the threshold-timer test inside T053; Spec 108's duplicated scope-filter and Clients-shell tasks were merged into T111/T116/T117/T122/T131). diff --git a/specs/110-catalog-popularity/spec.md b/specs/110-catalog-popularity/spec.md index cfe350a99..1a6a5f942 100644 --- a/specs/110-catalog-popularity/spec.md +++ b/specs/110-catalog-popularity/spec.md @@ -96,4 +96,5 @@ The Web UI result row, the macOS catalog row and the CLI `catalog search` table ## Amendments +- 2026-10-02, Spec 109 fix-catalog-rank (T169-T172, research D36): FR-004 still holds, but `Rank` now puts the match tier (how well the name matches the query) first, so popularity breaks ties inside a tier. FR-005 Popular also de-duplicates by normalized title (the reference `fetch` and Docker `mcp/fetch` are one entry) and counts GitHub stars only when the publisher owns the repository the entry names. FR-007 and FR-009(e) enqueue at most `limit` eligible keys for a typed query (the empty-query landing is uncapped). Rationale: Spec 109 research D36.5, D36.7, D36.8. - 2026-10-02, Spec 109 demo-ux-fixes (T164): FR-005 and US1 scenario 3 replace "source order" with curated-first, then round-robin across sources. Official is still never popularity-ordered, so Popular still differs from it. Every surface renders Popular before Official when Popular is non-empty. Rationale: Spec 109 research D35 A9. From e81ee2961f5560643a4180ed7e8f75f73a925940 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 2 Oct 2026 17:17:25 +0300 Subject: [PATCH 4/5] fix(catalog): main query decides source availability and Verified needs a real owner match R3.1 (medium, confirmed): when the main official-registry query landed inside the 5s budget but an expansion was still running, SearchAll returned no hits and marked the source unavailable. The progress snapshot now includes the landed main query, and a budget expiry after the main query landed returns its hits with no error (D36.2); the fetch keeps warming the cache behind. O1 (medium, confirmed): publisherOwnsRepo's substring rule let com.hub verify against github/github-mcp-server. A domain label must now equal the repo owner, be a whole -/_ token of it, or be a 5+ character brand with a prefix or suffix of at most 4 characters (notion of makenotion). Spec D36.5 and the REST contract text updated. --- internal/registries/catalog.go | 39 +++++++++++++++++-- .../registries/catalog_hit_signals_test.go | 4 ++ internal/registries/catalog_typed_test.go | 38 ++++++++++++++++++ internal/registries/catalog_warm_behind.go | 27 +++++++++---- .../registries/catalog_warm_behind_test.go | 2 +- internal/registries/official.go | 24 ++++++++---- internal/registries/search.go | 8 ++-- .../contracts/rest-api.md | 2 +- .../109-ux-navigation-consistency/research.md | 2 +- 9 files changed, 120 insertions(+), 26 deletions(-) diff --git a/internal/registries/catalog.go b/internal/registries/catalog.go index d9a94c59e..49fabf5f4 100644 --- a/internal/registries/catalog.go +++ b/internal/registries/catalog.go @@ -215,7 +215,7 @@ func SearchAll(ctx context.Context, q, tag string, limit int, opts SearchOptions defer wg.Done() reg := sources[i] - fetch := func(c context.Context, onPartial func([]ServerEntry)) ([]ServerEntry, error) { + fetch := func(c context.Context, onPartial func([]ServerEntry, bool)) ([]ServerEntry, error) { if empty { return searchRegistry(c, ®, tag, "", fetchLimit, nil) } @@ -473,8 +473,9 @@ func catalogHitTitle(reg *RegistryEntry, entry ServerEntry) string { // publisherOwnsRepo reports whether the namespace of an official-protocol id // owns the GitHub repository it names as its source (Spec 109 D36.5): an // `io.github.` namespace needs repo owner x; a domain namespace needs its -// owner label (≥ 3 characters, e.g. "notion" of com.notion) to appear in the -// repo owner (e.g. "makenotion"). A re-publisher of someone else's server, a +// owner label (≥ 3 characters, e.g. "notion" of com.notion) to equal the repo +// owner, be a whole token of it, or be a ≥ 5 character brand with a ≤ 4 +// character prefix/suffix on it (e.g. "makenotion"). A re-publisher of someone else's server, a // borrowed repo URL or a missing repository never verifies. func publisherOwnsRepo(id, sourceCodeURL string) bool { key, ok := GitHubRepoKey(sourceCodeURL) @@ -492,7 +493,37 @@ func publisherOwnsRepo(id, sourceCodeURL string) bool { } label, ok := namespaceOwner(id) label = strings.ToLower(label) - return ok && len(label) >= 3 && strings.Contains(repoOwner, label) + return ok && domainLabelMatchesOwner(label, repoOwner) +} + +// domainLabelMatchesOwner is the domain-namespace half of publisherOwnsRepo. +// A bare substring test let any short label verify against an unrelated owner +// ("hub" inside "github"), so the label must be the owner itself, a whole +// "-"/"_"-separated token of it ("acme" of acme-corp), or a brand of at least +// 5 characters with a short (at most 4 characters) prefix or suffix on the +// owner ("notion" of makenotion). +func domainLabelMatchesOwner(label, repoOwner string) bool { + if len(label) < 3 { + return false + } + if label == repoOwner { + return true + } + for _, tok := range strings.FieldsFunc(repoOwner, func(r rune) bool { return r == '-' || r == '_' }) { + if tok == label { + return true + } + } + if len(label) < 5 { + return false + } + if rest, ok := strings.CutPrefix(repoOwner, label); ok && len(rest) <= 4 { + return true + } + if rest, ok := strings.CutSuffix(repoOwner, label); ok && len(rest) <= 4 { + return true + } + return false } // applyCachedStars fills in hit.Popularity.Stars from the installed diff --git a/internal/registries/catalog_hit_signals_test.go b/internal/registries/catalog_hit_signals_test.go index cd2b440c1..cd7ded318 100644 --- a/internal/registries/catalog_hit_signals_test.go +++ b/internal/registries/catalog_hit_signals_test.go @@ -52,6 +52,10 @@ func TestBuildCatalogHit_VerifiedMeansPublisherOwnsRepo(t *testing.T) { {"io.github owner case-insensitive", officialReg(), "io.github.Dave-London/github", "https://github.com/dave-london/mcp", true}, {"io.github other owner", officialReg(), "io.github.rog0x/github", "https://github.com/someone-else/github", false}, {"domain label inside owner", officialReg(), "com.notion/mcp", "https://github.com/makenotion/notion-mcp-server", true}, + {"domain label equals owner", officialReg(), "com.notion/mcp", "https://github.com/notion/x", true}, + {"domain label is a whole owner token", officialReg(), "com.acme/mcp", "https://github.com/acme-corp/x", true}, + {"short label substring of owner is spoofable", officialReg(), "com.hub/evil", "https://github.com/github/github-mcp-server", false}, + {"long affix is not a short brand prefix", officialReg(), "com.otion/evil", "https://github.com/makenotion/notion-mcp-server", false}, {"re-publisher", officialReg(), "ai.smithery/Hint-Services-x", "https://github.com/Hint-Services/x", false}, {"borrowed repo", officialReg(), "agency.ottobot/foo", "https://github.com/modelcontextprotocol/registry", false}, {"short label never matches", officialReg(), "io.ab/foo", "https://github.com/cabinet/foo", false}, diff --git a/internal/registries/catalog_typed_test.go b/internal/registries/catalog_typed_test.go index 0032e1c73..2269a4305 100644 --- a/internal/registries/catalog_typed_test.go +++ b/internal/registries/catalog_typed_test.go @@ -155,6 +155,44 @@ func TestSearchAll_MainTimesOutExpansionHitsStillShown(t *testing.T) { } } +// R3.1: the MAIN query decides availability (D36.2). A main query that lands +// inside the budget while an expansion is still running must not turn the +// source unavailable: the hits that arrived are shown, with no timeout error. +func TestSearchAll_MainLandsExpansionSlowSourceStaysAvailable(t *testing.T) { + released := make(chan struct{}) + t.Cleanup(func() { close(released) }) + installCatalogFixtureSources(t, func(h http.Handler, id string) http.Handler { + if id != "official" { + return h + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("search") != "github" { // every expansion is slow + select { + case <-released: + case <-r.Context().Done(): + } + http.Error(w, "late", http.StatusGatewayTimeout) + return + } + h.ServeHTTP(w, r) + }) + }) + t.Cleanup(SetCatalogWarmBehindForTest(2*time.Second, 2)) + + hits, _, unavailable := SearchAll(context.Background(), "github", "", 20, SearchOptions{SourceTimeout: 400 * time.Millisecond, PopularityWait: -1}) + if len(unavailable) != 0 { + t.Fatalf("unavailable = %+v, want none: the main query answered", unavailable) + } + if len(hits) == 0 { + t.Fatal("the main query's hits must be shown") + } + for _, h := range hits { + if h.FromCache { + t.Errorf("%s: main-query hits are live, not from the cache", h.Entry.ID) + } + } +} + // recordingProvider is a PopularityProvider that holds no stars and records // every key SearchAll asks it to resolve. type recordingProvider struct { diff --git a/internal/registries/catalog_warm_behind.go b/internal/registries/catalog_warm_behind.go index b5f562163..e32b6d9f9 100644 --- a/internal/registries/catalog_warm_behind.go +++ b/internal/registries/catalog_warm_behind.go @@ -62,8 +62,9 @@ func releaseWarmBehind(key string) { // sourceFetchFunc fetches one source's entries. onPartial receives hits that // are already known while the fetch is still running (see -// searchCatalogSourceProgress); it may be ignored. -type sourceFetchFunc func(ctx context.Context, onPartial func([]ServerEntry)) ([]ServerEntry, error) +// searchCatalogSourceProgress), and whether the main query is among them; it +// may be ignored. +type sourceFetchFunc func(ctx context.Context, onPartial func(entries []ServerEntry, mainLanded bool)) ([]ServerEntry, error) // sourceFetchOutcome is what SearchAll gets back for one source. entries is the // fetch's result (on error: whatever hits arrived anyway, e.g. the official @@ -76,20 +77,22 @@ type sourceFetchOutcome struct { } type partialEntries struct { - mu sync.Mutex - entries []ServerEntry + mu sync.Mutex + entries []ServerEntry + mainLanded bool } -func (p *partialEntries) set(entries []ServerEntry) { +func (p *partialEntries) set(entries []ServerEntry, mainLanded bool) { p.mu.Lock() p.entries = entries + p.mainLanded = p.mainLanded || mainLanded p.mu.Unlock() } -func (p *partialEntries) get() []ServerEntry { +func (p *partialEntries) get() ([]ServerEntry, bool) { p.mu.Lock() defer p.mu.Unlock() - return p.entries + return p.entries, p.mainLanded } // fetchSourceWithinBudget runs fetch for one source and waits at most budget @@ -143,6 +146,14 @@ func fetchSourceWithinBudget(ctx context.Context, reg RegistryEntry, budget time return out default: } - return sourceFetchOutcome{entries: partial.get(), err: errors.New("source budget exceeded"), timedOut: true} + entries, mainLanded := partial.get() + if mainLanded { + // The main query decides availability (D36.2): it answered, only + // an expansion is still running, so the source is not unavailable. + // The fetch keeps going in the background and caches the full + // listing when it lands. + return sourceFetchOutcome{entries: entries} + } + return sourceFetchOutcome{entries: entries, err: errors.New("source budget exceeded"), timedOut: true} } } diff --git a/internal/registries/catalog_warm_behind_test.go b/internal/registries/catalog_warm_behind_test.go index 107f4eb58..9fe5f9d81 100644 --- a/internal/registries/catalog_warm_behind_test.go +++ b/internal/registries/catalog_warm_behind_test.go @@ -185,7 +185,7 @@ func TestSearchAll_ReferenceSourceNeverRunsInBackground(t *testing.T) { reg := RegistryEntry{ID: "reference", Name: "Reference", Protocol: protocolReference, ServersURL: "builtin://reference"} key := listingKey(®) - out := fetchSourceWithinBudget(context.Background(), reg, time.Second, func(context.Context, func([]ServerEntry)) ([]ServerEntry, error) { + out := fetchSourceWithinBudget(context.Background(), reg, time.Second, func(context.Context, func([]ServerEntry, bool)) ([]ServerEntry, error) { warmBehind.mu.Lock() held := warmBehind.inflight[key] warmBehind.mu.Unlock() diff --git a/internal/registries/official.go b/internal/registries/official.go index 85dd33479..688c555cb 100644 --- a/internal/registries/official.go +++ b/internal/registries/official.go @@ -139,10 +139,12 @@ func fetchOfficialCatalog(ctx context.Context, reg *RegistryEntry, q string) ([] } // fetchOfficialCatalogProgress is fetchOfficialCatalog that also calls -// onExpansion, serialized, with the merged hits of the expansion queries -// finished so far each time one lands, so a caller that gives up waiting for -// the (slower) main query can still use them. onExpansion may be nil. -func fetchOfficialCatalogProgress(ctx context.Context, reg *RegistryEntry, q string, onExpansion func([]ServerEntry)) ([]ServerEntry, error) { +// onProgress, serialized, with the merged hits of the queries finished so far +// each time one lands (mainLanded says the main query is among them), so a +// caller that gives up waiting can still use them: expansion hits while the +// main query is slow, and a landed main query's hits, which keep the source +// available (D36.2), while an expansion is slow. onProgress may be nil. +func fetchOfficialCatalogProgress(ctx context.Context, reg *RegistryEntry, q string, onProgress func(entries []ServerEntry, mainLanded bool)) ([]ServerEntry, error) { expansions := officialExpansionQueries(q) // Merge order: owner (".x/"), segment ("/x"), phrase ("x-y"), then main. var queries []string @@ -166,6 +168,7 @@ func fetchOfficialCatalogProgress(ctx context.Context, reg *RegistryEntry, q str err error } var mu sync.Mutex + mainLanded := false results := make([]pageResult, len(queries)) var wg sync.WaitGroup for i, query := range queries { @@ -176,14 +179,19 @@ func fetchOfficialCatalogProgress(ctx context.Context, reg *RegistryEntry, q str mu.Lock() defer mu.Unlock() results[i] = pageResult{entries: entries, err: err} - if onExpansion == nil || i == mainIdx || err != nil { + if i == mainIdx && err == nil { + mainLanded = true + } + if onProgress == nil || err != nil { return } var merged []ServerEntry - for j := 0; j < mainIdx; j++ { - merged = append(merged, results[j].entries...) + for j := 0; j <= mainIdx; j++ { + if results[j].err == nil { + merged = append(merged, results[j].entries...) + } } - onExpansion(collapseOfficialVersions(merged)) + onProgress(collapseOfficialVersions(merged), mainLanded) }(i, query) } wg.Wait() diff --git a/internal/registries/search.go b/internal/registries/search.go index f0821e5f3..119799635 100644 --- a/internal/registries/search.go +++ b/internal/registries/search.go @@ -142,7 +142,7 @@ func searchCatalogSource(ctx context.Context, reg *RegistryEntry, q string) ([]S // queries as they arrive. A caller that stops waiting (the 5s source budget) // can then still show them while the slow main query finishes in the // background (Spec 109 D36.2/D36.11). onPartial may be nil. -func searchCatalogSourceProgress(ctx context.Context, reg *RegistryEntry, q string, onPartial func([]ServerEntry)) ([]ServerEntry, error) { +func searchCatalogSourceProgress(ctx context.Context, reg *RegistryEntry, q string, onPartial func([]ServerEntry, bool)) ([]ServerEntry, error) { // FR-008: skip a key-requiring registry when no key is configured. if err := checkRegistryKey(reg); err != nil { return nil, err @@ -156,9 +156,11 @@ func searchCatalogSourceProgress(ctx context.Context, reg *RegistryEntry, q stri err error ) if reg.Protocol == protocolOfficial { - var progress func([]ServerEntry) + var progress func([]ServerEntry, bool) if onPartial != nil { - progress = func(expansion []ServerEntry) { onPartial(finishCatalogEntries(reg, q, expansion)) } + progress = func(landed []ServerEntry, mainLanded bool) { + onPartial(finishCatalogEntries(reg, q, landed), mainLanded) + } } servers, err = fetchOfficialCatalogProgress(ctx, reg, q, progress) } else { diff --git a/specs/109-ux-navigation-consistency/contracts/rest-api.md b/specs/109-ux-navigation-consistency/contracts/rest-api.md index 1b69349a8..a455e15cd 100644 --- a/specs/109-ux-navigation-consistency/contracts/rest-api.md +++ b/specs/109-ux-navigation-consistency/contracts/rest-api.md @@ -255,7 +255,7 @@ No new route: the Web UI and macOS write each secret with the existing `POST /se - Empty `q` → `results: []`, `sections: {"official": [...], "popular": [...]}` (≤ 12 each). The keys are not ordered; every surface renders Popular first when it is non-empty, then Official (curated reference servers first, then round-robin across sources, never popularity-ordered; research D35 A9). - **Cached fallback (D35).** A source whose live fetch fails (timeout or any error except a missing API key) is answered from its per-source listing cache when that holds a listing refreshed within 24 h: the hits match the trimmed query as a case-insensitive substring of name, description or id, carry `from_cache: true`, and rank like any other hit. The source stays in `unavailable[]` with `fallback: "cached_listing"` and `cached_at`, so a consumer that only reads `unavailable[]` is unchanged. A source with nothing cached has neither field. The cache is in memory, so a daemon restart empties it. - Ranking (pure `registries.Rank`, research D36.1): match tier desc (5 the publisher equals the query, 4 the name segment or title equals it, 3 a name starts with it, 2 a whole word of the name equals it, 1 substring or description, 0 namespace-only or no match), then `official` desc, `verified` desc, popularity desc (missing = 0), `title` asc, `id` asc. An empty `q` is tier 0 for every hit. A typed query is fetched wide (one page per source plus, for the official protocol, an owner and a name-prefix query) and ranked before it is cut to `limit`, so a namespace-only match (`io.github.*`) never displaces a name match. -- `verified` is true when a built-in official-protocol entry's namespace owns its source repository (`io.github.` with repo owner `x`, or a domain label contained in the repo owner); built-in reference and Docker entries stay verified as trusted sources; a custom source is never verified. `official` still means "from a built-in source" and is shown as the Official section, not as a per-card badge; Web and macOS cards show Verified, the publisher and popularity. +- `verified` is true when a built-in official-protocol entry's namespace owns its source repository (`io.github.` with repo owner `x`, or a domain label that equals the repo owner, is a whole token of it, or is a 5+ character brand with a short prefix/suffix on it); built-in reference and Docker entries stay verified as trusted sources; a custom source is never verified. `official` still means "from a built-in source" and is shown as the Official section, not as a per-card badge; Web and macOS cards show Verified, the publisher and popularity. - `title` is the source's own title when it has one (server.json `title`), else the name segment after the namespace. `description` is `""` when the source only had a placeholder. GitHub stars count only when the publisher owns the repository the entry names. - `added: true` when a configured server has `source_registry_id == source` **and** the same install target (`install.url`, or the command + args) as this result → surfaces render "Added ✓ · Open". The config does not store the registry's own server `id`, so `id` is not part of the join (data-model.md §9). A manually added server matches on the install target alone. When exactly one matching server is visible to the caller, the optional `added_server_name` identifies it; clients use that authoritative name before any comparison with redacted `GET /servers` URL/argv fields. It is omitted for ambiguous matches. - Adding stays `POST /registries/{id}/servers/{serverId}/add` (Spec 070), always quarantined. diff --git a/specs/109-ux-navigation-consistency/research.md b/specs/109-ux-navigation-consistency/research.md index ba43073c7..a72a15af9 100644 --- a/specs/109-ux-navigation-consistency/research.md +++ b/specs/109-ux-navigation-consistency/research.md @@ -346,7 +346,7 @@ Audit finding C1 was marked fixed by 109-j but the live registry still buried Gi **D36.4 Version collapse.** `ServerEntry` gains `Title` and `Version`, both `json:"-"` (the `Popularity` precedent). `collapseOfficialVersions` keeps one entry per name at the position of its first occurrence: an explicit `isLatest:true`, else the highest dotted-numeric version (prerelease suffix ignored), else the last seen. Without `version=latest` the registry returns one row per published version for some names. -**D36.5 Verified means the publisher owns the source repository** (narrows D12's second clause). For a built-in official-protocol entry: namespace `io.github.` with repo owner `x`, or a domain namespace whose owner label (at least 3 characters) is contained in the repo owner (`com.notion` with `makenotion`). A re-publisher (`ai.smithery/*`), a borrowed repo URL (`agency.ottobot/*` naming `modelcontextprotocol/registry`) and a missing repository are not verified. Built-in reference and Docker entries stay "trusted source"; a custom source is never verified. +**D36.5 Verified means the publisher owns the source repository** (narrows D12's second clause). For a built-in official-protocol entry: namespace `io.github.` with repo owner `x`, or a domain namespace whose owner label (at least 3 characters) equals the repo owner, is a whole `-`/`_` token of it, or is a brand of at least 5 characters with a prefix or suffix of at most 4 characters on it (`com.notion` with `makenotion`; `com.hub` never matches `github`). A re-publisher (`ai.smithery/*`), a borrowed repo URL (`agency.ottobot/*` naming `modelcontextprotocol/registry`) and a missing repository are not verified. Built-in reference and Docker entries stay "trusted source"; a custom source is never verified. **D36.6 `official` keeps its wire meaning; the per-card badge goes.** Redefining `official` as reference-only would empty the Official section down to the 7 curated servers and move sections and goldens in about eight test files (FR-060/D35 A9 shipped yesterday). So `official` stays "from a built-in source" and keeps feeding `Rank` and the Official section. Web and macOS cards no longer show an "Official" badge: every default source is official, so it carried no signal, and the section heading says it. Cards show Verified, the publisher line and popularity (FR-061). The CLI table has no badge column. From fa25b519df8bcc2cfd77be7745cc35bebaa54e9f Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 2 Oct 2026 18:09:21 +0300 Subject: [PATCH 5/5] test(cmd): drain captured stdout while the function runs so large output cannot block on Windows --- cmd/mcpproxy/doctor_quarantine_test.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/cmd/mcpproxy/doctor_quarantine_test.go b/cmd/mcpproxy/doctor_quarantine_test.go index 9081c0d76..368771351 100644 --- a/cmd/mcpproxy/doctor_quarantine_test.go +++ b/cmd/mcpproxy/doctor_quarantine_test.go @@ -128,12 +128,18 @@ func captureOutput(f func()) string { r, w, _ := os.Pipe() os.Stdout = w + // Drain the pipe while f runs: a write larger than the OS pipe buffer + // (4 KB on Windows) would otherwise block forever. + done := make(chan string) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + done <- buf.String() + }() + f() w.Close() os.Stdout = old - - var buf bytes.Buffer - io.Copy(&buf, r) - return buf.String() + return <-done }