Skip to content

Commit 2b6da58

Browse files
authored
feat(registry): simplify trust/quarantine + add edit endpoint (MCP-1072) (#594)
Provenance is now informational only: servers added from any registry — official or custom — follow the global quarantine default. Drop the custom-origin quarantine-forcing in add_from_registry and the config validation that forbade skip_quarantine for custom-origin servers. Simplify provenance to two plain values (official/custom) and normalize legacy official/trusted | custom/unverified strings on read so existing config.db / mcp_config.json keep working. Fix the REST list projection that hard-coded the old 'official/trusted' literal for the trusted flag. Add PUT /api/v1/registries/{id} (handleEditRegistrySource) + EditRegistrySourceRef to update a custom registry (name/url/servers-url), mirroring the add/remove-source cross-surface error pattern (registry_not_found, registry_shadows_builtin, invalid_registry_url, registries_locked). Add 'mcpproxy registry edit <id>' CLI + cliclient.EditRegistrySource, regen OpenAPI, and update docs. Related MCP-1072
1 parent d4f736e commit 2b6da58

23 files changed

Lines changed: 867 additions & 124 deletions

cmd/mcpproxy/registry_cmd.go

Lines changed: 99 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ var (
3030
registryAddSourceProto string // MCP-866
3131
registryAddSourceID string
3232
registryAddSourceName string
33+
registryEditName string // MCP-1072
34+
registryEditURL string
35+
registryEditServersURL string
3336
)
3437

3538
// GetRegistryCommand builds the `registry` command group (spec 070): a single
@@ -57,15 +60,16 @@ Typical flow:
5760
mcpproxy upstream approve weather-mcp # approve once you trust it
5861
5962
Add your own registry source (any official modelcontextprotocol/registry v0.1 endpoint):
60-
mcpproxy registry add-source https://registry.example.com # custom/unverified
63+
mcpproxy registry add-source https://registry.example.com # tagged "custom"
64+
mcpproxy registry edit my-reg --url https://new.example.com # update a custom source
6165
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
66+
'registry add', 'add-source', 'edit' and 'remove' talk to the running mcpproxy
67+
daemon. 'list' and 'search' use the daemon when available and otherwise read the
6468
registries directly.`,
6569
}
6670

6771
cmd.PersistentFlags().StringVarP(&registryConfigPath, "config", "c", "", "Path to MCP configuration file")
68-
cmd.AddCommand(newRegistryListCmd(), newRegistrySearchCmd(), newRegistryAddCmd(), newRegistryAddSourceCmd(), newRegistryRemoveCmd())
72+
cmd.AddCommand(newRegistryListCmd(), newRegistrySearchCmd(), newRegistryAddCmd(), newRegistryAddSourceCmd(), newRegistryEditCmd(), newRegistryRemoveCmd())
6973
return cmd
7074
}
7175

@@ -150,15 +154,14 @@ func registryRemoveErrorOutput(err error) error {
150154
func newRegistryAddSourceCmd() *cobra.Command {
151155
cmd := &cobra.Command{
152156
Use: "add-source <https-url>",
153-
Short: "Add a custom MCP registry source (quarantine-always)",
157+
Short: "Add a custom MCP registry source",
154158
Long: `Add your own MCP server registry — any https endpoint implementing the
155159
official modelcontextprotocol/registry v0.1 protocol (the same protocol shipped
156160
by Copilot/VS Code/Azure).
157161
158-
The added source is ALWAYS tagged custom/unverified: there is no allowlist you
159-
can add yourself into. Every server you discover and add through a custom source
160-
lands quarantined and can never skip quarantine — review and approve it once you
161-
trust it:
162+
The added source is tagged "custom" (informational). Servers you discover and
163+
add through it follow the global quarantine default like any other server, so
164+
with quarantine enabled (the default) they land quarantined for review:
162165
mcpproxy registry search <query> -r <id>
163166
mcpproxy registry add <id> <serverId>
164167
mcpproxy upstream approve <name>`,
@@ -199,7 +202,7 @@ trust it:
199202
}
200203

201204
fmt.Printf("✅ Added registry source '%s' (%s)\n", reg.ID, reg.Provenance)
202-
fmt.Printf("⚠️ This is a third-party, unverified registry — its servers are always quarantined until you approve them.\n")
205+
fmt.Printf("⚠️ This is a third-party registry — with quarantine enabled, servers you add from it are quarantined until you approve them.\n")
203206
fmt.Printf(" Search it with: mcpproxy registry search <query> -r %s\n", reg.ID)
204207
return nil
205208
},
@@ -210,6 +213,92 @@ trust it:
210213
return cmd
211214
}
212215

216+
func newRegistryEditCmd() *cobra.Command {
217+
cmd := &cobra.Command{
218+
Use: "edit <id>",
219+
Short: "Edit a user-added custom registry source",
220+
Long: `Update a custom MCP registry source you previously added with
221+
'registry add-source'. Use 'registry list' to see the ids.
222+
223+
Only custom registries can be edited — the shipped built-in registries cannot.
224+
Provide one or more of --name, --url, --servers-url; omitted fields are left
225+
unchanged. Changing --url re-derives the servers URL unless --servers-url is
226+
also given.
227+
228+
mcpproxy registry edit my-reg --url https://new.example.com
229+
mcpproxy registry edit my-reg --name "My Registry"`,
230+
Args: cobra.ExactArgs(1),
231+
RunE: func(_ *cobra.Command, args []string) error {
232+
registryID := args[0]
233+
234+
cfg, err := loadRegistryConfig()
235+
if err != nil {
236+
return outputError(clioutput.NewStructuredError(clioutput.ErrCodeConfigNotFound, err.Error()).
237+
WithRecoveryCommand("mcpproxy doctor"), clioutput.ErrCodeConfigNotFound)
238+
}
239+
240+
// edit MUST go through the daemon: the registry list lives on the
241+
// runtime config snapshot and is updated copy-on-write via UpdateConfig.
242+
if !shouldUseUpstreamDaemon(cfg.DataDir) {
243+
return outputError(clioutput.NewStructuredError(clioutput.ErrCodeConnectionFailed,
244+
"editing a registry source requires a running mcpproxy daemon").
245+
WithGuidance("Start the daemon, then retry").
246+
WithRecoveryCommand("mcpproxy serve"), clioutput.ErrCodeConnectionFailed)
247+
}
248+
249+
ctx, cancel := registryContext()
250+
defer cancel()
251+
252+
client := cliclient.NewClient(socket.DetectSocketPath(cfg.DataDir), nil)
253+
reg, err := client.EditRegistrySource(ctx, registryID, registryEditName, registryEditURL, registryEditServersURL)
254+
if err != nil {
255+
return registryEditErrorOutput(err)
256+
}
257+
258+
outputFormat := ResolveOutputFormat()
259+
if outputFormat == "json" || outputFormat == "yaml" {
260+
formatter, _ := GetOutputFormatter()
261+
out, _ := formatter.Format(reg)
262+
fmt.Println(out)
263+
return nil
264+
}
265+
266+
fmt.Printf("✅ Updated registry source '%s'\n", reg.ID)
267+
return nil
268+
},
269+
}
270+
cmd.Flags().StringVar(&registryEditName, "name", "", "New registry display name")
271+
cmd.Flags().StringVar(&registryEditURL, "url", "", "New base/servers https URL")
272+
cmd.Flags().StringVar(&registryEditServersURL, "servers-url", "", "New servers-collection URL (overrides the derived one)")
273+
return cmd
274+
}
275+
276+
// registryEditErrorOutput maps a *cliclient.RegistryAddError from an edit op to
277+
// a structured CLI error with edit-specific guidance (MCP-1072).
278+
func registryEditErrorOutput(err error) error {
279+
var addErr *cliclient.RegistryAddError
280+
if !errors.As(err, &addErr) {
281+
return outputError(clioutput.NewStructuredError(clioutput.ErrCodeOperationFailed, err.Error()), clioutput.ErrCodeOperationFailed)
282+
}
283+
284+
switch addErr.Code {
285+
case "registry_not_found":
286+
return outputError(clioutput.NewStructuredError(clioutput.ErrCodeServerNotFound, addErr.Message).
287+
WithGuidance("Check the ids with 'mcpproxy registry list' — only custom registries can be edited"), clioutput.ErrCodeServerNotFound)
288+
case "registry_shadows_builtin":
289+
return outputError(clioutput.NewStructuredError(clioutput.ErrCodeInvalidInput, addErr.Message).
290+
WithGuidance("Built-in registries cannot be edited"), clioutput.ErrCodeInvalidInput)
291+
case "invalid_registry_url":
292+
return outputError(clioutput.NewStructuredError(clioutput.ErrCodeInvalidInput, addErr.Message).
293+
WithGuidance("Provide a valid https URL"), clioutput.ErrCodeInvalidInput)
294+
case "registries_locked":
295+
return outputError(clioutput.NewStructuredError(clioutput.ErrCodeOperationFailed, addErr.Message).
296+
WithGuidance("Registry changes are disabled by policy (registries_locked)"), clioutput.ErrCodeOperationFailed)
297+
default:
298+
return outputError(clioutput.NewStructuredError(clioutput.ErrCodeOperationFailed, addErr.Message), clioutput.ErrCodeOperationFailed)
299+
}
300+
}
301+
213302
func newRegistryListCmd() *cobra.Command {
214303
return &cobra.Command{
215304
Use: "list",

docs/api/rest-api.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -549,10 +549,19 @@ List configured registries.
549549

550550
Add a user-supplied custom registry source. JSON body:
551551
`{ "url": "https://…", "protocol": "…", "id": "…", "name": "…" }` (only `url`
552-
required). The source is always tagged `custom/unverified`. Errors share a stable
552+
required). The source is always tagged `custom`. Errors share a stable
553553
code: `invalid_registry_url` (400), `registries_locked` (403),
554554
`registry_shadows_builtin` / `duplicate_registry` (409).
555555

556+
#### PUT /api/v1/registries/{id}
557+
558+
Edit a user-added custom registry source. JSON body:
559+
`{ "name": "…", "url": "https://…", "servers_url": "https://…" }` (all optional;
560+
an omitted/empty field is left unchanged). Returns `data.registry` echoing the
561+
updated entry. Built-in registries are refused with `registry_shadows_builtin`
562+
(409); an unknown id returns `registry_not_found` (404); a non-https url returns
563+
`invalid_registry_url` (400); a `registries_locked` policy returns 403.
564+
556565
#### DELETE /api/v1/registries/{id}
557566

558567
Remove a user-added custom registry source. Returns `data.registry` echoing the
@@ -566,8 +575,8 @@ Search a registry's servers (`?search=`, `?tag=`, `?limit=`).
566575

567576
#### POST /api/v1/registries/{id}/servers/{serverId}/add
568577

569-
Add a server from a registry as a quarantined upstream. Optional JSON body
570-
carries only overrides (never a config blob):
578+
Add a server from a registry as an upstream (quarantined per the global default).
579+
Optional JSON body carries only overrides (never a config blob):
571580

572581
```json
573582
{ "name": "github-mcp", "env": { "GITHUB_TOKEN": "" }, "enabled": true }

docs/registries.md

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ available via the `search_servers` / `list_registries` MCP tools, the
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. |
1414

15-
The shipped default set is exactly these **three** official/trusted entries. Earlier
15+
The shipped default set is exactly these **three** official, built-in entries. Earlier
1616
versions also shipped `pulse`, `smithery`, `fleur`, `azure-mcp-demo`, and
1717
`remote-mcp-servers` as defaults; these were removed. They are pruned from an
1818
existing `mcp_config.json` on load (genuinely user-added custom registries are never
@@ -35,23 +35,28 @@ Every registry carries a **provenance** tag:
3535

3636
| Provenance | Meaning |
3737
|---|---|
38-
| `official/trusted` | A shipped, built-in default (the three above). |
39-
| `custom/unverified` | Any registry the user added at runtime, or any non-default ID in `mcp_config.json`. |
38+
| `official` | A shipped, built-in default (the three above). |
39+
| `custom` | Any registry the user added at runtime, or any non-default ID in `mcp_config.json`. |
4040

4141
Trust is **derived, not asserted** — it comes solely from whether the registry ID
42-
is one of the shipped defaults. Writing `"provenance": "official/trusted"` into a
42+
is one of the shipped defaults. Writing `"provenance": "official"` into a
4343
custom `mcp_config.json` entry has no effect; mcpproxy recomputes provenance on
4444
every merge. **There is no allowlist a user can add themselves into.**
4545

46-
Consequences for `custom/unverified` registries:
46+
Provenance is **informational only** (it no longer changes quarantine behavior):
4747

48-
- Servers discovered through them are **always quarantined** on add, regardless of
49-
the global quarantine default — and they can **never** set `skip_quarantine`
50-
(enforced in config validation *and* at server-add time). A server's origin is
48+
- Servers discovered through **any** registry — official or custom — follow the
49+
**global quarantine default** like everything else. With quarantine enabled (the
50+
secure default) a newly added server lands quarantined for review; provenance no
51+
longer force-quarantines or forbids `skip_quarantine`. A server's origin is still
5152
recorded on its config as `source_registry_id` / `source_registry_provenance`
5253
and surfaced in the approval/quarantine view.
5354
- The `list_registries` output (MCP, REST, CLI) includes `provenance` and a
54-
`trusted` boolean so a UI can show a one-time third-party-registry warning.
55+
`trusted` boolean (derived `official == trusted`) so a UI can show a one-time
56+
third-party-registry warning.
57+
- **Migration:** earlier builds persisted the two-word tags `official/trusted` /
58+
`custom/unverified`; these are normalized to `official` / `custom` on read, so
59+
an existing `config.db` / `mcp_config.json` keeps working unchanged.
5560

5661
### Adding your own registry source
5762

@@ -65,7 +70,7 @@ mcpproxy registry add-source https://registry.example.com --id acme --name "Acme
6570
```
6671

6772
The ID is derived from the host when omitted; `--protocol` defaults to
68-
`modelcontextprotocol/registry`. The source is always tagged `custom/unverified`.
73+
`modelcontextprotocol/registry`. The source is always tagged `custom`.
6974
This requires a running daemon — the registry list is updated copy-on-write on the
7075
runtime config snapshot and persisted to `mcp_config.json`.
7176

@@ -88,7 +93,7 @@ The Web UI maps each code to an actionable message.
8893
### Removing a registry source
8994

9095
`mcpproxy registry remove <id>` deletes a custom registry you added earlier. Only
91-
`custom/unverified` registries can be removed — the shipped built-in defaults are
96+
`custom` registries can be removed — the shipped built-in defaults are
9297
refused via the same shadow guard as add-source. Removing a source does not touch
9398
any upstream servers you already added from it.
9499

@@ -109,6 +114,31 @@ Errors share a stable code across surfaces: `registry_not_found` (404),
109114
`registry_shadows_builtin` (409, built-in cannot be removed),
110115
`registries_locked` (403).
111116

117+
### Editing a registry source
118+
119+
`mcpproxy registry edit <id>` updates a custom registry you added earlier — its
120+
display name, base URL, or servers-collection URL. Only `custom` registries can be
121+
edited; the shipped built-in defaults are refused via the same shadow guard as
122+
add/remove-source. Omitted flags leave the existing value unchanged. Changing
123+
`--url` re-derives the servers URL unless `--servers-url` is also given.
124+
125+
```bash
126+
mcpproxy registry edit acme --url https://new.acme.example.com # change the URL
127+
mcpproxy registry edit acme --name "Acme Corp" # change the display name
128+
```
129+
130+
Like add/remove-source, this requires a running daemon — the change is applied
131+
copy-on-write on the runtime config snapshot and persisted to `mcp_config.json`.
132+
133+
Equivalent surfaces:
134+
135+
- **REST:** `PUT /api/v1/registries/{id}` with `{ "name": "…", "url": "https://…", "servers_url": "https://…" }` (all optional) → `{ "registry": { … } }` echoing the updated entry.
136+
- **CLI:** `mcpproxy registry edit <id> [--name … --url … --servers-url …]`.
137+
138+
Errors share a stable code across surfaces: `registry_not_found` (404),
139+
`registry_shadows_builtin` (409, built-in cannot be edited),
140+
`invalid_registry_url` (400), `registries_locked` (403).
141+
112142
### Enterprise: `registries_locked` (stub)
113143

114144
Setting `"registries_locked": true` in `mcp_config.json` disables runtime registry

internal/cliclient/client.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1962,3 +1962,58 @@ func (c *Client) RemoveRegistrySource(ctx context.Context, id string) (*contract
19621962
}
19631963
return &apiResp.Data.Registry, nil
19641964
}
1965+
1966+
// EditRegistrySource updates a user-added custom registry source via the daemon
1967+
// (MCP-1072). PUT /api/v1/registries/{id} → data.registry. Empty fields are left
1968+
// unchanged. On failure it returns a *RegistryAddError carrying the stable
1969+
// cross-surface code (registry_not_found, registry_shadows_builtin,
1970+
// registries_locked, invalid_registry_url).
1971+
func (c *Client) EditRegistrySource(ctx context.Context, id, name, sourceURL, serversURL string) (*contracts.RegistrySummary, error) {
1972+
body := contracts.EditRegistrySourceRequest{Name: name, URL: sourceURL, ServersURL: serversURL}
1973+
bodyBytes, err := json.Marshal(body)
1974+
if err != nil {
1975+
return nil, fmt.Errorf("failed to marshal request: %w", err)
1976+
}
1977+
1978+
u := c.baseURL + "/api/v1/registries/" + url.PathEscape(id)
1979+
req, err := http.NewRequestWithContext(ctx, http.MethodPut, u, bytes.NewReader(bodyBytes))
1980+
if err != nil {
1981+
return nil, fmt.Errorf("failed to create request: %w", err)
1982+
}
1983+
req.Header.Set("Content-Type", "application/json")
1984+
c.prepareRequest(ctx, req)
1985+
1986+
resp, err := c.httpClient.Do(req)
1987+
if err != nil {
1988+
return nil, fmt.Errorf("failed to call edit-registry-source API: %w", err)
1989+
}
1990+
defer resp.Body.Close()
1991+
1992+
respBytes, err := io.ReadAll(resp.Body)
1993+
if err != nil {
1994+
return nil, fmt.Errorf("failed to read response: %w", err)
1995+
}
1996+
1997+
var apiResp struct {
1998+
Success bool `json:"success"`
1999+
Data *contracts.EditRegistrySourceData `json:"data"`
2000+
Error string `json:"error"`
2001+
Code string `json:"code"`
2002+
RequestID string `json:"request_id"`
2003+
}
2004+
if err := json.Unmarshal(respBytes, &apiResp); err != nil {
2005+
return nil, fmt.Errorf("failed to parse response (status %d): %s", resp.StatusCode, string(respBytes))
2006+
}
2007+
2008+
if !apiResp.Success || resp.StatusCode != http.StatusOK {
2009+
msg := apiResp.Error
2010+
if msg == "" {
2011+
msg = fmt.Sprintf("API returned status %d", resp.StatusCode)
2012+
}
2013+
return nil, &RegistryAddError{Code: apiResp.Code, Message: msg, RequestID: apiResp.RequestID}
2014+
}
2015+
if apiResp.Data == nil {
2016+
return nil, fmt.Errorf("daemon returned success with no registry data")
2017+
}
2018+
return &apiResp.Data.Registry, nil
2019+
}

0 commit comments

Comments
 (0)