Skip to content
Open
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
71 changes: 65 additions & 6 deletions internal/api/handlers/v0/ui_index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><rect x='3' y='3' width='18' height='18' rx='3'/><path d='M8 12h8M12 8v8'/></svg>");
background-size: 60% 60%;
background-repeat: no-repeat;
background-position: center;
}
</style>
</head>
<body class="min-h-screen flex flex-col">
Expand Down Expand Up @@ -271,6 +279,47 @@ <h3 class="text-lg font-semibold mb-4">API Base URL</h3>
}
}

// 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 <img> 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 `<div class="server-icon-fallback shrink-0 w-10 h-10 rounded-md border border-gray-200" aria-hidden="true"></div>`;
}
return `<img
src="${escapeHtml(src)}"
alt=""
width="40"
height="40"
loading="lazy"
referrerpolicy="no-referrer"
class="shrink-0 w-10 h-10 rounded-md border border-gray-200 object-contain bg-white"
onerror="this.outerHTML='<div class=\\'server-icon-fallback shrink-0 w-10 h-10 rounded-md border border-gray-200\\' aria-hidden=\\'true\\'></div>'"
>`;
}

// Render recently updated servers
function renderRecentServers(recentServers) {
const container = document.getElementById('recent-cards');
Expand All @@ -287,9 +336,14 @@ <h3 class="text-lg font-semibold mb-4">API Base URL</h3>
header.className = 'cursor-pointer p-5 flex-1';
header.onclick = () => toggleDetails(card, item);
header.innerHTML = `
<div class="flex items-start justify-between gap-3 mb-2">
<h3 class="font-semibold text-gray-900 text-base flex-1 min-w-0 break-all">${escapeHtml(server.name)}</h3>
<span class="text-xs text-gray-500 font-medium whitespace-nowrap">v${escapeHtml(server.version)}</span>
<div class="flex items-start gap-3 mb-2">
${renderIconTile(server)}
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-3">
<h3 class="font-semibold text-gray-900 text-base flex-1 min-w-0 break-all">${escapeHtml(server.name)}</h3>
<span class="text-xs text-gray-500 font-medium whitespace-nowrap">v${escapeHtml(server.version)}</span>
</div>
</div>
</div>
<p class="text-sm text-gray-600 leading-relaxed line-clamp-2 mb-2 break-words">${escapeHtml(server.description || '')}</p>
<div class="text-xs text-gray-500">
Expand Down Expand Up @@ -352,9 +406,14 @@ <h3 class="font-semibold text-gray-900 text-base flex-1 min-w-0 break-all">${esc
header.className = 'cursor-pointer p-5 flex-1';
header.onclick = () => toggleDetails(card, item);
header.innerHTML = `
<div class="flex items-start justify-between gap-3 mb-2">
<h3 class="font-semibold text-gray-900 text-base flex-1 min-w-0">${escapeHtml(server.name)}</h3>
<span class="text-xs text-gray-500 font-medium whitespace-nowrap">v${escapeHtml(server.version)}</span>
<div class="flex items-start gap-3 mb-2">
${renderIconTile(server)}
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-3">
<h3 class="font-semibold text-gray-900 text-base flex-1 min-w-0">${escapeHtml(server.name)}</h3>
<span class="text-xs text-gray-500 font-medium whitespace-nowrap">v${escapeHtml(server.version)}</span>
</div>
</div>
</div>
<p class="text-sm text-gray-600 leading-relaxed line-clamp-2 mb-2">${escapeHtml(server.description || '')}</p>
<div class="text-xs text-gray-500">
Expand Down
45 changes: 45 additions & 0 deletions internal/api/handlers/v0/ui_test.go
Original file line number Diff line number Diff line change
@@ -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, "<!DOCTYPE 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 <img> 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)
}
}
}
Loading