Skip to content

Commit eba1787

Browse files
feat(registries): add-source CLI + REST/MCP surface + quarantine enforcement (MCP-866)
Builds on the provenance foundation to let users add their own MCP registry sources, always tagged custom/unverified so their servers can never escape quarantine. There is no allowlist a user can add themselves into. - `mcpproxy registry add-source <https-url> [--protocol|--id|--name]`: daemon-first CLI that adds a generic modelcontextprotocol/registry v0.1 endpoint. Writes cfg.Registries copy-on-write via UpdateConfig + persists, and rebuilds the effective catalog so the source is immediately searchable. - Server keystone (add_from_registry): stamp SourceRegistryID/Provenance onto the derived ServerConfig from the resolved registry; a custom/unverified source forces Quarantined=true and SkipQuarantine=false regardless of the global default (CN-002 extended). - New add-source op (add_registry_source.go): pure URL→entry derivation (https validation, id-from-host slug, v0.1 servers-url derivation) + guardrails (registries_locked, no shadowing a built-in id, no duplicate). Stable cross-surface error codes: invalid_registry_url / registries_locked / registry_shadows_builtin / duplicate_registry. - REST POST /api/v1/registries; cliclient.AddRegistrySource; provenance + trusted surfaced in list_registries across runtime REST + MCP so a UI can show the one-time third-party-registry warning. - Docs: docs/registries.md trust model + add-source + registries_locked stub. - OpenAPI regenerated. TDD: add-source derivation/validation unit tests, custom-origin quarantine-always keystone tests, and a registries integration test proving a user-added v0.1 endpoint is searchable AND tagged custom/unverified. Local: go build ./..., config/registries/server/httpapi/cliclient/contracts/cmd suites green (-race on the pure-logic packages), binary API + MCP e2e green, golangci-lint 0 issues, approval-hash stability canary green. Related MCP-866 Co-Authored-By: Paperclip <noreply@paperclip.ing>
1 parent 795bd7f commit eba1787

17 files changed

Lines changed: 766 additions & 16 deletions

cmd/mcpproxy/registry_cmd.go

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,15 @@ import (
2121

2222
// Registry command flags (spec 070).
2323
var (
24-
registryConfigPath string
25-
registrySearchTag string
26-
registryLimit int
27-
registryAddName string
28-
registryAddEnv []string
29-
registryAddEnabled bool
24+
registryConfigPath string
25+
registrySearchTag string
26+
registryLimit int
27+
registryAddName string
28+
registryAddEnv []string
29+
registryAddEnabled bool
30+
registryAddSourceProto string // MCP-866
31+
registryAddSourceID string
32+
registryAddSourceName string
3033
)
3134

3235
// GetRegistryCommand builds the `registry` command group (spec 070): a single
@@ -53,12 +56,79 @@ Typical flow:
5356
mcpproxy registry add pulse weather-mcp # add it (quarantined)
5457
mcpproxy upstream approve weather-mcp # approve once you trust it
5558
56-
'registry add' talks to the running mcpproxy daemon. 'list' and 'search' use the
57-
daemon when available and otherwise read the registries directly.`,
59+
Add your own registry source (any official modelcontextprotocol/registry v0.1 endpoint):
60+
mcpproxy registry add-source https://registry.example.com # custom/unverified
61+
62+
'registry add' and 'registry add-source' talk to the running mcpproxy daemon.
63+
'list' and 'search' use the daemon when available and otherwise read the
64+
registries directly.`,
5865
}
5966

6067
cmd.PersistentFlags().StringVarP(&registryConfigPath, "config", "c", "", "Path to MCP configuration file")
61-
cmd.AddCommand(newRegistryListCmd(), newRegistrySearchCmd(), newRegistryAddCmd())
68+
cmd.AddCommand(newRegistryListCmd(), newRegistrySearchCmd(), newRegistryAddCmd(), newRegistryAddSourceCmd())
69+
return cmd
70+
}
71+
72+
func newRegistryAddSourceCmd() *cobra.Command {
73+
cmd := &cobra.Command{
74+
Use: "add-source <https-url>",
75+
Short: "Add a custom MCP registry source (quarantine-always)",
76+
Long: `Add your own MCP server registry — any https endpoint implementing the
77+
official modelcontextprotocol/registry v0.1 protocol (the same protocol shipped
78+
by Copilot/VS Code/Azure).
79+
80+
The added source is ALWAYS tagged custom/unverified: there is no allowlist you
81+
can add yourself into. Every server you discover and add through a custom source
82+
lands quarantined and can never skip quarantine — review and approve it once you
83+
trust it:
84+
mcpproxy registry search <query> -r <id>
85+
mcpproxy registry add <id> <serverId>
86+
mcpproxy upstream approve <name>`,
87+
Args: cobra.ExactArgs(1),
88+
RunE: func(_ *cobra.Command, args []string) error {
89+
sourceURL := args[0]
90+
91+
cfg, err := loadRegistryConfig()
92+
if err != nil {
93+
return outputError(clioutput.NewStructuredError(clioutput.ErrCodeConfigNotFound, err.Error()).
94+
WithRecoveryCommand("mcpproxy doctor"), clioutput.ErrCodeConfigNotFound)
95+
}
96+
97+
// add-source MUST go through the daemon: the registry list lives on the
98+
// runtime config snapshot and is updated copy-on-write via UpdateConfig.
99+
if !shouldUseUpstreamDaemon(cfg.DataDir) {
100+
return outputError(clioutput.NewStructuredError(clioutput.ErrCodeConnectionFailed,
101+
"adding a registry source requires a running mcpproxy daemon").
102+
WithGuidance("Start the daemon, then retry").
103+
WithRecoveryCommand("mcpproxy serve"), clioutput.ErrCodeConnectionFailed)
104+
}
105+
106+
ctx, cancel := registryContext()
107+
defer cancel()
108+
109+
client := cliclient.NewClient(socket.DetectSocketPath(cfg.DataDir), nil)
110+
reg, err := client.AddRegistrySource(ctx, sourceURL, registryAddSourceProto, registryAddSourceID, registryAddSourceName)
111+
if err != nil {
112+
return registryAddErrorOutput(err)
113+
}
114+
115+
outputFormat := ResolveOutputFormat()
116+
if outputFormat == "json" || outputFormat == "yaml" {
117+
formatter, _ := GetOutputFormatter()
118+
out, _ := formatter.Format(reg)
119+
fmt.Println(out)
120+
return nil
121+
}
122+
123+
fmt.Printf("✅ Added registry source '%s' (%s)\n", reg.ID, reg.Provenance)
124+
fmt.Printf("⚠️ This is a third-party, unverified registry — its servers are always quarantined until you approve them.\n")
125+
fmt.Printf(" Search it with: mcpproxy registry search <query> -r %s\n", reg.ID)
126+
return nil
127+
},
128+
}
129+
cmd.Flags().StringVar(&registryAddSourceProto, "protocol", "", "Registry protocol (default: modelcontextprotocol/registry)")
130+
cmd.Flags().StringVar(&registryAddSourceID, "id", "", "Override the derived registry id")
131+
cmd.Flags().StringVar(&registryAddSourceName, "name", "", "Override the registry display name")
62132
return cmd
63133
}
64134

@@ -270,6 +340,18 @@ func registryAddErrorOutput(err error) error {
270340
case "registry_not_found", "server_not_found":
271341
return outputError(clioutput.NewStructuredError(clioutput.ErrCodeServerNotFound, addErr.Message).
272342
WithGuidance("Check the ids with 'mcpproxy registry list' and 'mcpproxy registry search'"), clioutput.ErrCodeServerNotFound)
343+
case "invalid_registry_url":
344+
return outputError(clioutput.NewStructuredError(clioutput.ErrCodeInvalidInput, addErr.Message).
345+
WithGuidance("Provide an https URL, e.g. https://registry.example.com"), clioutput.ErrCodeInvalidInput)
346+
case "registries_locked":
347+
return outputError(clioutput.NewStructuredError(clioutput.ErrCodeOperationFailed, addErr.Message).
348+
WithGuidance("Registry additions are disabled by policy (registries_locked)"), clioutput.ErrCodeOperationFailed)
349+
case "registry_shadows_builtin":
350+
return outputError(clioutput.NewStructuredError(clioutput.ErrCodeInvalidInput, addErr.Message).
351+
WithGuidance("Choose a different --id; built-in registry ids cannot be replaced"), clioutput.ErrCodeInvalidInput)
352+
case "duplicate_registry":
353+
return outputError(clioutput.NewStructuredError(clioutput.ErrCodeOperationFailed, addErr.Message).
354+
WithGuidance("A registry with that id already exists; pass a different --id"), clioutput.ErrCodeOperationFailed)
273355
default:
274356
return outputError(clioutput.NewStructuredError(clioutput.ErrCodeOperationFailed, addErr.Message), clioutput.ErrCodeOperationFailed)
275357
}

docs/registries.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,61 @@ key is configured it is sent on every request to that registry as an
2323
User-configured registries in `mcp_config.json` (`registries: [...]`) are **merged**
2424
with these defaults (keyed by ID); a custom entry never drops the shipped set.
2525

26+
## Trust model & user-added registries
27+
28+
Every registry carries a **provenance** tag:
29+
30+
| Provenance | Meaning |
31+
|---|---|
32+
| `official/trusted` | A shipped, built-in default (the five above). |
33+
| `custom/unverified` | Any registry the user added at runtime, or any non-default ID in `mcp_config.json`. |
34+
35+
Trust is **derived, not asserted** — it comes solely from whether the registry ID
36+
is one of the shipped defaults. Writing `"provenance": "official/trusted"` into a
37+
custom `mcp_config.json` entry has no effect; mcpproxy recomputes provenance on
38+
every merge. **There is no allowlist a user can add themselves into.**
39+
40+
Consequences for `custom/unverified` registries:
41+
42+
- Servers discovered through them are **always quarantined** on add, regardless of
43+
the global quarantine default — and they can **never** set `skip_quarantine`
44+
(enforced in config validation *and* at server-add time). A server's origin is
45+
recorded on its config as `source_registry_id` / `source_registry_provenance`
46+
and surfaced in the approval/quarantine view.
47+
- The `list_registries` output (MCP, REST, CLI) includes `provenance` and a
48+
`trusted` boolean so a UI can show a one-time third-party-registry warning.
49+
50+
### Adding your own registry source
51+
52+
`mcpproxy registry add-source` adds any https endpoint that implements the official
53+
`modelcontextprotocol/registry` v0.1 protocol (the same protocol Copilot / VS Code /
54+
Azure ship):
55+
56+
```bash
57+
mcpproxy registry add-source https://registry.example.com
58+
mcpproxy registry add-source https://registry.example.com --id acme --name "Acme Corp"
59+
```
60+
61+
The ID is derived from the host when omitted; `--protocol` defaults to
62+
`modelcontextprotocol/registry`. The source is always tagged `custom/unverified`.
63+
This requires a running daemon — the registry list is updated copy-on-write on the
64+
runtime config snapshot and persisted to `mcp_config.json`.
65+
66+
Equivalent surfaces:
67+
68+
- **REST:** `POST /api/v1/registries` with `{ "url": "https://…", "protocol": "…", "id": "…", "name": "…" }`.
69+
- **CLI:** `mcpproxy registry add-source <https-url>`.
70+
71+
Errors share a stable code across surfaces: `invalid_registry_url` (400),
72+
`registries_locked` (403), `registry_shadows_builtin` / `duplicate_registry` (409).
73+
74+
### Enterprise: `registries_locked` (stub)
75+
76+
Setting `"registries_locked": true` in `mcp_config.json` disables runtime registry
77+
additions (`registry add-source` and the REST/MCP add-source surface return
78+
`registries_locked`). Built-in defaults are unaffected. This is a forward-looking
79+
stub for enterprise policy pinning.
80+
2681
## Official v0.1 protocol
2782

2883
The official registry returns a cursor-paginated list of wrapped entries:

internal/cliclient/client.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1862,3 +1862,56 @@ func (c *Client) AddFromRegistry(ctx context.Context, registryID, serverID, name
18621862
}
18631863
return &apiResp.Data.Server, nil
18641864
}
1865+
1866+
// AddRegistrySource adds a user-supplied registry source via the daemon
1867+
// (MCP-866). POST /api/v1/registries → data.registry. On failure it returns a
1868+
// *RegistryAddError carrying the stable cross-surface code.
1869+
func (c *Client) AddRegistrySource(ctx context.Context, sourceURL, protocol, id, name string) (*contracts.RegistrySummary, error) {
1870+
body := contracts.AddRegistrySourceRequest{URL: sourceURL, Protocol: protocol, ID: id, Name: name}
1871+
bodyBytes, err := json.Marshal(body)
1872+
if err != nil {
1873+
return nil, fmt.Errorf("failed to marshal request: %w", err)
1874+
}
1875+
1876+
u := c.baseURL + "/api/v1/registries"
1877+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(bodyBytes))
1878+
if err != nil {
1879+
return nil, fmt.Errorf("failed to create request: %w", err)
1880+
}
1881+
req.Header.Set("Content-Type", "application/json")
1882+
c.prepareRequest(ctx, req)
1883+
1884+
resp, err := c.httpClient.Do(req)
1885+
if err != nil {
1886+
return nil, fmt.Errorf("failed to call add-registry-source API: %w", err)
1887+
}
1888+
defer resp.Body.Close()
1889+
1890+
respBytes, err := io.ReadAll(resp.Body)
1891+
if err != nil {
1892+
return nil, fmt.Errorf("failed to read response: %w", err)
1893+
}
1894+
1895+
var apiResp struct {
1896+
Success bool `json:"success"`
1897+
Data *contracts.AddRegistrySourceData `json:"data"`
1898+
Error string `json:"error"`
1899+
Code string `json:"code"`
1900+
RequestID string `json:"request_id"`
1901+
}
1902+
if err := json.Unmarshal(respBytes, &apiResp); err != nil {
1903+
return nil, fmt.Errorf("failed to parse response (status %d): %s", resp.StatusCode, string(respBytes))
1904+
}
1905+
1906+
if !apiResp.Success || resp.StatusCode != http.StatusOK {
1907+
msg := apiResp.Error
1908+
if msg == "" {
1909+
msg = fmt.Sprintf("API returned status %d", resp.StatusCode)
1910+
}
1911+
return nil, &RegistryAddError{Code: apiResp.Code, Message: msg, RequestID: apiResp.RequestID}
1912+
}
1913+
if apiResp.Data == nil {
1914+
return nil, fmt.Errorf("daemon returned success with no registry data")
1915+
}
1916+
return &apiResp.Data.Registry, nil
1917+
}

internal/contracts/types.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -964,6 +964,33 @@ type RegistryAddError struct {
964964
MissingInputs []string `json:"missing_inputs,omitempty"`
965965
}
966966

967+
// AddRegistrySourceRequest is the POST body for adding a user-supplied registry
968+
// source (MCP-866, POST /api/v1/registries). Provenance is NOT part of the
969+
// request — the server always tags an added source custom/unverified.
970+
type AddRegistrySourceRequest struct {
971+
URL string `json:"url"` // required https registry URL
972+
Protocol string `json:"protocol,omitempty"` // defaults to modelcontextprotocol/registry
973+
ID string `json:"id,omitempty"` // derived from the host when empty
974+
Name string `json:"name,omitempty"` // defaults to the id
975+
}
976+
977+
// RegistrySummary is a slim, stable projection of a registry, including its
978+
// provenance/trust so surfaces can flag third-party sources (MCP-866).
979+
type RegistrySummary struct {
980+
ID string `json:"id"`
981+
Name string `json:"name"`
982+
URL string `json:"url,omitempty"`
983+
ServersURL string `json:"servers_url,omitempty"`
984+
Protocol string `json:"protocol,omitempty"`
985+
Provenance string `json:"provenance,omitempty"`
986+
Trusted bool `json:"trusted"`
987+
}
988+
989+
// AddRegistrySourceData is the success `data` payload for add-source.
990+
type AddRegistrySourceData struct {
991+
Registry RegistrySummary `json:"registry"`
992+
}
993+
967994
// SuccessResponse is the standard success response wrapper for API endpoints.
968995
type SuccessResponse struct {
969996
Success bool `json:"success"`

internal/httpapi/code_exec_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ func (m *mockController) RefreshRegistryCache(registryID string) (int, error) {
9797
func (m *mockController) AddServerFromRegistryRef(_ context.Context, _, _, _ string, _ map[string]string, _ *bool) (*config.ServerConfig, *contracts.RegistryAddError, error) {
9898
return nil, nil, nil
9999
}
100+
func (m *mockController) AddRegistrySourceRef(_, _, _, _ string) (*config.RegistryEntry, *contracts.RegistryAddError, error) {
101+
return nil, nil, nil
102+
}
100103
func (m *mockController) GetManagementService() interface{} { return nil }
101104
func (m *mockController) GetRuntime() interface{} { return nil }
102105
func (m *mockController) GetSessions(limit, offset int) (interface{}, int, error) { return nil, 0, nil }

internal/httpapi/contracts_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,9 @@ func (m *MockServerController) RefreshRegistryCache(_ string) (int, error) {
315315
func (m *MockServerController) AddServerFromRegistryRef(_ context.Context, _, _, _ string, _ map[string]string, _ *bool) (*config.ServerConfig, *contracts.RegistryAddError, error) {
316316
return nil, nil, nil
317317
}
318+
func (m *MockServerController) AddRegistrySourceRef(_, _, _, _ string) (*config.RegistryEntry, *contracts.RegistryAddError, error) {
319+
return nil, nil, nil
320+
}
318321

319322
// Version and updates
320323
func (m *MockServerController) GetVersionInfo() *updatecheck.VersionInfo {

internal/httpapi/security_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,9 @@ func (m *baseController) RefreshRegistryCache(registryID string) (int, error) {
297297
func (m *baseController) AddServerFromRegistryRef(_ context.Context, _, _, _ string, _ map[string]string, _ *bool) (*config.ServerConfig, *contracts.RegistryAddError, error) {
298298
return nil, nil, nil
299299
}
300+
func (m *baseController) AddRegistrySourceRef(_, _, _, _ string) (*config.RegistryEntry, *contracts.RegistryAddError, error) {
301+
return nil, nil, nil
302+
}
300303
func (m *baseController) CallTool(ctx context.Context, toolName string, args map[string]interface{}) (interface{}, error) {
301304
return nil, nil
302305
}

internal/httpapi/server.go

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,10 @@ type ServerController interface {
128128
// stable cross-surface error code (*contracts.RegistryAddError) alongside
129129
// the raw error so the handler can map code → HTTP status.
130130
AddServerFromRegistryRef(ctx context.Context, registryID, serverID, name string, env map[string]string, enabled *bool) (*config.ServerConfig, *contracts.RegistryAddError, error)
131+
// AddRegistrySourceRef adds a user-supplied generic registry source
132+
// (MCP-866), always tagged custom/unverified. On failure it returns a stable
133+
// cross-surface error code alongside the raw error.
134+
AddRegistrySourceRef(url, protocol, id, name string) (*config.RegistryEntry, *contracts.RegistryAddError, error)
131135

132136
// Version and updates
133137
GetVersionInfo() *updatecheck.VersionInfo
@@ -679,6 +683,7 @@ func (s *Server) setupRoutes() {
679683

680684
// Registry browsing (Phase 7)
681685
r.Get("/registries", s.handleListRegistries)
686+
r.Post("/registries", s.handleAddRegistrySource) // MCP-866 user-added registry source
682687
r.Get("/registries/{id}/servers", s.handleSearchRegistryServers)
683688
r.Post("/registries/{id}/refresh", s.handleRefreshRegistryCache) // spec 070 FR-007
684689
r.Post("/registries/{id}/servers/{serverId}/add", s.handleAddFromRegistry) // spec 070 keystone add
@@ -4170,6 +4175,55 @@ func (s *Server) handleAddFromRegistry(w http.ResponseWriter, r *http.Request) {
41704175
})
41714176
}
41724177

4178+
// handleAddRegistrySource godoc
4179+
// @Summary Add a user-supplied registry source
4180+
// @Description Adds a generic modelcontextprotocol/registry v0.1 https endpoint as a custom registry (MCP-866). The source is always tagged custom/unverified, so every server discovered through it lands quarantined and can never skip quarantine.
4181+
// @Tags registries
4182+
// @Accept json
4183+
// @Produce json
4184+
// @Param body body contracts.AddRegistrySourceRequest true "Registry source (https url + optional protocol/id/name)"
4185+
// @Success 200 {object} contracts.SuccessResponse "Registry source added"
4186+
// @Failure 400 {object} contracts.ErrorResponse "invalid_registry_url"
4187+
// @Failure 403 {object} contracts.ErrorResponse "registries_locked"
4188+
// @Failure 409 {object} contracts.ErrorResponse "registry_shadows_builtin | duplicate_registry"
4189+
// @Security ApiKeyAuth
4190+
// @Security ApiKeyQuery
4191+
// @Router /api/v1/registries [post]
4192+
func (s *Server) handleAddRegistrySource(w http.ResponseWriter, r *http.Request) {
4193+
var req contracts.AddRegistrySourceRequest
4194+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
4195+
s.writeError(w, r, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %v", err))
4196+
return
4197+
}
4198+
if req.URL == "" {
4199+
s.writeError(w, r, http.StatusBadRequest, "url is required")
4200+
return
4201+
}
4202+
4203+
logger := s.getRequestLogger(r)
4204+
entry, rerr, err := s.controller.AddRegistrySourceRef(req.URL, req.Protocol, req.ID, req.Name)
4205+
if err != nil {
4206+
status := registryAddErrorStatus(rerr.Code)
4207+
if status >= http.StatusInternalServerError {
4208+
logger.Error("Add registry source failed", "url", req.URL, "error", err)
4209+
}
4210+
s.writeRegistryAddError(w, r, status, rerr)
4211+
return
4212+
}
4213+
4214+
s.writeSuccess(w, contracts.AddRegistrySourceData{
4215+
Registry: contracts.RegistrySummary{
4216+
ID: entry.ID,
4217+
Name: entry.Name,
4218+
URL: entry.URL,
4219+
ServersURL: entry.ServersURL,
4220+
Protocol: entry.Protocol,
4221+
Provenance: entry.Provenance,
4222+
Trusted: entry.IsTrusted(),
4223+
},
4224+
})
4225+
}
4226+
41734227
// handleRefreshRegistryCache godoc
41744228
// @Summary Refresh a registry's cached server list
41754229
// @Description Invalidates the cached server lists for a registry so the next search re-fetches fresh data from the source (spec 070 FR-007). Returns how many cache entries were dropped.
@@ -4206,8 +4260,12 @@ func registryAddErrorStatus(code string) int {
42064260
switch code {
42074261
case "registry_not_found", "server_not_found":
42084262
return http.StatusNotFound
4209-
case "no_install_info", "missing_required_input", "duplicate_name":
4263+
case "no_install_info", "missing_required_input", "duplicate_name", "invalid_registry_url":
42104264
return http.StatusBadRequest
4265+
case "registries_locked":
4266+
return http.StatusForbidden
4267+
case "registry_shadows_builtin", "duplicate_registry":
4268+
return http.StatusConflict
42114269
default:
42124270
return http.StatusInternalServerError
42134271
}

0 commit comments

Comments
 (0)