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 ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1036,4 +1036,4 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—`
| [106-security-residual-fixes](./specs/106-security-residual-fixes/) | `shipped` | 18/19 (95%) |
| [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | `shipped` | 126/126 (100%) |
| [108-profiles-v3](./specs/108-profiles-v3/) | `in-flight` | 23/153 (15%) |
| [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | `in-flight` | 2/179 (1%) |
| [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | `in-flight` | 3/180 (2%) |
57 changes: 54 additions & 3 deletions frontend/src/components/CatalogSearch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<div v-else-if="error" class="alert alert-error text-sm" data-test="catalog-error">{{ error }}</div>

<template v-else>
<div v-if="addError && !pendingResult" class="alert alert-error text-sm mb-3" data-test="catalog-add-error">{{ addError }}</div>
<div v-if="unavailable.length > 0" class="alert alert-warning text-sm mb-3" data-test="catalog-unavailable-notice">
<span>{{ unavailable.map((u) => `${u.source} (${u.reason})`).join(', ') }} unavailable</span>
</div>
Expand Down Expand Up @@ -100,7 +101,7 @@
<button
type="button"
class="btn btn-primary"
:disabled="!allPendingValuesFilled || confirming"
:disabled="!allPendingValuesFilled || confirming || hasUnavailableSecret"
data-test="catalog-secrets-confirm"
@click="confirmAdd"
>
Expand All @@ -124,6 +125,7 @@ import { serverDetailPath } from '@/utils/serverRoute'

const props = defineProps<{ source?: string }>()
const emit = defineEmits<{ added: [name: string] }>()
const router = useRouter()

const query = ref('')
const loading = ref(false)
Expand Down Expand Up @@ -193,6 +195,15 @@ const allPendingValuesFilled = computed(() => {
return inputs.every((i) => (pendingValues[i.name] || '').trim() !== '')
})

// Until the user-approved plain-text confirmation flow is implemented, fail
// closed: an unavailable keyring may not turn a Secret selection into raw
// configuration by accident.
const hasUnavailableSecret = computed(() =>
!keyringAvailable.value && (pendingResult.value?.required_inputs || []).some(
(input) => (pendingModes[input.name] || (input.secret_like ? 'secret' : 'value')) === 'secret'
)
)

function handleAdd(result: CatalogResult) {
const key = `${result.source}-${result.id}`
if (result.required_inputs && result.required_inputs.length > 0) {
Expand Down Expand Up @@ -270,6 +281,43 @@ async function addResult(result: CatalogResult, key: string, env: Record<string,
}
}

function installTarget(result: CatalogResult): string {
if (result.install.url) return `url:${result.install.url}`
return `stdio:${result.install.command || ''}\u0000${(result.install.args || []).join('\u0000')}`
}

function serverTarget(server: { url?: string; command?: string; args?: string[] }): string {
if (server.url) return `url:${server.url}`
return `stdio:${server.command || ''}\u0000${(server.args || []).join('\u0000')}`
}

async function openPreviouslyAdded(result: CatalogResult): Promise<void> {
// GET /servers redacts credential-bearing URL query values and argv, so an
// exact install-target comparison can fail even though the server-authoritative
// catalog response already established a unique visible match.
if (result.added_server_name) {
await router.push(serverDetailPath(result.added_server_name))
return
}
const response = await api.getServers()
if (!response.success || !response.data) {
addError.value = response.error || 'Could not resolve the installed server. Refresh and try again.'
return
}
const target = installTarget(result)
const matches = response.data.servers.filter((server) =>
serverTarget(server) === target &&
(server.source_registry_id === result.source || !server.source_registry_id)
)
if (matches.length === 1) {
await router.push(serverDetailPath(matches[0].name))
return
}
addError.value = matches.length === 0
? 'This catalog entry is marked added, but MCPProxy could not identify one visible installed server. Open it from Servers.'
: 'More than one installed server matches this catalog entry. Open the intended server from Servers.'
}

// CatalogResultCard is a small local functional-ish component (kept in this
// file rather than a separate SFC: it is presentational-only and has no
// reason to be reused outside CatalogSearch).
Expand All @@ -282,7 +330,6 @@ const CatalogResultCard = defineComponent({
},
emits: ['add'],
setup(cardProps, { emit: cardEmit }) {
const router = useRouter()
return () => {
const r = cardProps.result
const key = `${r.source}-${r.id}`
Expand Down Expand Up @@ -316,7 +363,11 @@ const CatalogResultCard = defineComponent({
class: `btn btn-sm ${added ? 'btn-success' : 'btn-primary'}`,
disabled: cardProps.busy,
'data-test': `catalog-add-${r.source}-${r.id}`,
onClick: () => (added && addedName ? router.push(serverDetailPath(addedName)) : cardEmit('add')),
onClick: () => {
if (!added) return cardEmit('add')
if (addedName) return router.push(serverDetailPath(addedName))
void openPreviouslyAdded(r)
},
},
added ? 'Added ✓ · Open' : cardProps.busy ? 'Adding…' : 'Add to MCPProxy'
),
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/components/ManualServerForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ import { resolveSecretFields, rollbackSecrets } from '@/composables/useSecretFie
import { useServersStore } from '@/stores/servers'
import { serverDetailPath } from '@/utils/serverRoute'

const props = withDefaults(defineProps<{ navigateAfterAdd?: boolean }>(), {
navigateAfterAdd: true,
})
const emit = defineEmits<{ added: [name: string] }>()

const router = useRouter()
Expand Down Expand Up @@ -158,7 +161,9 @@ async function handleSubmit() {

await serversStore.addServer(serverData)
emit('added', name.value)
void router.push(serverDetailPath(name.value))
if (props.navigateAfterAdd) {
void router.push(serverDetailPath(name.value))
}
} catch (e) {
// The secret write (if any) succeeded but something after it failed —
// don't leave an orphaned keyring entry behind, and let a retry reuse
Expand Down
17 changes: 9 additions & 8 deletions frontend/src/components/OnboardingWizard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,9 @@
Add a server manually
</button>
</div>
<div v-if="addServerOpen" class="text-left mt-4" data-test="wizard-manual-form">
<ManualServerForm :navigate-after-add="false" @added="onServerAdded" />
</div>
<p v-if="serverAddedJustNow" class="text-xs text-success mt-3">
✓ Server added — it's currently in quarantine. Review it on the Servers page after this wizard.
</p>
Expand Down Expand Up @@ -400,12 +403,16 @@
</summary>
<div class="mt-3">
<button
v-if="!addServerOpen"
class="btn btn-primary btn-sm w-full"
@click="openAddServer"
data-test="add-server-button"
>
Open the add-server form
</button>
<div v-else data-test="wizard-manual-form">
<ManualServerForm :navigate-after-add="false" @added="onServerAdded" />
</div>
<p v-if="serverAddedJustNow" class="text-xs text-success mt-2">
✓ Server added — it's currently in quarantine. Review it on the Servers page after this wizard.
</p>
Expand Down Expand Up @@ -673,12 +680,6 @@
<form method="dialog" class="modal-backdrop" @click.prevent="dismiss"><button>close</button></form>
</dialog>

<!-- Embedded AddServerModal for the server tab -->
<AddServerModal
:show="addServerOpen"
@close="addServerOpen = false"
@added="onServerAdded"
/>
</template>

<script setup lang="ts">
Expand All @@ -688,7 +689,7 @@ import api from '@/services/api'
import { useOnboardingStore } from '@/stores/onboarding'
import { useSystemStore } from '@/stores/system'
import { useServersStore } from '@/stores/servers'
import AddServerModal from '@/components/AddServerModal.vue'
import ManualServerForm from '@/components/ManualServerForm.vue'
import { useDialogOpen } from '@/composables/useDialogOpen'
import { skipReasonLabel } from '@/utils/importSkipReason'
import type { ClientStatus, ActivityRecord, ConnectPreview, ImportedServer } from '@/types'
Expand Down Expand Up @@ -1135,7 +1136,7 @@ function goBack() {
// Dashboard first, and the wizard would spring back open on return.
async function goToRegistry() {
await dismiss()
await router.push('/repositories')
await router.push('/add-server?tab=catalog')
}

// Review round 5: the Review/Settings/Servers links below used a plain
Expand Down
13 changes: 0 additions & 13 deletions frontend/src/components/SecretToggle.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@
</template>

<script setup lang="ts">
import { watch } from 'vue'
import { looksSecret } from '@/utils/secretLike'

// One Value/Secret toggle for a single env var or header field (Spec 109
Expand All @@ -66,17 +65,5 @@ function setMode(mode: 'value' | 'secret') {
emit('update:mode', mode)
}

// Re-default to 'value' if the keyring becomes unavailable while a field was
// already in 'secret' mode (e.g. a slow keyring-availability probe resolves
// after the field's initial default was applied).
watch(
() => props.keyringAvailable,
(available) => {
if (!available && props.mode === 'secret') {
emit('update:mode', 'value')
}
}
)

defineExpose({ defaultMode: () => (looksSecret(props.name) ? 'secret' : 'value') })
</script>
13 changes: 2 additions & 11 deletions frontend/src/components/TopHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,8 @@
</button>
</div>

<!-- Add Server Button. Spec 107 cross-review round 3, chunk 4 P2:
this always submitted through the generic AddServerModal, whose
serversStore.addServer() calls the core POST /api/v1/tools/call
dispatch door — a mandatory tenant-session refusal
(rest-endpoints.md §8) — so a tenant clicking their own labeled
"Add Personal Server" button always drew a 403 the API client
mistakes for an auth failure. /my/servers (UserServers.vue) is
the working tenant flow, wired to POST /api/v1/user/servers;
hidden here rather than rewired, matching the ModeSwitcher
precedent below (FR-041: tenant-inapplicable controls are
hidden, never issued-and-403'd). -->
<!-- Spec 107 FR-041: tenants use /my/servers, which writes through
the tenant-scoped API. Keep this admin add flow hidden for them. -->
<button
v-if="authStore.principalKind !== 'tenant'"
@click="router.push('/add-server')"
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ export interface ServerIsolationDefaults {

export interface Server {
name: string
/** Catalog provenance, used only to resolve an already-added catalog card. */
source_registry_id?: string
// Human-friendly display label from the source registry (MCP-1112). When
// present it is preferred over `name` for display; `name` stays the stable
// identifier used for routing and API calls (it may be a reverse-DNS id such
Expand Down Expand Up @@ -877,6 +879,8 @@ export interface CatalogResult {
required_inputs?: CatalogInput[]
source_code_url?: string
added: boolean
/** Unique visible installed server selected by the backend before redaction. */
added_server_name?: string
}

export interface CatalogSourceError {
Expand Down
27 changes: 3 additions & 24 deletions frontend/src/views/Home.vue
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
<button @click="showConnectModal = true" class="btn btn-primary btn-sm w-full gap-1">
Connect Clients
</button>
<button @click="showAddServer = true" class="btn btn-secondary btn-outline btn-sm w-full gap-1">
<button @click="router.push('/add-server?tab=import')" class="btn btn-secondary btn-outline btn-sm w-full gap-1" data-test="dashboard-import-configs">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Expand Down Expand Up @@ -257,11 +257,9 @@
</div>
</router-link>

<!-- Right Action Buttons. Spec 107 cross-review round 3, chunk 4 P2:
same broken AddServerModal path as the other Add Server
buttons on this page. -->
<!-- Right Action Buttons. -->
<div class="flex flex-col gap-2 w-full max-w-[240px] pt-3">
<button v-if="authStore.principalKind !== 'tenant'" @click="showAddServer = true" class="btn btn-primary btn-sm w-full gap-1" data-test="dashboard-right-add-server">
<button v-if="authStore.principalKind !== 'tenant'" @click="router.push('/add-server')" class="btn btn-primary btn-sm w-full gap-1" data-test="dashboard-right-add-server">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Expand Down Expand Up @@ -360,13 +358,11 @@

<!-- Modals -->
<ConnectModal :show="showConnectModal" @close="showConnectModal = false" />
<AddServerModal :show="showAddServer" @close="showAddServer = false" @added="handleServerAdded" />
<OnboardingWizard :show="onboardingStore.wizardOpen" @close="onboardingStore.closeWizard" />
</div>
</template>

<script setup lang="ts">
import { serverDetailPath } from '@/utils/serverRoute'
import { computed, nextTick, ref, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useServersStore } from '@/stores/servers'
Expand All @@ -381,7 +377,6 @@ import TelemetryBanner from '@/components/TelemetryBanner.vue'
import UpdateBanner from '@/components/UpdateBanner.vue'
import TokenPieChart from '@/components/TokenPieChart.vue'
import ConnectModal from '@/components/ConnectModal.vue'
import AddServerModal from '@/components/AddServerModal.vue'
import OnboardingWizard from '@/components/OnboardingWizard.vue'
import AttentionList from '@/components/AttentionList.vue'
import UsageSummaryStrip from '@/components/UsageSummaryStrip.vue'
Expand All @@ -401,7 +396,6 @@ const router = useRouter()

// Modal state
const showConnectModal = ref(false)
const showAddServer = ref(false)

// Auto-refresh interval
let refreshInterval: ReturnType<typeof setInterval> | null = null
Expand Down Expand Up @@ -598,21 +592,6 @@ const openTokenSavingsDetails = () => {
// directly instead: switched off, and not already spoken for by quarantine.
const disabledCount = computed(() => serversStore.serverCount.disabled)

// --- Add Server handler ---
const handleServerAdded = (serverName?: string) => {
showAddServer.value = false
serversStore.fetchServers()
// UX audit F07: a single add hands off to that server's detail view, where
// connect/scan/review/approve is already on screen. The bulk/import path
// emits no name and keeps the old refresh-in-place behaviour.
if (serverName) {
// The modal already toasted "<name> has been added successfully"; the
// generic toast below would be a second one for the same add.
void router.push(serverDetailPath(serverName))
return
}
systemStore.addToast({ type: 'success', title: 'Server Added', message: 'New server has been added successfully' })
}

// --- Formatters ---
const formatNumber = (num: number): string => {
Expand Down
19 changes: 2 additions & 17 deletions frontend/src/views/Servers.vue
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@
</p>
<div class="flex flex-wrap gap-2 justify-center">
<button
@click="showAddServer = true"
@click="router.push('/add-server')"
class="btn btn-primary"
data-test="servers-empty-add"
>
Expand All @@ -204,7 +204,7 @@
Add Server
</button>
<router-link
to="/repositories"
to="/add-server?tab=catalog"
class="btn btn-outline"
data-test="servers-empty-registry"
>
Expand Down Expand Up @@ -315,7 +315,6 @@
<!-- Hints Panel (Bottom of Page) -->
<CollapsibleHintsPanel :hints="serversHints" />

<AddServerModal :show="showAddServer" @close="showAddServer = false" @added="onServerAdded" />
</div>
</template>

Expand All @@ -329,8 +328,6 @@ import { useOnboardingStore } from '@/stores/onboarding'
import api from '@/services/api'
import type { Server } from '@/types'
import ServerCard from '@/components/ServerCard.vue'
import AddServerModal from '@/components/AddServerModal.vue'
import { serverDetailPath } from '@/utils/serverRoute'
import CollapsibleHintsPanel from '@/components/CollapsibleHintsPanel.vue'
import type { Hint } from '@/components/CollapsibleHintsPanel.vue'
import { useSecurityScannerStatus } from '@/composables/useSecurityScannerStatus'
Expand All @@ -348,7 +345,6 @@ const scopeQuery = useScopeQuery('servers')
const filter = ref<ServerFilter>('all')
const searchQuery = ref('')
const scanAllRunning = ref(false)
const showAddServer = ref(false)
const { hasEnabledScanners } = useSecurityScannerStatus()

// Spec 109 FR-013: the server card's stats line (last call, 24h errors)
Expand Down Expand Up @@ -429,17 +425,6 @@ const hasServers = computed(() => serversStore.servers.length > 0)
// yet" — `servers` starts empty either way.
const isFirstRun = computed(() => serversStore.loaded && serversStore.servers.length === 0)

function onServerAdded(serverName?: string) {
showAddServer.value = false
void serversStore.fetchServers()
// UX audit F07: a single add hands off to that server's detail view, where
// connect/scan/review/approve is already on screen. The bulk/import path
// emits no name and keeps the old refresh-in-place behaviour.
if (serverName) {
void router.push(serverDetailPath(serverName))
}
}

// The setup wizard is mounted by Dashboard.vue, so opening it from here means
// navigating there first; the store carries the tab request across the hop.
function openImportWizard() {
Expand Down
Loading
Loading