Skip to content

Commit 3d3c06d

Browse files
authored
fix(registries): trim default registries to 3 + prune deprecated former-defaults (MCP-1049) (#590)
* fix(registries): trim default registries to 3 + prune deprecated former-defaults (MCP-1049) The shipped default registry set now ships exactly three official/trusted entries: official, reference, docker-mcp-catalog. pulse and smithery are removed from DefaultRegistries() (still addable as custom sources, with their API-key env vars preserved). Because the registries merge keys by id and never prunes, former defaults persisted in an existing config (pulse, smithery, fleur, azure-mcp-demo, remote-mcp-servers) would otherwise resurface forever. Add a one-time, idempotent load migration (config.PruneDeprecatedRegistries, wired into initializeRegistries) that drops the known former-default id set ONLY, never touching genuinely user-added custom registries. SetRegistriesFromConfig also skips those ids during merge as defense-in-depth so a stale in-memory config (hot-reload, direct construction) converges too. Verified: fresh instance -> exactly 3; an existing config carrying all 5 deprecated entries + a custom registry -> converges to 3 defaults + the preserved custom via GET /api/v1/registries (the source the Web UI and macOS app both read). Tests: new internal/config/registry_prune_test.go; extended runtime list_registries_test.go non-leak guard to pulse/smithery/azure/remote plus a persisted-deprecated-convergence case; updated registries unit/integration tests for the trimmed set. Docs: docs/registries.md. * docs(registries): align configuration.md with trimmed 3-default set (MCP-1049) The Registries section still listed Pulse/Docker/Fleur/Azure MCP Registry Demo/Remote MCP Servers as defaults and used a Pulse default example. Update it to the shipped three-default set (official, reference, docker-mcp-catalog), switch the JSON example to a user-added custom source, and document the deprecated former-default prune. Addresses Codex review on PR #590. Related #590
1 parent 206bee3 commit 3d3c06d

9 files changed

Lines changed: 283 additions & 58 deletions

File tree

docs/configuration.md

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -791,17 +791,20 @@ See [Code Execution Documentation](code_execution/overview.md) for complete deta
791791

792792
## Registries
793793

794+
The three default registries ship built-in and require no configuration. Use
795+
the `registries` array only to **add your own** custom source:
796+
794797
```json
795798
{
796799
"registries": [
797800
{
798-
"id": "pulse",
799-
"name": "Pulse MCP",
800-
"description": "Browse and discover MCP use-cases, servers, clients, and news",
801-
"url": "https://www.pulsemcp.com/",
802-
"servers_url": "https://api.pulsemcp.com/v0beta/servers",
803-
"tags": ["verified"],
804-
"protocol": "custom/pulse"
801+
"id": "mycorp",
802+
"name": "My Corp Registry",
803+
"description": "Internal MCP server catalog",
804+
"url": "https://registry.mycorp.example/",
805+
"servers_url": "https://registry.mycorp.example/v0.1/servers",
806+
"tags": ["internal"],
807+
"protocol": "modelcontextprotocol/registry"
805808
}
806809
]
807810
}
@@ -818,14 +821,14 @@ See [Code Execution Documentation](code_execution/overview.md) for complete deta
818821
| `protocol` | string | Registry protocol type |
819822
| `count` | number/string | Number of servers in registry (auto-populated) |
820823

821-
**Default Registries:**
822-
- Pulse MCP
823-
- Docker MCP Catalog
824-
- Fleur
825-
- Azure MCP Registry Demo
826-
- Remote MCP Servers
824+
**Default Registries** (shipped built-in, no configuration required):
825+
- `official` — Official MCP Registry (`modelcontextprotocol/registry`): primary, zero-config aggregator
826+
- `reference` — Reference Servers (`builtin/reference`): curated `@modelcontextprotocol` servers, shipped in-binary so the basics work offline
827+
- `docker-mcp-catalog` — Docker MCP Catalog (`custom/docker`): signed-container MCP server inventory
828+
829+
> **Deprecated former-defaults:** earlier versions also shipped `pulse`, `smithery`, `fleur`, `azure-mcp-demo`, and `remote-mcp-servers` as defaults. These were removed and are pruned from an existing `mcp_config.json` on load, so upgrades converge to the three defaults above. Genuinely user-added custom registries are never touched; `pulse`/`smithery` can be added back as custom sources.
827830
828-
See [Search Servers Documentation](search_servers.md) for complete details.
831+
See [Registries Documentation](registries.md) and [Search Servers Documentation](search_servers.md) for complete details.
829832

830833
---
831834

docs/registries.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,14 @@ available via the `search_servers` / `list_registries` MCP tools, the
1111
| `official` | Official MCP Registry | `modelcontextprotocol/registry` | no | Primary, zero-config aggregator (`registry.modelcontextprotocol.io/v0.1/servers`). |
1212
| `reference` | Reference Servers | `builtin/reference` | no | Curated `@modelcontextprotocol` servers, **shipped in-binary** so the basics work offline. |
1313
| `docker-mcp-catalog` | Docker MCP Catalog | `custom/docker` | no | Signed-container MCP server inventory. |
14-
| `pulse` | Pulse MCP | `custom/pulse` | **yes** | Opt-in; set `MCPPROXY_REGISTRY_PULSE_API_KEY`. |
15-
| `smithery` | Smithery | `modelcontextprotocol/registry` | **yes** | Opt-in; set `MCPPROXY_REGISTRY_SMITHERY_API_KEY`. |
14+
15+
The shipped default set is exactly these **three** official/trusted entries. Earlier
16+
versions also shipped `pulse`, `smithery`, `fleur`, `azure-mcp-demo`, and
17+
`remote-mcp-servers` as defaults; these were removed. They are pruned from an
18+
existing `mcp_config.json` on load (genuinely user-added custom registries are never
19+
touched), so upgrading installs converge to the three above. `pulse` and `smithery`
20+
can still be **added back** as custom sources (see *Adding your own registry source*);
21+
when added they read `MCPPROXY_REGISTRY_PULSE_API_KEY` / `MCPPROXY_REGISTRY_SMITHERY_API_KEY`.
1622

1723
Key-requiring registries are **skipped** (not failed) when no key is configured, so
1824
a default search always succeeds. The API-key env var is
@@ -29,7 +35,7 @@ Every registry carries a **provenance** tag:
2935

3036
| Provenance | Meaning |
3137
|---|---|
32-
| `official/trusted` | A shipped, built-in default (the five above). |
38+
| `official/trusted` | A shipped, built-in default (the three above). |
3339
| `custom/unverified` | Any registry the user added at runtime, or any non-default ID in `mcp_config.json`. |
3440

3541
Trust is **derived, not asserted** — it comes solely from whether the registry ID

internal/config/config.go

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -912,31 +912,54 @@ func DefaultRegistries() []RegistryEntry {
912912
Protocol: "custom/docker",
913913
Provenance: RegistryProvenanceOfficial,
914914
},
915-
{
916-
ID: "pulse",
917-
Name: "Pulse MCP",
918-
Description: "Browse and discover MCP use-cases, servers, clients, and news (opt-in: requires an API key)",
919-
URL: "https://www.pulsemcp.com/",
920-
ServersURL: "https://api.pulsemcp.com/v0.1/servers",
921-
Tags: []string{"verified"},
922-
Protocol: "custom/pulse",
923-
RequiresKey: true,
924-
Provenance: RegistryProvenanceOfficial,
925-
},
926-
{
927-
ID: "smithery",
928-
Name: "Smithery",
929-
Description: "Smithery MCP server registry (opt-in: requires an API key)",
930-
URL: "https://smithery.ai/",
931-
ServersURL: "https://api.smithery.ai/servers",
932-
Tags: []string{"verified"},
933-
Protocol: "modelcontextprotocol/registry",
934-
RequiresKey: true,
935-
Provenance: RegistryProvenanceOfficial,
936-
},
937915
}
938916
}
939917

918+
// deprecatedDefaultRegistryIDs are registry ids that were SHIPPED as built-in
919+
// defaults in earlier versions and have since been removed from
920+
// DefaultRegistries(). Because the registries merge (registry_data.go) keys by id
921+
// and never prunes, a former default persisted in a user's config would otherwise
922+
// resurface forever. They are pruned from the persisted config on load
923+
// (PruneDeprecatedRegistries) and skipped by the merge so the running app
924+
// converges to the trimmed default set (MCP-1049). Genuinely user-added custom
925+
// registries are never in this set, so they are always preserved.
926+
var deprecatedDefaultRegistryIDs = map[string]bool{
927+
"pulse": true,
928+
"smithery": true,
929+
"fleur": true,
930+
"azure-mcp-demo": true,
931+
"remote-mcp-servers": true,
932+
}
933+
934+
// IsDeprecatedDefaultRegistry reports whether id is a known former-default
935+
// registry that was removed from the shipped set and must not be resurrected.
936+
func IsDeprecatedDefaultRegistry(id string) bool {
937+
return deprecatedDefaultRegistryIDs[id]
938+
}
939+
940+
// PruneDeprecatedRegistries removes deprecated former-default registries
941+
// (IsDeprecatedDefaultRegistry) from cfg.Registries in place and returns the
942+
// number removed. It is idempotent and matches by the known former-default id set
943+
// ONLY, so a genuinely user-added custom registry is never dropped.
944+
func PruneDeprecatedRegistries(cfg *Config) int {
945+
if cfg == nil || len(cfg.Registries) == 0 {
946+
return 0
947+
}
948+
kept := make([]RegistryEntry, 0, len(cfg.Registries))
949+
removed := 0
950+
for _, r := range cfg.Registries {
951+
if IsDeprecatedDefaultRegistry(r.ID) {
952+
removed++
953+
continue
954+
}
955+
kept = append(kept, r)
956+
}
957+
if removed > 0 {
958+
cfg.Registries = kept
959+
}
960+
return removed
961+
}
962+
940963
// DefaultConfig returns a default configuration
941964
func DefaultConfig() *Config {
942965
return &Config{

internal/config/loader.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,12 @@ func createDefaultConfigFile(path string, cfg *Config) error {
485485

486486
// initializeRegistries initializes the registries package with config data
487487
func initializeRegistries(cfg *Config) {
488+
// One-time migration (MCP-1049): drop former-default registries that were
489+
// trimmed from the shipped set so an existing config converges to the current
490+
// defaults instead of resurrecting them on every load. Idempotent and only
491+
// touches the known former-default id set, never user-added customs.
492+
PruneDeprecatedRegistries(cfg)
493+
488494
// This function will be implemented to avoid circular imports
489495
// For now, we'll create a callback mechanism
490496
if registriesInitCallback != nil {
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package config
2+
3+
import (
4+
"sort"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
// MCP-1049: the shipped default registry set is trimmed to exactly three
12+
// official/trusted entries, and former-default registries that were removed from
13+
// the shipped set are pruned from a user's persisted config on load (the merge in
14+
// registry_data.go keys by id and never prunes, so without this they live forever
15+
// in ~/.mcpproxy/config.json).
16+
17+
func TestDefaultRegistries_ExactlyThree(t *testing.T) {
18+
defaults := DefaultRegistries()
19+
ids := make([]string, 0, len(defaults))
20+
for _, r := range defaults {
21+
ids = append(ids, r.ID)
22+
}
23+
sort.Strings(ids)
24+
assert.Equal(t, []string{"docker-mcp-catalog", "official", "reference"}, ids,
25+
"the shipped default registry set must be exactly official/reference/docker-mcp-catalog")
26+
}
27+
28+
func TestDefaultRegistries_DoesNotShipDeprecated(t *testing.T) {
29+
for _, r := range DefaultRegistries() {
30+
assert.Falsef(t, IsDeprecatedDefaultRegistry(r.ID),
31+
"shipped default %q must not also be in the deprecated former-default set", r.ID)
32+
}
33+
}
34+
35+
func TestIsDeprecatedDefaultRegistry(t *testing.T) {
36+
for _, id := range []string{"pulse", "smithery", "fleur", "azure-mcp-demo", "remote-mcp-servers"} {
37+
assert.Truef(t, IsDeprecatedDefaultRegistry(id), "%q must be a known deprecated former-default", id)
38+
}
39+
for _, id := range []string{"official", "reference", "docker-mcp-catalog", "my-custom-registry", ""} {
40+
assert.Falsef(t, IsDeprecatedDefaultRegistry(id), "%q must NOT be flagged deprecated", id)
41+
}
42+
}
43+
44+
func TestPruneDeprecatedRegistries_RemovesFormerDefaultsPreservesCustom(t *testing.T) {
45+
cfg := &Config{
46+
Registries: []RegistryEntry{
47+
{ID: "official", Name: "Official MCP Registry"},
48+
{ID: "reference", Name: "Reference Servers"},
49+
{ID: "docker-mcp-catalog", Name: "Docker MCP Catalog"},
50+
{ID: "pulse", Name: "Pulse MCP"},
51+
{ID: "smithery", Name: "Smithery"},
52+
{ID: "fleur", Name: "Fleur"},
53+
{ID: "azure-mcp-demo", Name: "Azure MCP Registry Demo"},
54+
{ID: "remote-mcp-servers", Name: "Remote MCP Servers"},
55+
{ID: "my-custom-registry", Name: "My Custom Registry"},
56+
},
57+
}
58+
59+
removed := PruneDeprecatedRegistries(cfg)
60+
assert.Equal(t, 5, removed, "all five deprecated former-defaults must be removed")
61+
62+
ids := make([]string, 0, len(cfg.Registries))
63+
for _, r := range cfg.Registries {
64+
ids = append(ids, r.ID)
65+
}
66+
sort.Strings(ids)
67+
assert.Equal(t, []string{"docker-mcp-catalog", "my-custom-registry", "official", "reference"}, ids,
68+
"prune must keep the 3 current defaults plus the genuinely user-added custom registry")
69+
}
70+
71+
func TestPruneDeprecatedRegistries_Idempotent(t *testing.T) {
72+
cfg := &Config{
73+
Registries: []RegistryEntry{
74+
{ID: "official"},
75+
{ID: "pulse"},
76+
{ID: "my-custom-registry"},
77+
},
78+
}
79+
first := PruneDeprecatedRegistries(cfg)
80+
require.Equal(t, 1, first)
81+
second := PruneDeprecatedRegistries(cfg)
82+
assert.Equal(t, 0, second, "a second prune must be a no-op (idempotent)")
83+
assert.Len(t, cfg.Registries, 2)
84+
}
85+
86+
func TestPruneDeprecatedRegistries_NilAndEmptySafe(t *testing.T) {
87+
assert.Equal(t, 0, PruneDeprecatedRegistries(nil))
88+
assert.Equal(t, 0, PruneDeprecatedRegistries(&Config{}))
89+
}
90+
91+
// initializeRegistries runs on every config load; it must prune the persisted
92+
// deprecated entries so an existing install converges to the trimmed set.
93+
func TestInitializeRegistries_PrunesDeprecatedOnLoad(t *testing.T) {
94+
cfg := &Config{
95+
Registries: []RegistryEntry{
96+
{ID: "official"},
97+
{ID: "pulse"},
98+
{ID: "smithery"},
99+
{ID: "fleur"},
100+
{ID: "azure-mcp-demo"},
101+
{ID: "remote-mcp-servers"},
102+
{ID: "my-custom-registry"},
103+
},
104+
}
105+
initializeRegistries(cfg)
106+
107+
ids := make([]string, 0, len(cfg.Registries))
108+
for _, r := range cfg.Registries {
109+
ids = append(ids, r.ID)
110+
}
111+
sort.Strings(ids)
112+
assert.Equal(t, []string{"my-custom-registry", "official"}, ids,
113+
"load must prune deprecated former-defaults from the persisted config")
114+
}

internal/registries/integration_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,9 @@ func TestCompleteWorkflow(t *testing.T) {
146146
t.Error("expected default registries, got none")
147147
}
148148

149-
// Verify we have the expected default registries (official + reference
150-
// primary, Docker kept, Pulse opt-in) per MCP-865.
151-
expectedIDs := []string{"official", "reference", "docker-mcp-catalog", "pulse"}
149+
// Verify we have the trimmed default registry set (MCP-1049): exactly the
150+
// three official/trusted entries.
151+
expectedIDs := []string{"official", "reference", "docker-mcp-catalog"}
152152
found := make(map[string]bool)
153153

154154
for _, reg := range registries {
@@ -163,11 +163,11 @@ func TestCompleteWorkflow(t *testing.T) {
163163
})
164164

165165
t.Run("find registry by name", func(t *testing.T) {
166-
reg := FindRegistry("Pulse MCP")
166+
reg := FindRegistry("Docker MCP Catalog")
167167
if reg == nil {
168-
t.Error("expected to find Pulse MCP registry")
169-
} else if reg.ID != "pulse" {
170-
t.Errorf("expected ID 'pulse', got '%s'", reg.ID)
168+
t.Error("expected to find Docker MCP Catalog registry")
169+
} else if reg.ID != "docker-mcp-catalog" {
170+
t.Errorf("expected ID 'docker-mcp-catalog', got '%s'", reg.ID)
171171
}
172172
})
173173

internal/registries/registry_data.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ func SetRegistriesFromConfig(cfg *config.Config) {
4545
}
4646
if cfg != nil {
4747
for i := range cfg.Registries {
48+
// MCP-1049: never resurface a deprecated former-default registry that
49+
// is still persisted in an existing config. The load-time prune
50+
// (config.PruneDeprecatedRegistries) cleans the persisted slice, and
51+
// this skip guarantees convergence even for a stale in-memory config
52+
// (hot-reload, direct construction). A genuine custom registry is never
53+
// in the deprecated set, so it is always merged.
54+
if config.IsDeprecatedDefaultRegistry(cfg.Registries[i].ID) {
55+
continue
56+
}
4857
upsert(fromConfigEntry(&cfg.Registries[i]))
4958
}
5059
}

internal/registries/registry_data_test.go

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,13 @@ import (
88

99
// defaultRegistryIDs are the built-in registries shipped in
1010
// config.DefaultConfig(). A user-supplied config must MERGE with these, not
11-
// replace them (FR-006). The set was standardized on the official MCP registry
12-
// protocol (MCP-865): official + built-in reference primary, Docker kept, Pulse
13-
// and Smithery demoted to opt-in.
11+
// replace them (FR-006). The shipped set was trimmed to exactly three
12+
// official/trusted entries (MCP-1049): the official MCP registry, the built-in
13+
// reference servers, and the Docker MCP catalog. Pulse and Smithery were removed.
1414
var defaultRegistryIDs = []string{
1515
"official",
1616
"reference",
1717
"docker-mcp-catalog",
18-
"pulse",
19-
"smithery",
2018
}
2119

2220
func registryIDSet(t *testing.T) map[string]RegistryEntry {
@@ -57,7 +55,7 @@ func TestSetRegistriesFromConfig_MergesCustomWithDefaults(t *testing.T) {
5755
func TestSetRegistriesFromConfig_CustomOverridesDefaultByID(t *testing.T) {
5856
cfg := &config.Config{
5957
Registries: []config.RegistryEntry{
60-
{ID: "pulse", Name: "Pulse OVERRIDDEN", ServersURL: "https://override.example/servers"},
58+
{ID: "official", Name: "Official OVERRIDDEN", ServersURL: "https://override.example/servers"},
6159
},
6260
}
6361

@@ -67,8 +65,38 @@ func TestSetRegistriesFromConfig_CustomOverridesDefaultByID(t *testing.T) {
6765
if len(got) != len(defaultRegistryIDs) {
6866
t.Errorf("override should not change registry count: want %d got %d", len(defaultRegistryIDs), len(got))
6967
}
70-
if got["pulse"].Name != "Pulse OVERRIDDEN" {
71-
t.Errorf("colliding-ID config entry did not override default: got name %q", got["pulse"].Name)
68+
if got["official"].Name != "Official OVERRIDDEN" {
69+
t.Errorf("colliding-ID config entry did not override default: got name %q", got["official"].Name)
70+
}
71+
}
72+
73+
// MCP-1049: a deprecated former-default registry still persisted in config must
74+
// NOT resurface in the merged list, while a genuine custom registry is kept.
75+
func TestSetRegistriesFromConfig_SkipsDeprecatedFormerDefaults(t *testing.T) {
76+
cfg := &config.Config{
77+
Registries: []config.RegistryEntry{
78+
{ID: "pulse", Name: "Pulse MCP"},
79+
{ID: "smithery", Name: "Smithery"},
80+
{ID: "fleur", Name: "Fleur"},
81+
{ID: "azure-mcp-demo", Name: "Azure MCP Registry Demo"},
82+
{ID: "remote-mcp-servers", Name: "Remote MCP Servers"},
83+
{ID: "mycorp", Name: "My Corp Registry", ServersURL: "https://reg.mycorp.example/servers"},
84+
},
85+
}
86+
87+
SetRegistriesFromConfig(cfg)
88+
89+
got := registryIDSet(t)
90+
for _, gone := range []string{"pulse", "smithery", "fleur", "azure-mcp-demo", "remote-mcp-servers"} {
91+
if _, ok := got[gone]; ok {
92+
t.Errorf("deprecated former-default %q must not resurface in the merged list", gone)
93+
}
94+
}
95+
if _, ok := got["mycorp"]; !ok {
96+
t.Errorf("genuine custom registry %q must be preserved", "mycorp")
97+
}
98+
if len(got) != len(defaultRegistryIDs)+1 {
99+
t.Errorf("expected %d registries (3 defaults + 1 custom), got %d", len(defaultRegistryIDs)+1, len(got))
72100
}
73101
}
74102

0 commit comments

Comments
 (0)