Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/features/connect-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
11 changes: 6 additions & 5 deletions frontend/src/components/AccessExplainer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 } }))
Expand Down
13 changes: 11 additions & 2 deletions frontend/src/components/ImportServers.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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, string> = {}): 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
Expand All @@ -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<string, string>)[name] ?? name))
importedNames.push(...actuallyImported(response.data, server_names, rename as Record<string, string>))
skipped.push(...(response.data?.skipped ?? []))
}
detectedMessage.value = importSummary({ imported, renamed, skipped })
Expand Down Expand Up @@ -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 {
Expand Down
26 changes: 20 additions & 6 deletions frontend/src/components/clients/UpgradeAdminKeyDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,21 @@

<!-- Step 3: what happened, and the step the dialog never does itself. -->
<div v-else-if="step === 'done'" class="space-y-3" data-test="upgrade-done-step" role="status" aria-live="polite">
<p v-if="applied" class="text-sm"><strong>{{ applied.upgraded.length }}</strong> upgraded<template v-if="applied.upgraded.length">: {{ applied.upgraded.join(', ') }}</template>.</p>
<p v-if="applied" class="text-sm" data-test="upgrade-summary"><strong>{{ applied.upgraded.length }}</strong> client{{ applied.upgraded.length === 1 ? '' : 's' }} upgraded<template v-if="applied.upgraded.length">: {{ applied.upgraded.join(', ') }}</template>.</p>
<p v-else class="text-sm">No client holds the admin key.</p>
<ul v-if="applied?.failed.length" class="text-sm text-error list-disc list-inside" data-test="upgrade-failed">
<li v-for="item in applied.failed" :key="item.client_id">{{ item.client_id }}: {{ item.error }}</li>
</ul>
<section class="rounded-box border border-warning/50 p-3 space-y-2" data-test="rotate-admin-key-panel">
<template v-if="hasFailures">
<p class="text-sm text-error" data-test="upgrade-failed-heading">{{ applied!.failed.length }} could not be upgraded:</p>
<ul class="text-sm text-error list-disc list-inside" data-test="upgrade-failed">
<li v-for="item in applied!.failed" :key="item.client_id">{{ item.client_id }}: {{ item.error }}</li>
</ul>
<!-- FR-025: rotating invalidates every copy that was not upgraded, so a
partial failure must not offer the rotate step. -->
<section class="rounded-box border border-warning/50 p-3 space-y-2" data-test="upgrade-retry">
<p class="text-sm">Fix the problem shown for each client, then run Upgrade admin-key clients again. Only the clients that still hold the admin key are changed. Do not rotate the admin API key yet: the clients above would stop working.</p>
<button type="button" class="btn btn-outline btn-sm" data-test="upgrade-try-again" @click="step = 'choose'">Try again</button>
</section>
</template>
<section v-else-if="rotateReady" class="rounded-box border border-warning/50 p-3 space-y-2" data-test="rotate-admin-key-panel">
<h4 class="font-semibold">Rotate the admin API key</h4>
<p class="text-sm">Upgraded clients no longer need the admin key, but every other copy of it still works until you replace it.</p>
<ol class="list-decimal list-inside text-sm space-y-1">
Expand All @@ -81,13 +90,14 @@
</ol>
<a class="link link-primary text-sm" :href="docsHref" target="_blank" rel="noopener noreferrer" data-test="rotate-admin-key-docs">How to rotate the admin API key</a>
</section>
<p v-else-if="applied" class="text-sm" data-test="upgrade-holders-remain">Some clients still hold the admin key, so do not rotate it yet. Review them in the <RouterLink class="link link-primary" :to="{ name: 'clients' }" @click="emit('close')">Clients list</RouterLink>.</p>
<div class="modal-action"><button type="button" class="btn btn-primary btn-sm" data-test="upgrade-close" @click="emit('close')">Close</button></div>
</div>
</BaseDialog>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import BaseDialog from '@/components/BaseDialog.vue'
import GuardRefusal from '@/components/GuardRefusal.vue'
import api, { type ApiError } from '@/services/api'
Expand All @@ -110,6 +120,10 @@ const error = ref('')
const stale = ref(false)
const previewData = ref<UpgradePreview | null>(null)
const applied = ref<UpgradeApplyResult | null>(null)
const hasFailures = computed(() => (applied.value?.failed?.length ?? 0) > 0)
// The backend sets next_step only once no admin-key holder remains, so it is the
// single signal that rotating is safe (on the apply path and the empty-preview path).
const rotateReady = computed(() => (applied.value ? !!applied.value.next_step : !!previewData.value?.next_step))
const docsHref = docsUrl('/configuration/config-file/')
const guardText = 'Binding every upgraded client to this profile would leave it reachable without authentication.'

Expand Down
16 changes: 13 additions & 3 deletions frontend/src/composables/useScopeQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,13 @@ const pageRouteNames: Partial<Record<PageId, string>> = {
'profile-editor': 'profile-editor',
}

// Routes linkTo can build but that pageIdForRouteName must keep reporting as
// outside the contract (Settings and a server detail host no scope chips).
const linkOnlyRouteNames: Partial<Record<PageId, string>> = {
settings: 'settings',
'server-detail': 'server-detail',
}

/** The page a route name belongs to (the inverse of the map above), or
* undefined for a route outside the contract (Settings, a server detail...).
* `tab` is the route's `?tab=` value, which splits the Clients route. */
Expand Down Expand Up @@ -394,7 +401,7 @@ export interface UseScopeQueryResult {
* reads only parameters independent of the conflicting pair (a page whose own
* state already resolved it while the URL write-back is still in flight). */
toRest: (opts?: { ignoreConflict?: boolean }) => Record<string, string> | null
linkTo: (page: PageId, patch?: Record<string, string>) => RouteLocationRaw
linkTo: (page: PageId, patch?: Record<string, string>, params?: Record<string, string>) => RouteLocationRaw
chips: ComputedRef<ScopeChip[]>
}

Expand Down Expand Up @@ -495,7 +502,9 @@ export function useScopeQuery(page: PageId): UseScopeQueryResult {
return out
}

function linkTo(target: PageId, patch: Record<string, string> = {}): RouteLocationRaw {
// `params` are the path params of a parameterised route (`profile-editor`'s
// `name`, `server-detail`'s `serverName`); they never reach the query.
function linkTo(target: PageId, patch: Record<string, string> = {}, params?: Record<string, string>): RouteLocationRaw {
const query: Record<string, string> = {}
// Rule 3: sticky params carry (profile/client/token only once available).
for (const def of registry.values()) {
Expand All @@ -509,7 +518,8 @@ export function useScopeQuery(page: PageId): UseScopeQueryResult {
// An empty patch value clears a sticky param (the caller names the subject
// for the target page and must not inherit a competing one: F5.1).
for (const key of Object.keys(query)) if (query[key] === '') delete query[key]
const name = pageRouteNames[target]
const name = pageRouteNames[target] ?? linkOnlyRouteNames[target]
if (name && params) return { name, params, query }
return name ? { name, query } : { path: `/${target}`, query }
}

Expand Down
29 changes: 24 additions & 5 deletions frontend/src/stores/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,15 +93,33 @@ export const useClientsStore = defineStore('clients', () => {
applyAll(allResponse)
if (response.success && Array.isArray(response.data?.clients)) {
warnings.value = response.data.warnings ?? []
// GET /clients is metadata-only. For rows whose detail was loaded via
// loadDetail(), keep every detail-resolved field (state, installed,
// connected, connection_unverified, config paths, sessions) and take
// only the presence metadata from the poll. The backend omits
// `sessions` when empty, so its absence is not a signal.
// Source-of-truth rule (#1451-10): the 30s presence poll owns
// `last_seen` and `active_sessions`. GET /clients is metadata-only, so
// while the poll's presence matches the row, keep every detail-resolved
// field (state, installed, connected, connection_unverified, config
// paths, sessions) so a poll never downgrades them (#1444). The backend
// omits `sessions` when empty, so its absence is not a signal. When the
// poll's presence differs, the detail-derived presence fields (state,
// connected, sessions) are stale: take them from the poll, forget the
// detail flag and reload the detail so the row converges on one source.
const previous = new Map(clients.value.map(client => [client.id, client]))
const stale: string[] = []
clients.value = response.data.clients.map(incoming => {
const existing = previous.get(incoming.id)
if (!existing || !detailLoaded.has(incoming.id)) return incoming
const presenceChanged =
(incoming.active_sessions ?? 0) !== (existing.active_sessions ?? 0) ||
(incoming.last_seen ?? null) !== (existing.last_seen ?? null)
if (presenceChanged) {
detailLoaded.delete(incoming.id)
stale.push(incoming.id)
return {
...incoming,
installed: existing.installed,
config_path: existing.config_path,
display_path: existing.display_path,
}
}
return {
...incoming,
state: existing.state,
Expand All @@ -113,6 +131,7 @@ export const useClientsStore = defineStore('clients', () => {
sessions: existing.sessions,
}
})
for (const id of stale) void loadDetail(id)
for (const id of [...detailLoaded]) {
if (!clients.value.some(client => client.id === id)) detailLoaded.delete(id)
}
Expand Down
25 changes: 25 additions & 0 deletions frontend/tests/unit/access-explainer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,31 @@ describe('AccessExplainer (Spec 108-i T094, FR-046)', () => {
expect(wrapper.emitted('close')).toBeTruthy()
})

it('fix routes go through the scope link map; only move_client stays unscoped (#1446-10)', async () => {
setAvailableFeatures(['scope_filters'])
;(api.explainAccess as any).mockResolvedValue({ ...FIXTURE, fixes: [
{ step: 'tier_cap', action: 'edit_token', target: 'ci', label: 'Fix edit_token' },
{ step: 'tier_cap', action: 'move_client', target: 'cursor', label: 'Fix move_client' },
] })
const router = makeRouter()
await router.push('/clients?profile=work-ro&client=cursor')
await router.isReady()
const wrapper = mount(AccessExplainer, { props: { open: false, subject: { kind: 'client', name: 'cursor' } }, global: { plugins: [router] } })
await wrapper.setProps({ open: true })
await flushPromises()
await run(wrapper)
await wrapper.get('[data-test="explain-fix-edit_token"]').trigger('click')
await flushPromises()
// The tokens page registers profile: the sticky filter carries, client does not.
expect(router.currentRoute.value.path).toBe('/tokens')
expect(router.currentRoute.value.query).toEqual({ profile: 'work-ro', token: 'ci' })
await router.push('/clients?profile=work-ro&client=cursor')
await wrapper.get('[data-test="explain-fix-move_client"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/clients')
expect(router.currentRoute.value.query).toEqual({ focus: 'cursor', move: '1' })
})

it('reconnect_client dispatches the connect event for the client', async () => {
;(api.explainAccess as any).mockResolvedValue({ ...FIXTURE, fixes: [{ step: 'credential', action: 'reconnect_client', target: 'cursor', label: 'Reconnect Cursor' }] })
const { wrapper } = await mountExplainer()
Expand Down
Loading
Loading