diff --git a/frontend/src/utils/serverRoute.ts b/frontend/src/utils/serverRoute.ts
index b55ea1986..2b1a9799a 100644
--- a/frontend/src/utils/serverRoute.ts
+++ b/frontend/src/utils/serverRoute.ts
@@ -10,6 +10,18 @@ export function serverDetailPath(name: string, tab?: string): string {
return tab ? `${base}?tab=${encodeURIComponent(tab)}` : base
}
+// MCP-2125 (#643 Defect B): scan ids embed the raw upstream server name, so an
+// official-registry server whose name contains '/' (e.g.
+// "com.pulsemcp/google-flights") yields a scan id like
+// "scan-com.pulsemcp/google-flights-1781284446323229000". The scan-report route
+// is a single `:jobId` segment, so the id MUST be percent-encoded — otherwise
+// the '/' splits the path and the link falls through to the catch-all 404.
+// vue-router decodes the param back to the original id on read (same class as
+// serverDetailPath above / MCP-1112).
+export function scanReportPath(jobId: string): string {
+ return `/security/scans/${encodeURIComponent(jobId)}`
+}
+
// serverDisplayName prefers the registry-provided human-friendly `title` over
// the raw reverse-DNS `name` identifier (e.g. "io.github.owner/repo"). The
// `name` remains the stable key used for API calls and routing; only the
diff --git a/frontend/src/views/Security.vue b/frontend/src/views/Security.vue
index 7d100ab81..ef99b734c 100644
--- a/frontend/src/views/Security.vue
+++ b/frontend/src/views/Security.vue
@@ -339,7 +339,7 @@
Details →
@@ -448,6 +448,7 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import api from '@/services/api'
import { refreshSecurityScannerStatus } from '@/composables/useSecurityScannerStatus'
import { useSystemStore } from '@/stores/system'
+import { scanReportPath } from '@/utils/serverRoute'
const systemStore = useSystemStore()
diff --git a/frontend/src/views/ServerDetail.vue b/frontend/src/views/ServerDetail.vue
index f95e1d241..0f67e46e2 100644
--- a/frontend/src/views/ServerDetail.vue
+++ b/frontend/src/views/ServerDetail.vue
@@ -1148,7 +1148,7 @@
-
+
View Full Report →
@@ -1223,7 +1223,7 @@ import type { Hint } from '@/components/CollapsibleHintsPanel.vue'
import type { Server, Tool, ToolApproval, SecurityScanReport } from '@/types'
import api from '@/services/api'
import { useSecurityScannerStatus } from '@/composables/useSecurityScannerStatus'
-import { serverDisplayName } from '@/utils/serverRoute'
+import { serverDisplayName, scanReportPath } from '@/utils/serverRoute'
import { selectQuarantinedTools } from '@/utils/toolQuarantine'
import { oauthSignInState } from '@/utils/health'
import { computeToolDiffSections } from '@/utils/toolDiff'
diff --git a/frontend/tests/unit/scan-report-route.spec.ts b/frontend/tests/unit/scan-report-route.spec.ts
new file mode 100644
index 000000000..93038a953
--- /dev/null
+++ b/frontend/tests/unit/scan-report-route.spec.ts
@@ -0,0 +1,43 @@
+import { describe, it, expect } from 'vitest'
+import { createRouter, createWebHistory } from 'vue-router'
+import { scanReportPath } from '@/utils/serverRoute'
+
+// MCP-2125 (Defect B of MCP-2123): scan ids embed the raw upstream server name,
+// so official-registry servers whose names contain '/' (e.g.
+// "com.pulsemcp/google-flights") produce a scan id like
+// "scan-com.pulsemcp/google-flights-1781284446323229000". The scan-report route
+// is a single `:jobId` segment, so an unencoded '/' splits the path and falls
+// through to the catch-all 404. The id MUST be percent-encoded; vue-router v4
+// decodes the param back on read (same class as MCP-1112 / serverDetailPath).
+
+const SLASH_SCAN_ID = 'scan-com.pulsemcp/google-flights-1781284446323229000'
+
+describe('scanReportPath (MCP-2125)', () => {
+ it('percent-encodes a "/"-containing scan id into a single path segment', () => {
+ expect(scanReportPath(SLASH_SCAN_ID)).toBe(
+ '/security/scans/scan-com.pulsemcp%2Fgoogle-flights-1781284446323229000'
+ )
+ })
+
+ it('leaves a plain scan id untouched (no "/" to encode)', () => {
+ expect(scanReportPath('scan-github-123')).toBe('/security/scans/scan-github-123')
+ })
+})
+
+describe('scan-report route round-trip (MCP-2125)', () => {
+ it('decodes the encoded "/" back into the jobId param (no 404)', async () => {
+ const router = createRouter({
+ history: createWebHistory(),
+ routes: [
+ { path: '/security/scans/:jobId', name: 'scan-report', component: { template: '' } },
+ { path: '/:pathMatch(.*)*', name: 'not-found', component: { template: '404 ' } },
+ ],
+ })
+ await router.push(scanReportPath(SLASH_SCAN_ID))
+ await router.isReady()
+ // It must match scan-report (NOT the catch-all 404)...
+ expect(router.currentRoute.value.name).toBe('scan-report')
+ // ...and the param must be decoded back to the original scan id.
+ expect(router.currentRoute.value.params.jobId).toBe(SLASH_SCAN_ID)
+ })
+})
|