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 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: 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
Expand Down
17 changes: 15 additions & 2 deletions internal/registries/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.<x>` 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 {
Expand All @@ -496,19 +497,31 @@ 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
}
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
Expand Down
28 changes: 28 additions & 0 deletions internal/registries/verified_generic_labels_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading