Skip to content

Commit 795bd7f

Browse files
feat(registries): MCP-866 trust/provenance model foundation (config + merge)
Foundation layer for user-added registries. CLI add-source, server-add-time stamping/enforcement, REST/MCP surface, and docs follow in subsequent commits. - Add registry provenance trust tags (official/trusted vs custom/unverified) to both config and registries RegistryEntry, with IsTrusted() helpers. - DefaultRegistries are tagged official/trusted. - SetRegistriesFromConfig recomputes provenance AUTHORITATIVELY by ID: a shipped-default ID is always official; anything user-added is custom/unverified — a user cannot self-assert trust via config. - ServerConfig gains SourceRegistryID + SourceRegistryProvenance so a server's origin is recorded for the approval/quarantine view. - Config validation rejects skip_quarantine for servers sourced from a custom/unverified registry (quarantine-always; no user allowlist). - RegistriesLocked enterprise stub knob (doc + add-source rejection only). - Tests: provenance JSON round-trip, authoritative merge recompute (incl. rejecting self-asserted trust), skip_quarantine rejection/allowance. config + registries suites green with -race; golangci-lint 0 issues. Refs MCP-866. Co-Authored-By: Paperclip <noreply@paperclip.ing>
1 parent c2f1f9d commit 795bd7f

7 files changed

Lines changed: 228 additions & 2 deletions

File tree

internal/config/config.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,13 @@ type Config struct {
101101
// Registries configuration for MCP server discovery
102102
Registries []RegistryEntry `json:"registries,omitempty" mapstructure:"registries"`
103103

104+
// RegistriesLocked is an enterprise stub knob (MCP-866): when true, runtime
105+
// additions of custom registries (e.g. `registry add-source`, the REST/MCP
106+
// add-source surface) are rejected so an administrator can pin the discovery
107+
// sources. Built-in defaults are unaffected. Documented but otherwise inert
108+
// beyond the add-source rejection.
109+
RegistriesLocked bool `json:"registries_locked,omitempty" mapstructure:"registries-locked"`
110+
104111
// Deprecated: Features flags are unused and have no runtime effect. Kept for backward compatibility.
105112
Features *FeatureFlags `json:"features,omitempty" mapstructure:"features"`
106113

@@ -251,6 +258,16 @@ type ServerConfig struct {
251258
LauncherWaitTimeout Duration `json:"launcher_wait_timeout,omitempty" mapstructure:"launcher_wait_timeout" swaggertype:"string"`
252259
EnabledTools []string `json:"enabled_tools,omitempty" mapstructure:"enabled_tools"` // Allowlist: only these tools are exposed; mutually exclusive with disabled_tools
253260
DisabledTools []string `json:"disabled_tools,omitempty" mapstructure:"disabled_tools"` // Denylist: these tools are hidden; mutually exclusive with enabled_tools
261+
262+
// SourceRegistryID records which registry this server was added from (empty
263+
// for manually-configured servers). MCP-866: surfaced in the approval /
264+
// quarantine view so a reviewer can see a server's origin.
265+
SourceRegistryID string `json:"source_registry_id,omitempty" mapstructure:"source_registry_id"`
266+
// SourceRegistryProvenance is the trust tag of the source registry at add
267+
// time (RegistryProvenanceOfficial / RegistryProvenanceCustom). When it is
268+
// RegistryProvenanceCustom, skip_quarantine is forbidden — a custom,
269+
// unverified registry can never opt its servers out of quarantine.
270+
SourceRegistryProvenance string `json:"source_registry_provenance,omitempty" mapstructure:"source_registry_provenance"`
254271
}
255272

256273
// OAuthConfig represents OAuth configuration for a server
@@ -632,6 +649,18 @@ func (c *OutputSanitisationConfig) WouldMutate(trust string) bool {
632649
return trust == "untrusted" && (c.IsStripEnabled() || c.IsSpotlightEnabled())
633650
}
634651

652+
// Registry provenance tags (MCP-866). Trust is derived, not user-asserted: a
653+
// registry is "trusted" only when it is one of the shipped built-in defaults.
654+
// Anything a user adds at runtime (e.g. via `registry add-source`) is
655+
// "custom/unverified" and can NEVER skip quarantine — there is no allowlist a
656+
// user can append themselves into.
657+
const (
658+
// RegistryProvenanceOfficial marks a built-in, shipped-by-default registry.
659+
RegistryProvenanceOfficial = "official/trusted"
660+
// RegistryProvenanceCustom marks a user-added registry of unknown trust.
661+
RegistryProvenanceCustom = "custom/unverified"
662+
)
663+
635664
// RegistryEntry represents a registry in the configuration
636665
type RegistryEntry struct {
637666
ID string `json:"id"`
@@ -646,6 +675,19 @@ type RegistryEntry struct {
646675
// true and no key is configured, the registry is skipped/marked unavailable
647676
// rather than failing the whole search (FR-008).
648677
RequiresKey bool `json:"requires_key,omitempty"`
678+
// Provenance is the trust tag for this registry (MCP-866):
679+
// RegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom
680+
// for user-added registries. It is authoritatively (re)computed by the
681+
// registries merge from whether the ID is a shipped default — a user cannot
682+
// claim "official/trusted" by writing it into their config.
683+
Provenance string `json:"provenance,omitempty" mapstructure:"provenance"`
684+
}
685+
686+
// IsTrusted reports whether the registry is an official, shipped-by-default
687+
// source. Trust is never granted by omission — an absent provenance tag is
688+
// untrusted.
689+
func (r *RegistryEntry) IsTrusted() bool {
690+
return r != nil && r.Provenance == RegistryProvenanceOfficial
649691
}
650692

651693
// CursorMCPConfig represents the structure for Cursor IDE MCP configuration
@@ -847,6 +889,7 @@ func DefaultRegistries() []RegistryEntry {
847889
ServersURL: "https://registry.modelcontextprotocol.io/v0.1/servers",
848890
Tags: []string{"verified", "official"},
849891
Protocol: "modelcontextprotocol/registry",
892+
Provenance: RegistryProvenanceOfficial,
850893
},
851894
{
852895
ID: "reference",
@@ -856,6 +899,7 @@ func DefaultRegistries() []RegistryEntry {
856899
ServersURL: "builtin://reference",
857900
Tags: []string{"verified", "official", "reference"},
858901
Protocol: "builtin/reference",
902+
Provenance: RegistryProvenanceOfficial,
859903
},
860904
{
861905
ID: "docker-mcp-catalog",
@@ -865,6 +909,7 @@ func DefaultRegistries() []RegistryEntry {
865909
ServersURL: "https://hub.docker.com/v2/repositories/mcp/",
866910
Tags: []string{"verified"},
867911
Protocol: "custom/docker",
912+
Provenance: RegistryProvenanceOfficial,
868913
},
869914
{
870915
ID: "pulse",
@@ -875,6 +920,7 @@ func DefaultRegistries() []RegistryEntry {
875920
Tags: []string{"verified"},
876921
Protocol: "custom/pulse",
877922
RequiresKey: true,
923+
Provenance: RegistryProvenanceOfficial,
878924
},
879925
{
880926
ID: "smithery",
@@ -885,6 +931,7 @@ func DefaultRegistries() []RegistryEntry {
885931
Tags: []string{"verified"},
886932
Protocol: "modelcontextprotocol/registry",
887933
RequiresKey: true,
934+
Provenance: RegistryProvenanceOfficial,
888935
},
889936
}
890937
}
@@ -1247,6 +1294,16 @@ func (c *Config) ValidateDetailed() []ValidationError {
12471294
Message: "enabled_tools and disabled_tools are mutually exclusive; use one or the other",
12481295
})
12491296
}
1297+
1298+
// MCP-866: a server sourced from a custom/unverified registry can NEVER
1299+
// skip quarantine. There is no allowlist a user can add themselves into,
1300+
// so an unverified third-party source must always be reviewed.
1301+
if server.SkipQuarantine && server.SourceRegistryProvenance == RegistryProvenanceCustom {
1302+
errors = append(errors, ValidationError{
1303+
Field: fieldPrefix + ".skip_quarantine",
1304+
Message: "skip_quarantine is not allowed for a server added from a custom/unverified registry",
1305+
})
1306+
}
12501307
}
12511308

12521309
// Validate DataDir exists (if specified and not empty).
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package config
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
// MCP-866: provenance/trust model for registries.
12+
13+
func TestDefaultRegistries_AreOfficialTrusted(t *testing.T) {
14+
for _, r := range DefaultRegistries() {
15+
assert.Equalf(t, RegistryProvenanceOfficial, r.Provenance,
16+
"built-in default %q must be tagged official/trusted", r.ID)
17+
assert.Truef(t, r.IsTrusted(), "built-in default %q must report IsTrusted", r.ID)
18+
}
19+
}
20+
21+
func TestRegistryEntry_IsTrusted(t *testing.T) {
22+
assert.True(t, (&RegistryEntry{Provenance: RegistryProvenanceOfficial}).IsTrusted())
23+
assert.False(t, (&RegistryEntry{Provenance: RegistryProvenanceCustom}).IsTrusted())
24+
// Absent provenance is NOT trusted — never grant trust by omission.
25+
assert.False(t, (&RegistryEntry{}).IsTrusted())
26+
}
27+
28+
func TestRegistryEntry_ProvenanceJSONRoundTrip(t *testing.T) {
29+
in := RegistryEntry{ID: "acme", Name: "Acme", URL: "https://acme.example/", Provenance: RegistryProvenanceCustom}
30+
b, err := json.Marshal(in)
31+
require.NoError(t, err)
32+
assert.Contains(t, string(b), `"provenance":"custom/unverified"`)
33+
34+
var out RegistryEntry
35+
require.NoError(t, json.Unmarshal(b, &out))
36+
assert.Equal(t, RegistryProvenanceCustom, out.Provenance)
37+
}
38+
39+
// A server added from a custom/unverified registry must never carry
40+
// skip_quarantine=true; config validation rejects it (FR: quarantine-always).
41+
func TestValidate_RejectsSkipQuarantineForCustomOriginServer(t *testing.T) {
42+
cfg := DefaultConfig()
43+
cfg.Servers = []*ServerConfig{
44+
{
45+
Name: "evil",
46+
Protocol: "stdio",
47+
Command: "npx",
48+
Enabled: true,
49+
SkipQuarantine: true,
50+
SourceRegistryID: "acme",
51+
SourceRegistryProvenance: RegistryProvenanceCustom,
52+
},
53+
}
54+
err := cfg.Validate()
55+
require.Error(t, err)
56+
assert.Contains(t, err.Error(), "skip_quarantine")
57+
}
58+
59+
// skip_quarantine remains allowed for trusted/official-origin servers and for
60+
// servers with no registry origin (manually-added), to avoid breaking existing
61+
// configs.
62+
func TestValidate_AllowsSkipQuarantineForTrustedOrigin(t *testing.T) {
63+
cfg := DefaultConfig()
64+
cfg.Servers = []*ServerConfig{
65+
{Name: "ok-trusted", Protocol: "stdio", Command: "npx", Enabled: true, SkipQuarantine: true, SourceRegistryProvenance: RegistryProvenanceOfficial},
66+
{Name: "ok-manual", Protocol: "stdio", Command: "npx", Enabled: true, SkipQuarantine: true},
67+
}
68+
assert.NoError(t, cfg.Validate())
69+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package registries
2+
3+
import (
4+
"testing"
5+
6+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
// MCP-866: provenance is computed authoritatively at merge time. A built-in
12+
// default is official/trusted; any user-added registry is custom/unverified,
13+
// and a user CANNOT claim trust by writing "official/trusted" into their config.
14+
func TestSetRegistriesFromConfig_ComputesProvenance(t *testing.T) {
15+
cfg := config.DefaultConfig()
16+
cfg.Registries = []config.RegistryEntry{
17+
{
18+
ID: "acme",
19+
Name: "Acme Registry",
20+
URL: "https://acme.example/",
21+
ServersURL: "https://acme.example/v0.1/servers",
22+
Protocol: "modelcontextprotocol/registry",
23+
// Malicious attempt to self-assert trust — must be ignored.
24+
Provenance: config.RegistryProvenanceOfficial,
25+
},
26+
}
27+
28+
SetRegistriesFromConfig(cfg)
29+
30+
acme := FindRegistry("acme")
31+
require.NotNil(t, acme)
32+
assert.Equal(t, config.RegistryProvenanceCustom, acme.Provenance,
33+
"user-added registry must be custom/unverified regardless of self-asserted provenance")
34+
assert.False(t, acme.IsTrusted())
35+
36+
official := FindRegistry("official")
37+
require.NotNil(t, official)
38+
assert.Equal(t, config.RegistryProvenanceOfficial, official.Provenance)
39+
assert.True(t, official.IsTrusted())
40+
}
41+
42+
func TestRegistryEntry_IsTrusted(t *testing.T) {
43+
assert.True(t, (&RegistryEntry{Provenance: config.RegistryProvenanceOfficial}).IsTrusted())
44+
assert.False(t, (&RegistryEntry{Provenance: config.RegistryProvenanceCustom}).IsTrusted())
45+
assert.False(t, (&RegistryEntry{}).IsTrusted())
46+
}

internal/registries/registry_data.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,22 @@ func SetRegistriesFromConfig(cfg *config.Config) {
1616
index := make(map[string]int) // ID -> position in merged
1717
merged := make([]RegistryEntry, 0, len(config.DefaultRegistries()))
1818

19+
// Trust is derived from membership in the shipped default set (MCP-866), not
20+
// from any provenance a user wrote into their config — so a custom registry
21+
// can never claim "official/trusted", and an override of a default ID keeps
22+
// its trusted status (e.g. attaching an API key to a built-in registry).
23+
defaults := config.DefaultRegistries()
24+
defaultIDs := make(map[string]bool, len(defaults))
25+
for i := range defaults {
26+
defaultIDs[defaults[i].ID] = true
27+
}
28+
1929
upsert := func(r RegistryEntry) {
30+
if defaultIDs[r.ID] {
31+
r.Provenance = config.RegistryProvenanceOfficial
32+
} else {
33+
r.Provenance = config.RegistryProvenanceCustom
34+
}
2035
if pos, ok := index[r.ID]; ok {
2136
merged[pos] = r
2237
return
@@ -25,7 +40,6 @@ func SetRegistriesFromConfig(cfg *config.Config) {
2540
merged = append(merged, r)
2641
}
2742

28-
defaults := config.DefaultRegistries()
2943
for i := range defaults {
3044
upsert(fromConfigEntry(&defaults[i]))
3145
}
@@ -38,6 +52,12 @@ func SetRegistriesFromConfig(cfg *config.Config) {
3852
registryList = merged
3953
}
4054

55+
// IsTrusted reports whether this is an official, shipped-by-default registry.
56+
// Trust is never granted by omission — an absent provenance tag is untrusted.
57+
func (r *RegistryEntry) IsTrusted() bool {
58+
return r != nil && r.Provenance == config.RegistryProvenanceOfficial
59+
}
60+
4161
// fromConfigEntry converts a config.RegistryEntry to a registries.RegistryEntry.
4262
func fromConfigEntry(r *config.RegistryEntry) RegistryEntry {
4363
return RegistryEntry{

internal/registries/types.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ type RegistryEntry struct {
1717
// ErrRegistryKeyMissing so the calling surface can mark it unavailable
1818
// instead of failing the whole search (FR-008).
1919
RequiresKey bool `json:"requires_key,omitempty"`
20+
// Provenance is the trust tag (MCP-866): config.RegistryProvenanceOfficial
21+
// for built-in defaults, config.RegistryProvenanceCustom for user-added
22+
// registries. Computed authoritatively by SetRegistriesFromConfig — never
23+
// trusted by self-assertion.
24+
Provenance string `json:"provenance,omitempty"`
2025
}
2126

2227
// ServerEntry represents an MCP server discovered via a registry

oas/docs.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

oas/swagger.yaml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,14 @@ components:
109109
$ref: '#/components/schemas/config.RegistryEntry'
110110
type: array
111111
uniqueItems: false
112+
registries_locked:
113+
description: |-
114+
RegistriesLocked is an enterprise stub knob (MCP-866): when true, runtime
115+
additions of custom registries (e.g. `registry add-source`, the REST/MCP
116+
add-source surface) are rejected so an administrator can pin the discovery
117+
sources. Built-in defaults are unaffected. Documented but otherwise inert
118+
beyond the add-source rejection.
119+
type: boolean
112120
require_mcp_auth:
113121
description: 'Require authentication on /mcp endpoint (default: false)'
114122
type: boolean
@@ -461,6 +469,14 @@ components:
461469
type: string
462470
protocol:
463471
type: string
472+
provenance:
473+
description: |-
474+
Provenance is the trust tag for this registry (MCP-866):
475+
RegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom
476+
for user-added registries. It is authoritatively (re)computed by the
477+
registries merge from whether the ID is a shipped default — a user cannot
478+
claim "official/trusted" by writing it into their config.
479+
type: string
464480
requires_key:
465481
description: |-
466482
RequiresKey marks a registry that needs an API key to be queried. When
@@ -616,6 +632,19 @@ components:
616632
skip_quarantine:
617633
description: Skip tool-level quarantine for this server
618634
type: boolean
635+
source_registry_id:
636+
description: |-
637+
SourceRegistryID records which registry this server was added from (empty
638+
for manually-configured servers). MCP-866: surfaced in the approval /
639+
quarantine view so a reviewer can see a server's origin.
640+
type: string
641+
source_registry_provenance:
642+
description: |-
643+
SourceRegistryProvenance is the trust tag of the source registry at add
644+
time (RegistryProvenanceOfficial / RegistryProvenanceCustom). When it is
645+
RegistryProvenanceCustom, skip_quarantine is forbidden — a custom,
646+
unverified registry can never opt its servers out of quarantine.
647+
type: string
619648
updated:
620649
type: string
621650
url:

0 commit comments

Comments
 (0)