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.