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
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1035,7 +1035,7 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—`
| [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | `in-flight` | 94/113 (83%) |
| [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/) | `shipped` | 192/193 (99%) |
| [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | `shipped` | 233/234 (100%) |
| [108-profiles-v3](./specs/108-profiles-v3/) | `shipped` | 194/195 (99%) |
| [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | `shipped` | 239/240 (100%) |
| [110-catalog-popularity](./specs/110-catalog-popularity/) | `in-flight` | 19/23 (83%) |
| [112-client-header-forwarding](./specs/112-client-header-forwarding/) | `shipped` | 38/40 (95%) |
2 changes: 1 addition & 1 deletion docs/cli/catalog-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ mcpproxy upstream add github https://api.githubcopilot.com/mcp/ --secret-header
mcpproxy upstream add weather --secret-env WEATHER_API_KEY=abc123 -- npx -y weather-mcp
```

`--secret-env` and `--secret-header` write the value to the keyring and store `${keyring:<server>-env-<name>}` in the config. The Web UI and macOS Add Server forms offer the same choice as a **Value · Secret** toggle that defaults to Secret for names like `*_TOKEN`, `*_KEY`, `*SECRET*` and `*PASSWORD*`. See [Keyring Integration](/features/keyring-integration).
`--secret-env` and `--secret-header` write the value to the keyring and store `${keyring:<server>-env-<name>}` in the config. The Web UI and macOS Add Server forms offer the same choice as a **Value · Secret** toggle that defaults to Secret for names like `*_TOKEN`, `*_KEY`, `*SECRET*` and `*PASSWORD*`. The value is masked while you type it (Show reveals it). See [Keyring Integration](/features/keyring-integration).

## Catalog sources and the older commands

Expand Down
4 changes: 4 additions & 0 deletions docs/development/release-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ page disagree):
at 1440 and 900 px: the token Profile chip is one line inside its cell, and
radio labels sit next to their radios in the create-profile, custom-client and
bulk-move dialogs.
- `usertest-web-fixes.spec.ts`: the first-run user test findings, at 1440 and
900 px: the header status pill names servers awaiting review, a secret-like
Manual-add value is masked with a Show toggle, and profile Try it is readable
and labels unsaved edits.

Setup is not duplicated in YAML: the job calls
[`scripts/run-web-smoke.sh`](https://github.com/smart-mcp-proxy/mcpproxy-go/blob/main/scripts/run-web-smoke.sh),
Expand Down
2 changes: 2 additions & 0 deletions docs/development/web-ui-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ Alongside it, [`e2e/web-ui-sweep/visual-a11y-sweep.spec.ts`](https://github.com/

[`e2e/web-ui-sweep/demo-ux-fixes.spec.ts`](https://github.com/smart-mcp-proxy/mcpproxy-go/blob/main/e2e/web-ui-sweep/demo-ux-fixes.spec.ts) guards the two pixel-level findings of the live Web UI demo (Spec 108 T149 and T151). It seeds a profile titled `Work Read-only for very long client names 2026` and a token pinned to it, then at 1440x900 and 900x900 checks that the token's Profile chip is a single line inside its table cell with the full title on hover (a Range over its text reports one line, and its scroll height does not exceed its client height), and that the radio labels of the create-profile, custom-client and bulk-move dialogs start within 16 px of their radios. It attaches screenshots to the report.

[`e2e/web-ui-sweep/usertest-web-fixes.spec.ts`](https://github.com/smart-mcp-proxy/mcpproxy-go/blob/main/e2e/web-ui-sweep/usertest-web-fixes.spec.ts) guards three findings of the first-run user test (Spec 109 T202 and T201, Spec 108 T169) at 1440x900 and 900x900: the header status pill says "awaiting review" for the sweep's quarantined server (compact form keeps the text in its title, no sideways scroll), a secret-like Manual-add env value is a password input with a Show toggle (never submitted), and profile Try it shows no `[object Object]`, says it uses unsaved edits, and the tool counts read "Saved profile:" while a Deny toggle is unsaved. The wizard import flow is covered by vitest and live QA, because the smoke core uses the real HOME for client paths.

The release QA gate runs this exact script on every tag as its **advisory** `web-ui-sweep` job — see [Release Gate](release-gate.md#web-ui-sweep-t2--advisory). Extend the committed sweep when you add a screen worth guarding on releases; use the ad-hoc pattern below for the deeper, spec-specific verification that ships beside a spec.

## Ad-hoc, spec-specific verification
Expand Down
1 change: 1 addition & 0 deletions docs/features/config-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ When importing, MCPProxy checks for existing servers by name:
- **Existing servers are skipped** by default
- Skipped servers appear in the `skipped` array with reason
- Use the preview mode to see what will be imported before committing
- Previewing a client config that holds no MCP servers (an empty file, `{}`, or an empty server list) answers with "no servers" (an empty `imported` list) instead of an error; importing it still reports that no servers were found

## Security Considerations

Expand Down
2 changes: 1 addition & 1 deletion docs/features/profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ It lists the bindings concerned and two fixes: turn `require_mcp_auth` on, or se
| Try a draft | Try it | Try it | `profile try` | not offered | `POST /profiles/try` |
| Explain access | Explain access | Explain access | `access explain` | `profiles explain` | `GET /access/explain` |

**Try it** runs a real `retrieve_tools` under the unsaved draft and shows what is returned and what is hidden with reasons, without saving anything.
**Try it** runs a real `retrieve_tools` under the unsaved draft and shows what is returned and what is hidden with reasons, without saving anything. The tool table and its visible/hidden counts show the saved profile; while you have unsaved edits they are labelled "Saved profile". Try it says whether it used your unsaved edits.

**Deleting a profile** that clients, tokens or `anonymous_profile` still use is refused with `409 profile_in_use` (listing who) until you give `reassign_to`; `force` leaves the references dangling, which denies everything and never widens. A profile that is the `anonymous_profile` is refused even with `force`. **Renaming** moves every pin, binding, `switchable_to` and `anonymous_profile` reference to the new name.

Expand Down
2 changes: 1 addition & 1 deletion docs/web-ui/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Settings, Docs, Feedback and the theme switch sit below the groups. **Clients**

## The header

At 1100 px and wider the header shows, left to right: the search field (`⌘K`), the status pill (for example "1 of 2 online, 14 tools, Retrieve"), the attention pill and the **+ Add** menu. Narrower widths collapse each into an icon; nothing clips or scrolls sideways down to 390 px.
At 1100 px and wider the header shows, left to right: the search field (`⌘K`), the status pill (for example "1 of 2 online, 14 tools, Retrieve"; when servers are waiting for review it says so, "0 online, 4 awaiting review", and it shows "Loading servers…" until the first list arrives), the attention pill and the **+ Add** menu. Narrower widths collapse each into an icon; nothing clips or scrolls sideways down to 390 px.

- **Search and `⌘K`** (`Ctrl+K` on Windows and Linux; `/` when no input is focused) opens the command palette. It searches pages, servers, tools, settings and actions. Pressing Enter on free text opens the Tools page with that text as the query.
- **+ Add** offers **Server** (the catalog-first Add Server page), **Client** (the connect dialog with the diff preview), **Token** (the create-token dialog) and **Profile** once profiles are available. Connecting a new client is at most two clicks from any page.
Expand Down
2 changes: 1 addition & 1 deletion e2e/web-ui-sweep/navigation-consistency.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ for (const width of WIDTHS) {
expect(visible.includes('header-drawer-toggle'), `drawer toggle visibility on ${where}`).toBe(width < 1024)

const pill = (await header.locator('[data-test="header-status-pill"]').innerText()).trim()
if (wide) expect(pill, `status pill on ${where}`).toMatch(/\d+ of \d+ online/)
if (wide) expect(pill, `status pill on ${where}`).toMatch(/\d+ (of \d+ )?online/)
else expect(pill, `status pill on ${where}`).toMatch(/^\s*●?\s*\d+\/\d+\s*$/)

const add = (await header.locator('[data-test="header-add-menu"]').innerText()).trim()
Expand Down
126 changes: 126 additions & 0 deletions e2e/web-ui-sweep/usertest-web-fixes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// First-run user test fixes sweep (Spec 109 T199-T204, Spec 108 T169; plan
// fix-usertest-web).
//
// Drives the Web UI served by a REAL mcpproxy binary and checks the three
// findings that are about what a user SEES:
// - the header status pill says "N awaiting review" instead of reading a
// review-pending install as "0 of N online" (compact form at 900px keeps
// the full text in its title and never scrolls the header sideways);
// - a secret-like env value typed in Manual add is masked, with a Show/Hide
// toggle that changes display only;
// - profile Try it never prints "[object Object]", says it uses unsaved
// edits, and the tool counts are labelled as the saved profile.
// The setup wizard's import flow is covered by vitest plus live QA: this
// launcher's core uses the real HOME for canonical client paths.
//
// Launcher: scripts/run-web-smoke.sh (docs/development/web-ui-verification.md).
import { test, expect, Page } from '@playwright/test'
import path from 'node:path'
import { SERVER, api, cleanupProfiles } from './profiles-seed'

const BASE = process.env.MCPPROXY_BASE_URL || 'http://127.0.0.1:18080'
const KEY = process.env.MCPPROXY_API_KEY || ''
const REPORT_DIR = process.env.SWEEP_REPORT_DIR || './playwright-report'

const TRY_PROFILE = 'e2e-try'
const FAKE_SECRET = 'fake-secret-value'
const VIEWPORTS = [
{ width: 1440, height: 900 },
{ width: 900, height: 900 },
]

function url(route: string): string {
const sep = route.includes('?') ? '&' : '?'
return KEY ? `${BASE}/ui${route}${sep}apikey=${encodeURIComponent(KEY)}` : `${BASE}/ui${route}`
}

async function open(page: Page, route: string) {
await page.goto(url(route))
await page.waitForLoadState('domcontentloaded')
const closeWizard = page.locator('[data-test="close-wizard"]')
if (await closeWizard.isVisible().catch(() => false)) await closeWizard.click()
await page.locator('main').first().waitFor({ state: 'visible' })
}

async function shot(page: Page, name: string, label: string) {
await page.screenshot({ path: path.join(REPORT_DIR, `usertest-web-fixes-${name}-${label}.png`), fullPage: false })
}

test.describe.configure({ mode: 'serial' })
test.skip(!SERVER, 'needs a fixture upstream (SWEEP_SERVER_NAME)')

test.beforeAll(async () => {
await cleanupProfiles([TRY_PROFILE])
await api('POST', '/profiles', { name: TRY_PROFILE, title: 'E2E Try', servers: [SERVER], max_tier: 'read' })
})
test.afterAll(async () => {
await cleanupProfiles([TRY_PROFILE])
})

for (const viewport of VIEWPORTS) {
const label = `${viewport.width}x${viewport.height}`
const wide = viewport.width >= 1100

test(`status pill names servers awaiting review at ${label}`, async ({ page }) => {
await page.setViewportSize(viewport)
await open(page, '/')
const pill = page.locator('[data-test="header-status-pill"]')
await expect(pill).toBeVisible()
// The sweep config has a quarantined server, so review is pending.
if (wide) {
await expect(pill).toContainText(/\d+ online · \d+ awaiting review/)
} else {
await expect(page.locator('[data-test="header-status-compact"]')).toHaveText(/^\d+\/\d+$/)
await expect(pill).toHaveAttribute('title', /awaiting review/)
}
const overflow = await page.evaluate(() => document.scrollingElement!.scrollWidth - document.scrollingElement!.clientWidth)
expect(overflow, `page scrolls sideways at ${label}`).toBeLessThanOrEqual(1)
await shot(page, 'pill', label)
})

test(`Manual add masks a secret-like value while typing at ${label}`, async ({ page }) => {
await page.setViewportSize(viewport)
await open(page, '/add-server?tab=manual')
await page.locator('[data-test="manual-type-stdio"]').check()
await page.locator('[data-test="manual-env-add"]').click()
await page.locator('[data-test="manual-env-name-0"]').fill('API_TOKEN')
const input = page.locator('[data-test="secret-toggle-value-input"]').first()
await input.fill(FAKE_SECRET)

await expect(input).toHaveAttribute('type', 'password')
expect(await page.locator('body').innerText()).not.toContain(FAKE_SECRET)

const reveal = page.locator('[data-test="secret-toggle-reveal"]').first()
await reveal.click()
await expect(input).toHaveAttribute('type', 'text')
await shot(page, 'secret-revealed', label)
await reveal.click()
await expect(input).toHaveAttribute('type', 'password')
await shot(page, 'secret-masked', label)
// Never submitted: the sweep must not add a server.
})

test(`profile Try it is readable and labels unsaved edits at ${label}`, async ({ page }) => {
await page.setViewportSize(viewport)
await open(page, `/profiles/${TRY_PROFILE}`)
await expect(page.locator('[data-test="profile-tool-counts"]')).toBeVisible()
await expect(page.locator('[data-test="profile-try-source"]')).toHaveText('Uses the saved profile')

// Deny the first row (unsaved edit).
await page.locator('[data-test^="tool-deny-"]').first().check()
await expect(page.locator('[data-test="profile-tool-unsaved-note"]')).toBeVisible()
await expect(page.locator('[data-test="profile-tool-counts"]')).toContainText(/^Saved profile:/)

await page.locator('[data-test="profile-try-query"]').fill('read')
await page.locator('[data-test="profile-try-run"]').click()
const results = page.locator('[data-test="profile-try-results"]')
await expect(results).toContainText('Hidden by profile')
expect(await results.innerText()).not.toContain('[object Object]')
await expect(page.locator('[data-test="profile-try-source"]')).toHaveText('Uses your unsaved edits')
await shot(page, 'profile-try', label)

// Discard so the profile on disk stays as seeded.
await page.locator('[data-test="profile-editor-discard"]').click()
await expect(page.locator('[data-test="profile-tool-unsaved-note"]')).toHaveCount(0)
})
}
27 changes: 16 additions & 11 deletions frontend/src/components/ImportServers.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
</label>
</div>
</div>
<div v-else class="text-sm opacity-70" data-test="detected-import-empty">No importable servers found in local client configs.</div>
<div v-else-if="showEmpty" class="text-sm opacity-70" data-test="detected-import-empty">{{ importedOnce ? 'Nothing left to import — every server in your client configs is on MCPProxy.' : 'No importable servers found in local client configs.' }}</div>
<div v-if="detectedError" class="alert alert-error text-sm mt-3">{{ detectedError }}</div>
<p v-if="detectedMessage" class="text-sm mt-3" data-test="detected-import-message">{{ detectedMessage }}</p>
<p v-if="detectedMessage && showMessage" class="text-sm mt-3" :class="detectedImportedCount > 0 ? 'text-success' : ''" data-test="detected-import-message">{{ detectedImportedCount > 0 ? '✓ ' : '' }}{{ detectedMessage }}</p>
<p v-if="detectedSelectedCount" class="text-xs mt-2" data-test="detected-selection-summary">
<span class="font-semibold">{{ detectedSelectedCount }}</span> selected
<span v-if="detectedRenames.size" class="text-warning"> · {{ detectedRenames.size }} renamed</span>
Expand Down Expand Up @@ -96,9 +96,11 @@
import { ref, reactive, computed, onMounted } from 'vue'
import api, { type CanonicalConfigPath } from '@/services/api'
import type { ImportResponse, ImportedServer } from '@/types'
import { skipReasonLabel } from '@/utils/importSkipReason'
import { importSummary } from '@/utils/onboardingServersStep'

const props = withDefaults(defineProps<{ detected?: boolean }>(), { detected: false })
// showEmpty: the wizard owns its own empty and completion states, so it turns
// this one off; standalone use keeps the first-load empty line.
const props = withDefaults(defineProps<{ detected?: boolean; showEmpty?: boolean; showMessage?: boolean }>(), { detected: false, showEmpty: true, showMessage: true })
const emit = defineEmits<{ imported: [count: number] }>()

const content = ref('')
Expand All @@ -117,6 +119,10 @@ const detectedLoading = ref(false)
const detectedImporting = ref(false)
const detectedError = ref<string | null>(null)
const detectedMessage = ref('')
const detectedImportedCount = ref(0)
// True once an import completed in this panel, so an empty reload reads
// "nothing left" instead of "nothing found".
const importedOnce = ref(false)
const detectedQuarantine = ref(true)
const detectedSelectedCount = computed(() => detectedSources.value.reduce((n, source) => n + Object.values(source.selected).filter(Boolean).length, 0))
const detectedRenames = computed(() => {
Expand Down Expand Up @@ -151,7 +157,7 @@ function toggleDetectedQuarantine(event: Event) {
async function loadDetectedSources(clearMessage = true) {
detectedLoading.value = true
detectedError.value = null
if (clearMessage) detectedMessage.value = ''
if (clearMessage) { detectedMessage.value = ''; detectedImportedCount.value = 0 }
try {
const paths = await api.getCanonicalConfigPaths()
if (!paths.success || !paths.data) return
Expand All @@ -171,7 +177,7 @@ async function importDetected() {
try {
let imported = 0
let renamed = 0
const skippedByReason = new Map<string, number>()
const skipped: Array<{ reason?: string }> = []
for (const source of detectedSources.value) {
const server_names = source.servers.filter(server => source.selected[server.name]).map(server => server.name)
if (!server_names.length) continue
Expand All @@ -183,12 +189,11 @@ 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
for (const skipped of response.data?.skipped ?? []) skippedByReason.set(skipped.reason, (skippedByReason.get(skipped.reason) ?? 0) + 1)
skipped.push(...(response.data?.skipped ?? []))
}
const parts = [`${imported} server${imported === 1 ? '' : 's'} imported`]
if (renamed) parts.push(`${renamed} renamed`)
for (const [reason, count] of skippedByReason) parts.push(`${count} skipped (${skipReasonLabel(reason)})`)
detectedMessage.value = parts.join(' · ')
detectedMessage.value = importSummary({ imported, renamed, skipped })
detectedImportedCount.value = imported
importedOnce.value = true
emit('imported', imported)
await loadDetectedSources(false)
} catch (error) { detectedError.value = error instanceof Error ? error.message : 'Import failed' }
Expand Down
Loading
Loading