diff --git a/docs/cli/catalog-commands.md b/docs/cli/catalog-commands.md index b4a54d2f5..f04cdfe96 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: 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, or the entry comes from a trusted Docker or reference source), 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. +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; generic labels such as labs, tools, mcp, dev, app and ai verify only when they exactly equal the repository owner, or the entry comes from a trusted Docker or reference source), 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/internal/registries/catalog.go b/internal/registries/catalog.go index 326a27622..6876e2f59 100644 --- a/internal/registries/catalog.go +++ b/internal/registries/catalog.go @@ -474,7 +474,8 @@ func catalogHitTitle(reg *RegistryEntry, entry ServerEntry) string { // owns the GitHub repository it names as its source (Spec 109 D37.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 equal the repo -// owner, be a whole token of it, or be a ≥ 5 character brand with a ≤ 4 +// owner (a generic label from genericOwnerLabels verifies ONLY on that exact +// equality), 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 { @@ -496,12 +497,21 @@ func publisherOwnsRepo(id, sourceCodeURL string) bool { return ok && domainLabelMatchesOwner(label, repoOwner) } +// genericOwnerLabels are domain labels any publisher can register, so they +// never verify through the token or affix rules (#1466). +var genericOwnerLabels = map[string]bool{ + "labs": true, "tools": true, "mcp": true, "dev": true, "app": true, "ai": true, +} + // 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). +// owner ("notion" of makenotion). Generic labels (genericOwnerLabels: labs, +// tools, mcp, dev, app, ai) are anyone's: they verify only on exact equality +// with the owner, so com.tools cannot borrow acme-tools. The deny-list applies +// to the namespace label, not to the repo owner's tokens. func domainLabelMatchesOwner(label, repoOwner string) bool { if len(label) < 3 { return false @@ -509,6 +519,9 @@ func domainLabelMatchesOwner(label, repoOwner string) bool { if label == repoOwner { return true } + if genericOwnerLabels[label] { + return false + } for _, tok := range strings.FieldsFunc(repoOwner, func(r rune) bool { return r == '-' || r == '_' }) { if tok == label { return true diff --git a/internal/registries/verified_generic_labels_test.go b/internal/registries/verified_generic_labels_test.go new file mode 100644 index 000000000..d3c3c7c01 --- /dev/null +++ b/internal/registries/verified_generic_labels_test.go @@ -0,0 +1,28 @@ +package registries + +import "testing" + +// Generic domain labels (labs, tools, mcp, dev, app, ai) must verify only on an +// exact repo-owner match, never through the token or affix rules (#1466). +func TestPublisherOwnsRepo_GenericLabels(t *testing.T) { + cases := []struct { + id, url string + want bool + }{ + {"com.tools/x", "https://github.com/acme-tools/x", false}, + {"com.mcp/x", "https://github.com/mcp-acme/x", false}, + {"io.labs/x", "https://github.com/acme-labs/x", false}, + {"com.dev/x", "https://github.com/dev_corp/x", false}, + {"com.app/x", "https://github.com/myapp/x", false}, + {"com.app/x", "https://github.com/app-corp/x", false}, + {"com.ai/x", "https://github.com/acme-ai/x", false}, + {"com.tools/x", "https://github.com/tools/x", true}, + {"com.acme/mcp", "https://github.com/acme-corp/x", true}, + {"com.notion/mcp", "https://github.com/makenotion/x", true}, + } + for _, c := range cases { + if got := publisherOwnsRepo(c.id, c.url); got != c.want { + t.Errorf("publisherOwnsRepo(%q, %q) = %v, want %v", c.id, c.url, got, c.want) + } + } +}