Skip to content

Commit 30a43c1

Browse files
committed
feat(webui): remove affordance for custom registries (MCP-1064)
Add a Remove action to the Repositories page registry selector for custom/unverified (user-added) registries, the Web UI match for the DELETE /api/v1/registries/{id} backend path landed in #592. - Remove (trash) button rendered only on custom/unverified registries; built-in official/trusted registries offer no removal. - Confirms before removing, noting that upstream servers already added from the source are unaffected; refreshes the registry list on success and drops the removed source from the active selection. - New api.removeRegistrySource() mirrors the addRegistrySource structured-error pattern, mapping registry_not_found (404), registry_shadows_builtin (409), and registries_locked (403) to actionable messages. - Docs: document the Web UI remove surface in docs/registries.md. Related #592
1 parent d4f736e commit 30a43c1

5 files changed

Lines changed: 442 additions & 3 deletions

File tree

docs/registries.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,14 @@ Equivalent surfaces:
104104

105105
- **REST:** `DELETE /api/v1/registries/{id}``{ "registry": { … } }` echoing the removed entry.
106106
- **CLI:** `mcpproxy registry remove <id>`.
107+
- **Web UI:** the **Repositories** page registry selector shows a **Remove** (trash)
108+
action on each **Third-party · unverified** registry only — built-in defaults
109+
offer no removal. It confirms first (noting that upstream servers already added
110+
from the source are unaffected), then refreshes the list on success.
107111

108112
Errors share a stable code across surfaces: `registry_not_found` (404),
109113
`registry_shadows_builtin` (409, built-in cannot be removed),
110-
`registries_locked` (403).
114+
`registries_locked` (403). The Web UI maps each code to an actionable message.
111115

112116
### Enterprise: `registries_locked` (stub)
113117

frontend/src/services/api.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,18 @@ export interface AddRegistrySourceResult {
4444
code?: string
4545
}
4646

47+
// MCP-1064 / MCP-1057: result of removing a *custom/unverified registry source*
48+
// (DELETE /registries/{id}). Carries the stable error `code`
49+
// (registry_not_found | registry_shadows_builtin | registries_locked) so the UI
50+
// can render an actionable message. Removing a source does not touch upstream
51+
// servers already added from it.
52+
export interface RemoveRegistrySourceResult {
53+
success: boolean
54+
registry?: RegistrySummary
55+
error?: string
56+
code?: string
57+
}
58+
4759
class APIService {
4860
private baseUrl = ''
4961
private apiKey = ''
@@ -730,6 +742,49 @@ class APIService {
730742
}
731743
}
732744

745+
// MCP-1064 / MCP-1057: remove a user-added custom/unverified registry source.
746+
// Mirrors the structured-error pattern of addRegistrySource so the UI can
747+
// branch on the stable `code` (registry_not_found | registry_shadows_builtin
748+
// | registries_locked). Built-in official/trusted registries cannot be
749+
// removed (the backend refuses them with registry_shadows_builtin), so the UI
750+
// only offers this on custom sources. Removing a source leaves any upstream
751+
// servers already added from it untouched.
752+
async removeRegistrySource(registryId: string): Promise<RemoveRegistrySourceResult> {
753+
const headers: Record<string, string> = {}
754+
if (this.apiKey) headers['X-API-Key'] = this.apiKey
755+
756+
try {
757+
const response = await fetch(`${this.baseUrl}/api/v1/registries/${encodeURIComponent(registryId)}`, {
758+
method: 'DELETE',
759+
headers
760+
})
761+
762+
const payload: any = await response.json().catch(() => ({}))
763+
764+
if (!response.ok) {
765+
if (response.status === 401 || response.status === 403) {
766+
// registries_locked is a 403 policy decision, not an auth failure —
767+
// only emit the auth-error path for a missing/invalid key.
768+
if (payload?.code !== 'registries_locked') {
769+
this.emitAuthError(payload?.error || `HTTP ${response.status}`, response.status)
770+
}
771+
}
772+
return {
773+
success: false,
774+
error: payload?.error || `HTTP ${response.status}: ${response.statusText}`,
775+
code: payload?.code
776+
}
777+
}
778+
779+
return { success: true, registry: payload?.data?.registry }
780+
} catch (error) {
781+
return {
782+
success: false,
783+
error: error instanceof Error ? error.message : 'Unknown error'
784+
}
785+
}
786+
}
787+
733788
// Spec 070 (CN-001): add a server to upstream by *reference* — the server
734789
// re-derives and validates the config from the registry entry. The client no
735790
// longer splits install_cmd / chooses protocol (that client-side parsing was

frontend/src/views/Repositories.vue

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@
4646
<button type="button" class="link link-primary text-xs" data-test="registry-select-all" @click="selectAllRegistries">All</button>
4747
<button type="button" class="link text-xs" data-test="registry-clear-all" @click="clearRegistries">Clear</button>
4848
</li>
49-
<li v-for="registry in registries" :key="registry.id">
50-
<label class="label cursor-pointer justify-start gap-3 py-2">
49+
<li v-for="registry in registries" :key="registry.id" class="flex flex-row items-center">
50+
<label class="label cursor-pointer justify-start gap-3 py-2 flex-1">
5151
<input
5252
type="checkbox"
5353
class="checkbox checkbox-sm"
@@ -57,6 +57,20 @@
5757
/>
5858
<span class="text-sm">{{ registry.name }}<span v-if="isCustomRegistry(registry)" class="opacity-60"> — unverified</span></span>
5959
</label>
60+
<!-- MCP-1064: only custom/unverified (user-added) registries can be removed. -->
61+
<button
62+
v-if="isCustomRegistry(registry)"
63+
type="button"
64+
class="btn btn-ghost btn-xs text-error shrink-0"
65+
:data-test="`registry-remove-${registry.id}`"
66+
:title="`Remove ${registry.name}`"
67+
:aria-label="`Remove ${registry.name}`"
68+
@click.stop.prevent="openRemoveRegistry(registry)"
69+
>
70+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
71+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
72+
</svg>
73+
</button>
6074
</li>
6175
</ul>
6276
</div>
@@ -436,6 +450,52 @@
436450
</form>
437451
</dialog>
438452

453+
<!-- Remove custom registry confirmation (MCP-1064) -->
454+
<dialog :open="removeTarget !== null" class="modal" data-test="registry-remove-dialog">
455+
<div class="modal-box">
456+
<h3 class="font-bold text-lg">Remove registry</h3>
457+
<div class="text-sm py-3 space-y-2">
458+
<p>
459+
Remove the custom registry
460+
<strong>{{ removeTarget?.name || removeTarget?.id }}</strong>
461+
from your discovery sources?
462+
</p>
463+
<p class="text-base-content/70">
464+
This only removes the source. Upstream servers you already added from it
465+
stay configured and are not affected.
466+
</p>
467+
</div>
468+
469+
<div v-if="removeError" class="alert alert-error text-sm" data-test="registry-remove-error">
470+
<span>{{ removeError }}</span>
471+
</div>
472+
473+
<div class="modal-action">
474+
<button
475+
type="button"
476+
class="btn btn-ghost"
477+
data-test="registry-remove-cancel"
478+
@click="cancelRemoveRegistry"
479+
>
480+
Cancel
481+
</button>
482+
<button
483+
type="button"
484+
class="btn btn-error"
485+
data-test="registry-remove-confirm"
486+
:disabled="removingRegistry"
487+
@click="confirmRemoveRegistry"
488+
>
489+
<span v-if="removingRegistry" class="loading loading-spinner loading-xs"></span>
490+
<span v-else>Remove</span>
491+
</button>
492+
</div>
493+
</div>
494+
<form method="dialog" class="modal-backdrop">
495+
<button @click="cancelRemoveRegistry">close</button>
496+
</form>
497+
</dialog>
498+
439499
<!-- Success Toast -->
440500
<div v-if="showSuccessToast" class="toast toast-end" data-test="registry-add-success">
441501
<div class="alert alert-success">
@@ -494,6 +554,12 @@ const addRegistryError = ref<string | null>(null)
494554
const addingRegistry = ref(false)
495555
const showThirdPartyWarning = ref(false)
496556
557+
// Remove-custom-registry state (MCP-1064). removeTarget !== null drives the
558+
// confirmation dialog; only custom/unverified registries are ever offered here.
559+
const removeTarget = ref<Registry | null>(null)
560+
const removingRegistry = ref(false)
561+
const removeError = ref<string | null>(null)
562+
497563
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
498564
499565
// A registry is "custom/unverified" (third-party) when its provenance says so,
@@ -889,6 +955,66 @@ async function doAddRegistry() {
889955
}
890956
}
891957
958+
// --- Remove custom registry source (MCP-1064 / backend MCP-1057) ---
959+
960+
function openRemoveRegistry(registry: Registry) {
961+
removeTarget.value = registry
962+
removeError.value = null
963+
}
964+
965+
function cancelRemoveRegistry() {
966+
if (removingRegistry.value) return
967+
removeTarget.value = null
968+
removeError.value = null
969+
}
970+
971+
// Map the backend's stable remove error codes to actionable messages.
972+
function removeRegistryErrorMessage(code: string | undefined, fallback: string | undefined): string {
973+
switch (code) {
974+
case 'registry_not_found':
975+
return 'That registry no longer exists — it may have already been removed.'
976+
case 'registry_shadows_builtin':
977+
return 'Built-in registries cannot be removed.'
978+
case 'registries_locked':
979+
return 'Removing registries is locked by an administrator on this instance.'
980+
default:
981+
return fallback || 'Failed to remove registry.'
982+
}
983+
}
984+
985+
async function confirmRemoveRegistry() {
986+
const target = removeTarget.value
987+
if (!target || removingRegistry.value) return
988+
989+
removingRegistry.value = true
990+
removeError.value = null
991+
992+
try {
993+
const result = await api.removeRegistrySource(target.id)
994+
995+
if (result.success) {
996+
// Drop it from the active selection so we stop searching a now-gone
997+
// source, then refresh the list so the entry disappears. Re-search only
998+
// when it had been selected, to avoid clobbering current results.
999+
const wasSelected = selectedRegistries.value.includes(target.id)
1000+
if (wasSelected) {
1001+
selectedRegistries.value = selectedRegistries.value.filter(id => id !== target.id)
1002+
}
1003+
removeTarget.value = null
1004+
await loadRegistries()
1005+
if (wasSelected) handleRegistryChange()
1006+
showToast(`Removed registry "${target.name || target.id}". Servers already added from it are unaffected.`)
1007+
return
1008+
}
1009+
1010+
removeError.value = removeRegistryErrorMessage(result.code, result.error)
1011+
} catch (err) {
1012+
removeError.value = 'Failed to remove registry: ' + (err as Error).message
1013+
} finally {
1014+
removingRegistry.value = false
1015+
}
1016+
}
1017+
8921018
function copyToClipboard(text: string) {
8931019
navigator.clipboard.writeText(text)
8941020
showToast('Installation command copied to clipboard!')
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2+
import api from '@/services/api'
3+
4+
// MCP-1064 (follows MCP-1057 backend remove path): the Web UI removes a
5+
// *custom/unverified* registry source through DELETE /api/v1/registries/{id}.
6+
// The client surfaces the slim RegistrySummary on success and exposes the
7+
// stable cross-surface error `code` (registry_not_found | registry_shadows_builtin
8+
// | registries_locked) so the UI can render an actionable message. Removing a
9+
// source does NOT touch upstream servers already added from it.
10+
11+
describe('api.removeRegistrySource', () => {
12+
beforeEach(() => {
13+
api.setAPIKey('test-key')
14+
})
15+
16+
afterEach(() => {
17+
vi.restoreAllMocks()
18+
api.clearAPIKey()
19+
})
20+
21+
it('DELETEs the encoded registry id and returns the removed registry summary', async () => {
22+
const fetchMock = vi.fn().mockResolvedValue({
23+
ok: true,
24+
status: 200,
25+
json: async () => ({
26+
success: true,
27+
data: {
28+
registry: {
29+
id: 'acme-registry',
30+
name: 'Acme Registry',
31+
url: 'https://acme.example/registry',
32+
provenance: 'custom/unverified',
33+
trusted: false
34+
}
35+
},
36+
request_id: 'req-1'
37+
})
38+
})
39+
vi.stubGlobal('fetch', fetchMock)
40+
41+
const result = await api.removeRegistrySource('acme-registry')
42+
43+
expect(result.success).toBe(true)
44+
expect(result.registry?.id).toBe('acme-registry')
45+
expect(result.registry?.provenance).toBe('custom/unverified')
46+
47+
const [calledUrl, calledInit] = fetchMock.mock.calls[0]
48+
expect(calledUrl).toBe('/api/v1/registries/acme-registry')
49+
expect(calledInit.method).toBe('DELETE')
50+
expect((calledInit.headers as Record<string, string>)['X-API-Key']).toBe('test-key')
51+
})
52+
53+
it('percent-encodes a namespaced registry id in the path', async () => {
54+
const fetchMock = vi.fn().mockResolvedValue({
55+
ok: true,
56+
status: 200,
57+
json: async () => ({ success: true, data: { registry: { id: 'acme/inc' } } })
58+
})
59+
vi.stubGlobal('fetch', fetchMock)
60+
61+
await api.removeRegistrySource('acme/inc')
62+
63+
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/registries/acme%2Finc')
64+
})
65+
66+
it('surfaces registry_not_found (404) as a structured error', async () => {
67+
const fetchMock = vi.fn().mockResolvedValue({
68+
ok: false,
69+
status: 404,
70+
statusText: 'Not Found',
71+
json: async () => ({ success: false, error: 'no such registry', code: 'registry_not_found' })
72+
})
73+
vi.stubGlobal('fetch', fetchMock)
74+
75+
const result = await api.removeRegistrySource('ghost')
76+
77+
expect(result.success).toBe(false)
78+
expect(result.code).toBe('registry_not_found')
79+
})
80+
81+
it('surfaces registry_shadows_builtin (409 — built-in cannot be removed)', async () => {
82+
const fetchMock = vi.fn().mockResolvedValue({
83+
ok: false,
84+
status: 409,
85+
statusText: 'Conflict',
86+
json: async () => ({ success: false, error: 'built-in registry', code: 'registry_shadows_builtin' })
87+
})
88+
vi.stubGlobal('fetch', fetchMock)
89+
90+
const result = await api.removeRegistrySource('official')
91+
92+
expect(result.success).toBe(false)
93+
expect(result.code).toBe('registry_shadows_builtin')
94+
})
95+
96+
it('surfaces registries_locked (403) without emitting an auth error', async () => {
97+
const fetchMock = vi.fn().mockResolvedValue({
98+
ok: false,
99+
status: 403,
100+
statusText: 'Forbidden',
101+
json: async () => ({ success: false, error: 'locked', code: 'registries_locked' })
102+
})
103+
vi.stubGlobal('fetch', fetchMock)
104+
105+
const result = await api.removeRegistrySource('acme')
106+
107+
expect(result.success).toBe(false)
108+
expect(result.code).toBe('registries_locked')
109+
})
110+
})

0 commit comments

Comments
 (0)