diff --git a/internal/api/handlers/v0/ui_index.html b/internal/api/handlers/v0/ui_index.html index 38bac7f7..3231fcf4 100644 --- a/internal/api/handlers/v0/ui_index.html +++ b/internal/api/handlers/v0/ui_index.html @@ -9,6 +9,14 @@ body { background: linear-gradient(to bottom, #ffffff 0%, #f9fafb 100%); } + /* Inline SVG fallback icon shown when a server has no icon or the icon URL fails to load. */ + .server-icon-fallback { + background-color: #f3f4f6; + background-image: url("data:image/svg+xml;utf8,"); + background-size: 60% 60%; + background-repeat: no-repeat; + background-position: center; + } @@ -271,6 +279,47 @@

API Base URL

} } + // Pick the best icon URL for a server card. + // Spec requires HTTPS; we enforce it here so we never render attacker-controlled http:// + // or javascript: URLs even if a future server bypasses validation. Returns null when + // no usable icon is available so the caller can render the fallback tile. + function pickIconSrc(server) { + if (!server || !Array.isArray(server.icons) || server.icons.length === 0) return null; + // Prefer a 'light'-themed icon (page background is light), then theme-agnostic, then dark. + const score = (icon) => { + if (!icon || typeof icon.src !== 'string') return -1; + if (icon.theme === 'light') return 2; + if (!icon.theme) return 1; + return 0; + }; + const best = [...server.icons].sort((a, b) => score(b) - score(a))[0]; + const src = best && best.src; + if (typeof src !== 'string') return null; + // HTTPS only — matches the server.json schema requirement. + if (!/^https:\/\//i.test(src)) return null; + return src; + } + + // Render the icon tile used by both card renderers. Wraps an with lazy loading, + // fixed dimensions for layout stability (no CLS), and an onerror handler that swaps + // in the inline-SVG fallback if the remote icon fails to load. + function renderIconTile(server) { + const src = pickIconSrc(server); + if (!src) { + return ``; + } + return ``; + } + // Render recently updated servers function renderRecentServers(recentServers) { const container = document.getElementById('recent-cards'); @@ -287,9 +336,14 @@

API Base URL

header.className = 'cursor-pointer p-5 flex-1'; header.onclick = () => toggleDetails(card, item); header.innerHTML = ` -
-

${escapeHtml(server.name)}

- v${escapeHtml(server.version)} +
+ ${renderIconTile(server)} +
+
+

${escapeHtml(server.name)}

+ v${escapeHtml(server.version)} +
+

${escapeHtml(server.description || '')}

@@ -352,9 +406,14 @@

${esc header.className = 'cursor-pointer p-5 flex-1'; header.onclick = () => toggleDetails(card, item); header.innerHTML = ` -
-

${escapeHtml(server.name)}

- v${escapeHtml(server.version)} +
+ ${renderIconTile(server)} +
+
+

${escapeHtml(server.name)}

+ v${escapeHtml(server.version)} +
+

${escapeHtml(server.description || '')}

diff --git a/internal/api/handlers/v0/ui_test.go b/internal/api/handlers/v0/ui_test.go new file mode 100644 index 00000000..4a9d9b13 --- /dev/null +++ b/internal/api/handlers/v0/ui_test.go @@ -0,0 +1,45 @@ +package v0_test + +import ( + "strings" + "testing" + + v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" +) + +func TestGetUIHTML_NotEmpty(t *testing.T) { + html := v0.GetUIHTML() + if html == "" { + t.Fatal("GetUIHTML returned empty string") + } + if !strings.Contains(html, "") { + t.Error("UI HTML missing DOCTYPE declaration") + } + if !strings.Contains(html, "Official MCP Registry") { + t.Error("UI HTML missing expected page title text") + } +} + +// TestGetUIHTML_IconSupport guards the icon-rendering wiring added for +// modelcontextprotocol/registry#784. If a future refactor removes the icon +// picker or the lazy-loading attribute, this test fails so the regression +// surfaces in CI rather than after release. +func TestGetUIHTML_IconSupport(t *testing.T) { + html := v0.GetUIHTML() + + checks := map[string]string{ + "pickIconSrc helper": "function pickIconSrc", + "renderIconTile helper": "function renderIconTile", + "lazy-loaded tag": `loading="lazy"`, + "server-icon-fallback": "server-icon-fallback", + "https-only icon URL": "^https:", + "explicit icon dimensions (width)": `width="40"`, + "explicit icon dimensions (height)": `height="40"`, + } + + for name, marker := range checks { + if !strings.Contains(html, marker) { + t.Errorf("UI HTML missing %s (expected substring %q)", name, marker) + } + } +}