Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1037,3 +1037,4 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—`
| [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | `shipped` | 126/126 (100%) |
| [108-profiles-v3](./specs/108-profiles-v3/) | `in-flight` | 23/153 (15%) |
| [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | `in-flight` | 4/180 (2%) |
| [110-catalog-popularity](./specs/110-catalog-popularity/) | `in-flight` | 19/23 (83%) |
20 changes: 20 additions & 0 deletions cmd/mcpproxy/catalog_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"strings"
"sync"

"github.com/spf13/cobra"

Expand All @@ -14,6 +15,23 @@ import (
"github.com/smart-mcp-proxy/mcpproxy-go/internal/registries"
)

// installMemoryPopularityProviderOnce guards installMemoryPopularityProvider
// so a CLI process that ends up calling it more than once (e.g. 'catalog
// show' after an in-process 'catalog search' fallback) installs a single
// provider rather than leaking one per call.
var installMemoryPopularityProviderOnce sync.Once

// installMemoryPopularityProvider installs a memory-only (no bbolt store)
// popularity provider for the CLI's in-process paths (Spec 110 FR-010): the
// in-process 'catalog search' fallback (no daemon running) and 'catalog
// show'. The core daemon (internal/runtime) installs its own bbolt-backed
// provider instead — this one is never wired there.
func installMemoryPopularityProvider() {
installMemoryPopularityProviderOnce.Do(func() {
registries.SetPopularityProvider(registries.NewGitHubStarsProvider(registries.PopularityOptions{}))
})
}

// printCatalogDeprecationNotice prints the FR-066 deprecation note to
// stderr: 'registry search'/'registry add' remain as aliases (scripts must
// not break), but point at their 'catalog' equivalent.
Expand Down Expand Up @@ -124,6 +142,7 @@ func newCatalogShowCmd() *cobra.Command {
ctx, cancel := registryContext()
defer cancel()
registries.SetRegistriesFromConfig(cfg)
installMemoryPopularityProvider()
reg := registries.FindRegistry(source)
if reg == nil {
return outputError(clioutput.NewStructuredError(clioutput.ErrCodeServerNotFound, fmt.Sprintf("catalog source %q not found", source)).
Expand Down Expand Up @@ -237,6 +256,7 @@ func catalogSearch(ctx context.Context, cfg *config.Config, q, source, tag strin
// returns — which is also what makes it independently testable against a
// fixture list (registries.SetRegistriesForTest).
registries.SetRegistriesFromConfig(cfg)
installMemoryPopularityProvider()
return catalogSearchInProcess(ctx, cfg, q, source, tag, limit)
}

Expand Down
32 changes: 32 additions & 0 deletions docs/features/catalog-popularity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
title: Catalog popularity
sidebar_label: Catalog popularity
description: How MCPProxy ranks catalog results using GitHub stars and Docker Hub pull counts.
---

# Catalog popularity

Catalog popularity uses real, source-native signals. It does not synthesize or
combine counts from different services:

- **GitHub stars** are fetched for catalog entries whose source-code URL points
to a GitHub repository. The provider caches results for 24 hours and uses
ETag revalidation. It allows at most four concurrent requests and applies a
rolling request budget of 50 per hour without a token or 4,000 per hour with
one.
- **Docker Hub installs** come from the `pull_count` field in the
`docker-mcp-catalog` listing. Docker's `star_count` is ignored; it is not
comparable to GitHub stars.

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
to the search response's `unavailable` list.

Set **`MCPPROXY_GITHUB_TOKEN`** to raise the GitHub request budget. The generic
`GITHUB_TOKEN` environment variable is not read for this purpose.

Set **`MCPPROXY_CATALOG_POPULARITY=false`** (or `0`/`off`) to disable outbound
GitHub requests, for example in an offline environment. Docker pull counts
remain available because they are part of the Docker catalog listing already
being fetched.
5 changes: 5 additions & 0 deletions docs/registries.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,11 @@ Because every add surface (MCP, REST, CLI) funnels through the same keystone, a
packages-only server is added as stdio and a remotes-only server as http
identically across all surfaces.

## Catalog popularity signal

Catalog ordering, GitHub stars, Docker pull counts, rate limits, and opt-out
behavior are described in [Catalog popularity](features/catalog-popularity.md).

## Adding a discovered server

See [registry-add.md](features/registry-add.md). New servers are quarantined by
Expand Down
10 changes: 9 additions & 1 deletion internal/httpapi/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,14 @@ func TestCatalogSearch_AddedScopedForNonAdminUserContext(t *testing.T) {
// TestCatalogSearch_EmptyQueryReturnsEmptyResults pins contracts/rest-api.md#catalog:
// "Empty q → results: [], sections: {...}". Before this fix, Results was set
// unconditionally to the ranked hit list even when Sections was populated.
//
// Spec 110 (T014): sections.popular is asserted EMPTY here, not populated —
// this fixture carries no popularity signal at all (no source_code_url, no
// Docker pull_count), and Popular now only ever shows hits with a known
// signal (FR-005/SC-002). Before Spec 110, Popular was just the ranked pool
// re-sorted by a popularity score that was always 0 for everyone, so it
// looked "populated" while actually carrying no real signal — the exact bug
// this spec fixes.
func TestCatalogSearch_EmptyQueryReturnsEmptyResults(t *testing.T) {
withCatalogFixtureRegistry(t)
ctrl := &scopeController{cfg: scopeFixtureConfig(false), servers: catalogFixtureServers(), withManagement: true}
Expand All @@ -270,7 +278,7 @@ func TestCatalogSearch_EmptyQueryReturnsEmptyResults(t *testing.T) {
require.True(t, ok, "expected sections to be populated for an empty q")
popular, ok := sections["popular"].([]interface{})
require.True(t, ok)
assert.NotEmpty(t, popular, "expected the fixture's entries in sections.popular")
assert.Empty(t, popular, "Spec 110 FR-005/SC-002: no popularity signal in this fixture -> sections.popular must be empty")
}

// TestCatalogSearch_SourceFilterAppliesBeforeTruncation is the regression for
Expand Down
Loading
Loading