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
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | 206/207 (100%) |
| [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | `shipped` | 216/217 (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%) |
4 changes: 2 additions & 2 deletions cmd/mcpproxy/catalog_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 14 additions & 6 deletions cmd/mcpproxy/catalog_order_parity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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))
Expand Down
14 changes: 10 additions & 4 deletions cmd/mcpproxy/doctor_quarantine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
2 changes: 1 addition & 1 deletion docs/api/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/cli/catalog-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ mcpproxy catalog search # browse the Official and Popular sec
| `--limit`, `-l <n>` | 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
Expand Down
2 changes: 2 additions & 0 deletions docs/features/catalog-popularity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 29 additions & 2 deletions frontend/src/components/CatalogSearch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,24 @@ async function openPreviouslyAdded(result: CatalogResult): Promise<void> {
: '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).
Expand Down Expand Up @@ -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 D37.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 (D37.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,
Expand Down
4 changes: 4 additions & 0 deletions frontend/tests/unit/add-server-catalog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
119 changes: 119 additions & 0 deletions frontend/tests/unit/catalog-card-signals.spec.ts
Original file line number Diff line number Diff line change
@@ -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 T179 (D37.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: '<div/>' } }, { path: '/servers/:serverName', component: { template: '<div/>' } }],
})

function result(overrides: Record<string, unknown> = {}) {
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<string, unknown>[]) {
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: '<img src=x onerror=alert(1)>' })])
const line = wrapper.find('[data-test="catalog-result-publisher"]')
expect(line.text()).toBe('by <img src=x onerror=alert(1)>')
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)
})
})
6 changes: 5 additions & 1 deletion frontend/tests/unit/catalog-order-parity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
Loading
Loading