From 078b2dfd533233f6dfe3f136685678d54eb527ea Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Mon, 5 Oct 2026 20:28:49 +0300 Subject: [PATCH] fix(web): clients presence, upgrade done step, explainer scope and import names - clients store: the 30s presence poll owns last_seen/active_sessions; a change drops stale detail presence fields and reloads the detail (#1451) - UpgradeAdminKeyDialog: partial failure states what worked/failed, withholds the rotate panel and offers Try again; rotate only when next_step is set (#1446) - AccessExplainer: fix routes go through the scope link map except move_client (#1446) - ImportServers: emit only the names the core actually imported (#1506) --- docs/features/connect-clients.md | 2 +- frontend/src/components/AccessExplainer.vue | 11 ++--- frontend/src/components/ImportServers.vue | 13 +++++- .../clients/UpgradeAdminKeyDialog.vue | 26 +++++++++--- frontend/src/composables/useScopeQuery.ts | 16 ++++++-- frontend/src/stores/clients.ts | 29 ++++++++++--- frontend/tests/unit/access-explainer.spec.ts | 25 +++++++++++ .../unit/client-binding-controls.spec.ts | 36 ++++++++++++++-- .../tests/unit/clients-store-presence.spec.ts | 41 +++++++++++++++++-- .../unit/import-servers-completion.spec.ts | 24 ++++++++++- 10 files changed, 193 insertions(+), 30 deletions(-) diff --git a/docs/features/connect-clients.md b/docs/features/connect-clients.md index 8bde2f350..9a8ddfe61 100644 --- a/docs/features/connect-clients.md +++ b/docs/features/connect-clients.md @@ -92,7 +92,7 @@ touched and its live sessions are notified. | Replace a credential | Rotate on the row: a supported client previews the config change first; a custom client shows the new secret once and stays pending until you finalize | `mcpproxy client rotate cursor`, `client rotate ci-bot --finalize` | | Revoke a credential | Forget (optionally also remove the config entry) | `mcpproxy client forget cursor --disconnect` | -A client whose config still holds the instance admin API key (or no credential, or a revoked or expired one) is reported on the Clients page with a warning and by `mcpproxy doctor`. Nothing is rewritten automatically. **Upgrade clients holding the admin key** (Clients page, or `mcpproxy client upgrade-admin-key-holders`) previews, then replaces the admin key in every such client's config with a per-client credential; afterwards rotate the admin API key, which the action offers as its last step. A client without an active client credential cannot be bound to a profile until it is connected with one. +A client whose config still holds the instance admin API key (or no credential, or a revoked or expired one) is reported on the Clients page with a warning and by `mcpproxy doctor`. Nothing is rewritten automatically. **Upgrade clients holding the admin key** (Clients page, or `mcpproxy client upgrade-admin-key-holders`) previews, then replaces the admin key in every such client's config with a per-client credential; afterwards rotate the admin API key, which the action offers as its last step only once no client holds the key any more. If some clients could not be upgraded, the dialog lists what succeeded and what failed, withholds the rotate step (rotating would break the failed clients) and offers Try again; only clients that still hold the admin key are changed on a retry. A client without an active client credential cannot be bound to a profile until it is connected with one. The credential is shown masked everywhere except the single moment it is created for a custom client. It is valid on MCP endpoints only; a client can never use it to read activity, config or other clients over REST. diff --git a/frontend/src/components/AccessExplainer.vue b/frontend/src/components/AccessExplainer.vue index 53ee4a61a..cc1247844 100644 --- a/frontend/src/components/AccessExplainer.vue +++ b/frontend/src/components/AccessExplainer.vue @@ -70,7 +70,6 @@ import { useProfilesStore } from '@/stores/profiles' import { useScopeQuery } from '@/composables/useScopeQuery' import { CONNECT_CLIENT_EVENT } from '@/navigation/navModel' import { STEP_LABELS, describeError, reasonText } from '@/utils/profiles' -import { profileEditorLink } from '@/utils/profileRoute' import type { AccessExplanation, ExplainSubjectQuery } from '@/types/api' // Spec 108-i T099 / FR-046: "Why can't this client use this tool?" The steps are @@ -175,22 +174,24 @@ function follow(fix: { action: string; target: string }) { case 'allow_in_profile': case 'classify_in_profile': case 'add_server_to_profile': - void router.push(profileEditorLink(fix.target, tool)) + void router.push(scope.linkTo('profile-editor', { focus: tool }, { name: fix.target })) break case 'move_client': + // Deliberately unscoped: a sticky profile/client filter could hide the + // very client row this fix is about to move. void router.push({ name: 'clients', query: { focus: fix.target, move: '1' } }) break case 'edit_token': void router.push(scope.linkTo('tokens', { token: fix.target })) break case 'enable_server': - void router.push({ name: 'server-detail', params: { serverName: fix.target || server } }) + void router.push(scope.linkTo('server-detail', {}, { serverName: fix.target || server })) break case 'approve_tool': - void router.push({ name: 'review', query: { server: fix.target || server } }) + void router.push(scope.linkTo('review', { server: fix.target || server })) break case 'change_setting': - void router.push({ path: '/settings', query: { tab: 'security', focus: fix.target } }) + void router.push(scope.linkTo('settings', { tab: 'security', focus: fix.target })) break case 'reconnect_client': window.dispatchEvent(new CustomEvent(CONNECT_CLIENT_EVENT, { detail: { client: fix.target } })) diff --git a/frontend/src/components/ImportServers.vue b/frontend/src/components/ImportServers.vue index 883057c38..42f536393 100644 --- a/frontend/src/components/ImportServers.vue +++ b/frontend/src/components/ImportServers.vue @@ -171,6 +171,15 @@ async function loadDetectedSources(clearMessage = true) { finally { detectedLoading.value = false } } +// The names the core actually imported, so a server skipped as already_exists +// (possibly still quarantined from an earlier run) is never reported as +// "just imported". An old core that returns no list falls back to the request. +function actuallyImported(data: ImportResponse | undefined, requested: string[], rename: Record = {}): string[] { + if (Array.isArray(data?.imported) && data.imported.length > 0) return data.imported.map(server => server.name) + const skipped = new Set([...(data?.skipped ?? []), ...(data?.failed ?? [])].map(item => item.name)) + return requested.map(name => rename[name] ?? name).filter((name, i) => !skipped.has(name) && !skipped.has(requested[i])) +} + async function importDetected() { detectedImporting.value = true detectedError.value = null @@ -190,7 +199,7 @@ async function importDetected() { const response = await api.importServersFromPath({ path: source.path, format: source.format, server_names, rename: Object.keys(rename).length ? rename : undefined, skip_quarantine: !detectedQuarantine.value }) if (!response.success) throw new Error(response.error || `Could not import ${source.name}`) imported += response.data?.summary?.imported ?? server_names.length - importedNames.push(...server_names.map(name => (rename as Record)[name] ?? name)) + importedNames.push(...actuallyImported(response.data, server_names, rename as Record)) skipped.push(...(response.data?.skipped ?? [])) } detectedMessage.value = importSummary({ imported, renamed, skipped }) @@ -266,7 +275,7 @@ async function handleImport() { addError.value = resp.error || 'Import failed' return } - emit('imported', resp.data?.summary?.imported ?? resp.data?.imported?.length ?? names.length, names) + emit('imported', resp.data?.summary?.imported ?? resp.data?.imported?.length ?? names.length, actuallyImported(resp.data, names)) } catch (e) { addError.value = e instanceof Error ? e.message : 'Import failed' } finally { diff --git a/frontend/src/components/clients/UpgradeAdminKeyDialog.vue b/frontend/src/components/clients/UpgradeAdminKeyDialog.vue index e8e0e8d2e..c31813c51 100644 --- a/frontend/src/components/clients/UpgradeAdminKeyDialog.vue +++ b/frontend/src/components/clients/UpgradeAdminKeyDialog.vue @@ -67,12 +67,21 @@
-

{{ applied.upgraded.length }} upgraded.

+

{{ applied.upgraded.length }} client{{ applied.upgraded.length === 1 ? '' : 's' }} upgraded.

No client holds the admin key.

-
    -
  • {{ item.client_id }}: {{ item.error }}
  • -
-
+ +

Rotate the admin API key

Upgraded clients no longer need the admin key, but every other copy of it still works until you replace it.

    @@ -81,13 +90,14 @@
How to rotate the admin API key
+

Some clients still hold the admin key, so do not rotate it yet. Review them in the Clients list.