The catalogue page interpolates publisher-supplied URL fields (`server.repository.url`, `server.websiteUrl`) inside `href="..."` attributes via `innerHTML`, escaping them with a `textContent`→`innerHTML` helper. Per the HTML5 fragment-serialisation algorithm, that round-trip encodes only `&`, `<`, `>`, and U+00A0 inside text nodes — it does not encode `"` or `'`. The helper is therefore safe in element-text contexts (where it is used for `name`, `version`, `description`, etc.) but unsafe inside an attribute value. Add an attribute-safe `escapeAttr` helper that follows the OWASP attribute-encoding rule (also encodes `"` and `'`) and use it for the two `href` interpolations. Element-text usages keep the existing `escapeHtml` and remain unchanged. A short comment on each helper documents the contexts they cover so future fields are wired up correctly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
533 lines
24 KiB
HTML
533 lines
24 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Official MCP Registry</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<style>
|
|
body {
|
|
background: linear-gradient(to bottom, #ffffff 0%, #f9fafb 100%);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="min-h-screen flex flex-col">
|
|
<div class="max-w-7xl mx-auto px-4 py-8 md:py-16 flex-1 w-full">
|
|
<!-- Header -->
|
|
<header class="mb-8 pb-8 border-b">
|
|
<h1 class="text-4xl font-bold text-gray-900 mb-2">Official MCP Registry</h1>
|
|
<p class="text-gray-600 text-lg mb-6">Discover Model Context Protocol servers</p>
|
|
<div class="flex gap-6 text-sm">
|
|
<a href="https://github.com/modelcontextprotocol/registry" target="_blank" class="text-blue-600 hover:text-blue-700 font-medium">GitHub</a>
|
|
<a href="https://github.com/modelcontextprotocol/registry/tree/main/docs" target="_blank" class="text-blue-600 hover:text-blue-700 font-medium">Docs</a>
|
|
<a href="/docs" class="text-blue-600 hover:text-blue-700 font-medium">API Reference</a>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Recently Updated -->
|
|
<div id="recently-updated" class="hidden mb-8">
|
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">Recently Updated</h2>
|
|
<div id="recent-cards" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
<!-- Populated by JS -->
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Search and Filter -->
|
|
<div class="mb-8 space-y-3">
|
|
<div class="relative">
|
|
<input
|
|
type="search"
|
|
id="search"
|
|
placeholder="Search servers by name..."
|
|
class="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
autofocus
|
|
>
|
|
<span id="search-loading" class="hidden absolute right-3 top-2.5 text-gray-400">Searching...</span>
|
|
</div>
|
|
<label class="flex items-center text-sm text-gray-600">
|
|
<input type="checkbox" id="latest-only" class="mr-2" checked>
|
|
<span>Show only latest versions</span>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Loading State -->
|
|
<div id="loading" class="text-center py-12 text-gray-600">
|
|
Loading servers...
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div id="error" class="hidden bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
|
<p class="text-red-800" id="error-message"></p>
|
|
<button onclick="location.reload()" class="mt-2 text-sm text-red-600 hover:underline">Retry</button>
|
|
</div>
|
|
|
|
<!-- Server List -->
|
|
<div id="server-list" class="hidden">
|
|
<div id="server-cards" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
<!-- Populated by JS -->
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div class="mt-8 flex justify-between items-center">
|
|
<button
|
|
id="prev-btn"
|
|
class="px-5 py-2.5 text-sm font-medium border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed transition-all"
|
|
disabled
|
|
>
|
|
Previous
|
|
</button>
|
|
<span class="text-sm text-gray-600 font-medium" id="page-info"></span>
|
|
<button
|
|
id="next-btn"
|
|
class="px-5 py-2.5 text-sm font-medium border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed transition-all"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<footer class="mt-8 bg-gray-50 border-t py-4">
|
|
<div class="max-w-7xl mx-auto px-4">
|
|
<div class="flex justify-between items-center text-sm text-gray-600">
|
|
<div>
|
|
Built in the open by <a href="https://github.com/modelcontextprotocol/registry" target="_blank" class="text-blue-600 hover:underline">MCP contributors</a>
|
|
</div>
|
|
<button id="debug-btn" class="text-blue-600 hover:underline">
|
|
Server: <span id="server-name">Production</span> (<span id="version-info">...</span>)
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
|
|
<!-- Debug Modal -->
|
|
<div id="debug-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
|
<h3 class="text-lg font-semibold mb-4">API Base URL</h3>
|
|
<div class="space-y-2 mb-4">
|
|
<label class="flex items-center">
|
|
<input type="radio" name="baseUrl" value="prod" class="mr-2" checked>
|
|
<span>Production (registry.modelcontextprotocol.io)</span>
|
|
</label>
|
|
<label class="flex items-center">
|
|
<input type="radio" name="baseUrl" value="staging" class="mr-2">
|
|
<span>Staging (staging.registry.modelcontextprotocol.io)</span>
|
|
</label>
|
|
<label class="flex items-center">
|
|
<input type="radio" name="baseUrl" value="local" class="mr-2">
|
|
<span>Local (localhost:8080)</span>
|
|
</label>
|
|
<label class="flex items-center">
|
|
<input type="radio" name="baseUrl" value="custom" class="mr-2">
|
|
<span>Custom</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="custom-url"
|
|
placeholder="https://custom-url.com"
|
|
class="w-full px-3 py-2 border rounded disabled:bg-gray-100"
|
|
disabled
|
|
>
|
|
</div>
|
|
<div class="flex gap-2 justify-end">
|
|
<button id="debug-cancel" class="px-4 py-2 text-sm border rounded hover:bg-gray-50">Cancel</button>
|
|
<button id="debug-apply" class="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700">Apply</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let servers = [];
|
|
let cursorHistory = [];
|
|
let currentCursor = null;
|
|
let nextCursor = null;
|
|
let searchTimeout = null;
|
|
let baseUrl = '';
|
|
|
|
// Get base URL based on selection
|
|
function getBaseUrl() {
|
|
const selection = localStorage.getItem('baseUrl') || 'local';
|
|
const customUrl = localStorage.getItem('customUrl') || '';
|
|
|
|
switch (selection) {
|
|
case 'prod':
|
|
return 'https://registry.modelcontextprotocol.io';
|
|
case 'staging':
|
|
return 'https://staging.registry.modelcontextprotocol.io';
|
|
case 'local':
|
|
return '';
|
|
case 'custom':
|
|
return customUrl;
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
// Get server display name
|
|
function getServerName() {
|
|
const selection = localStorage.getItem('baseUrl') || 'local';
|
|
const customUrl = localStorage.getItem('customUrl') || '';
|
|
|
|
switch (selection) {
|
|
case 'prod':
|
|
return 'Production';
|
|
case 'staging':
|
|
return 'Staging';
|
|
case 'local':
|
|
return 'Local';
|
|
case 'custom':
|
|
return 'Custom';
|
|
default:
|
|
return 'Production';
|
|
}
|
|
}
|
|
|
|
// Initialize base URL and server name
|
|
baseUrl = getBaseUrl();
|
|
document.getElementById('server-name').textContent = getServerName();
|
|
|
|
// URL parameter management
|
|
function updateURL() {
|
|
const params = new URLSearchParams();
|
|
const search = document.getElementById('search').value;
|
|
const latestOnly = document.getElementById('latest-only').checked;
|
|
|
|
if (search) params.set('q', search);
|
|
if (!latestOnly) params.set('all', '1');
|
|
if (currentCursor) params.set('cursor', currentCursor);
|
|
|
|
const newUrl = params.toString() ? `?${params.toString()}` : '/';
|
|
history.pushState({cursor: currentCursor, search, latestOnly}, '', newUrl);
|
|
}
|
|
|
|
function loadFromURL() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
document.getElementById('search').value = params.get('q') || '';
|
|
document.getElementById('latest-only').checked = !params.has('all');
|
|
currentCursor = params.get('cursor') || null;
|
|
}
|
|
|
|
// Handle browser back/forward
|
|
window.addEventListener('popstate', (event) => {
|
|
if (event.state) {
|
|
currentCursor = event.state.cursor;
|
|
document.getElementById('search').value = event.state.search || '';
|
|
document.getElementById('latest-only').checked = event.state.latestOnly !== false;
|
|
fetchServers(currentCursor, event.state.search, event.state.latestOnly);
|
|
}
|
|
});
|
|
|
|
// Debug modal handlers
|
|
document.getElementById('debug-btn').addEventListener('click', () => {
|
|
const selection = localStorage.getItem('baseUrl') || 'prod';
|
|
document.querySelector(`input[name="baseUrl"][value="${selection}"]`).checked = true;
|
|
document.getElementById('custom-url').value = localStorage.getItem('customUrl') || '';
|
|
document.getElementById('custom-url').disabled = selection !== 'custom';
|
|
document.getElementById('debug-modal').classList.remove('hidden');
|
|
});
|
|
|
|
document.getElementById('debug-cancel').addEventListener('click', () => {
|
|
document.getElementById('debug-modal').classList.add('hidden');
|
|
});
|
|
|
|
document.getElementById('debug-apply').addEventListener('click', () => {
|
|
const selection = document.querySelector('input[name="baseUrl"]:checked').value;
|
|
const customUrl = document.getElementById('custom-url').value;
|
|
localStorage.setItem('baseUrl', selection);
|
|
localStorage.setItem('customUrl', customUrl);
|
|
location.reload();
|
|
});
|
|
|
|
document.querySelectorAll('input[name="baseUrl"]').forEach(radio => {
|
|
radio.addEventListener('change', (e) => {
|
|
document.getElementById('custom-url').disabled = e.target.value !== 'custom';
|
|
});
|
|
});
|
|
|
|
// Fetch version info
|
|
async function fetchVersion() {
|
|
try {
|
|
const version = await fetch(`${baseUrl}/v0.1/version`).then(r => r.json());
|
|
document.getElementById('version-info').textContent = version.version || 'unknown';
|
|
} catch (err) {
|
|
document.getElementById('version-info').textContent = 'error';
|
|
}
|
|
}
|
|
|
|
// Fetch recently updated servers
|
|
async function fetchRecentlyUpdated() {
|
|
try {
|
|
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
|
const response = await fetch(`${baseUrl}/v0.1/servers?updated_since=${oneHourAgo}&limit=96`);
|
|
const data = await response.json();
|
|
|
|
if (data.servers && data.servers.length > 0) {
|
|
renderRecentServers(data.servers);
|
|
document.getElementById('recently-updated').classList.remove('hidden');
|
|
}
|
|
} catch (err) {
|
|
// Silently fail if recent updates can't be fetched
|
|
}
|
|
}
|
|
|
|
// Render recently updated servers
|
|
function renderRecentServers(recentServers) {
|
|
const container = document.getElementById('recent-cards');
|
|
container.innerHTML = '';
|
|
|
|
recentServers.forEach(item => {
|
|
const server = item.server;
|
|
const meta = item._meta?.['io.modelcontextprotocol.registry/official'];
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'bg-blue-50 border border-blue-200 rounded-lg overflow-hidden hover:border-blue-400 hover:shadow-md transition-all flex flex-col';
|
|
|
|
const header = document.createElement('div');
|
|
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>
|
|
<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">
|
|
Updated ${formatDate(meta?.updatedAt || meta?.publishedAt)}
|
|
</div>
|
|
`;
|
|
|
|
card.appendChild(header);
|
|
container.appendChild(card);
|
|
});
|
|
}
|
|
|
|
// Fetch servers
|
|
async function fetchServers(cursor = null, search = '', latestOnly = null) {
|
|
try {
|
|
if (latestOnly === null) {
|
|
latestOnly = document.getElementById('latest-only').checked;
|
|
}
|
|
|
|
let url = `${baseUrl}/v0.1/servers?limit=96`;
|
|
if (cursor) url += `&cursor=${encodeURIComponent(cursor)}`;
|
|
if (search) url += `&search=${encodeURIComponent(search)}`;
|
|
if (latestOnly) url += `&version=latest`;
|
|
|
|
const response = await fetch(url);
|
|
const data = await response.json();
|
|
|
|
servers = data.servers || [];
|
|
nextCursor = data.metadata?.nextCursor || null;
|
|
|
|
renderServers();
|
|
updatePagination();
|
|
updateURL();
|
|
|
|
document.getElementById('loading').classList.add('hidden');
|
|
document.getElementById('server-list').classList.remove('hidden');
|
|
} catch (err) {
|
|
showError('Failed to load servers: ' + err.message);
|
|
}
|
|
}
|
|
|
|
// Render server cards
|
|
function renderServers() {
|
|
const container = document.getElementById('server-cards');
|
|
container.innerHTML = '';
|
|
|
|
if (servers.length === 0) {
|
|
container.innerHTML = '<div class="text-center py-12 text-gray-600">No servers found</div>';
|
|
return;
|
|
}
|
|
|
|
servers.forEach(item => {
|
|
const server = item.server;
|
|
const meta = item._meta?.['io.modelcontextprotocol.registry/official'];
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'bg-white border border-gray-200 rounded-lg overflow-hidden hover:border-blue-300 hover:shadow-md transition-all flex flex-col';
|
|
|
|
const header = document.createElement('div');
|
|
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>
|
|
<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">
|
|
${formatDate(meta?.updatedAt || meta?.publishedAt)}
|
|
</div>
|
|
`;
|
|
|
|
card.appendChild(header);
|
|
container.appendChild(card);
|
|
});
|
|
}
|
|
|
|
// Toggle expanded details
|
|
function toggleDetails(card, item) {
|
|
const existingDetails = card.querySelector('.details-content');
|
|
if (existingDetails) {
|
|
existingDetails.remove();
|
|
card.classList.remove('border-gray-400');
|
|
return;
|
|
}
|
|
|
|
card.classList.add('border-gray-400');
|
|
|
|
const server = item.server;
|
|
const meta = item._meta?.['io.modelcontextprotocol.registry/official'];
|
|
|
|
let packagesHtml = '';
|
|
if (server.packages && server.packages.length > 0) {
|
|
packagesHtml = `
|
|
<div class="mt-3">
|
|
<h4 class="font-medium text-gray-900 mb-2">Packages</h4>
|
|
<ul class="space-y-2">
|
|
${server.packages.map(pkg => `
|
|
<li class="text-sm">
|
|
<span class="font-mono bg-gray-100 px-2 py-1 rounded">${escapeHtml(pkg.registryType)}</span>
|
|
<span class="text-gray-600">${escapeHtml(pkg.identifier)}</span>
|
|
</li>
|
|
`).join('')}
|
|
</ul>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
let remotesHtml = '';
|
|
if (server.remotes && server.remotes.length > 0) {
|
|
remotesHtml = `
|
|
<div class="mt-3">
|
|
<h4 class="font-medium text-gray-900 mb-2">Remotes</h4>
|
|
<ul class="space-y-2">
|
|
${server.remotes.map(remote => `
|
|
<li class="text-sm">
|
|
<span class="font-mono bg-gray-100 px-2 py-1 rounded">${escapeHtml(remote.type)}</span>
|
|
${remote.url ? `<span class="text-gray-600">${escapeHtml(remote.url)}</span>` : ''}
|
|
</li>
|
|
`).join('')}
|
|
</ul>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
const detailsDiv = document.createElement('div');
|
|
detailsDiv.className = 'details-content border-t bg-gray-50 p-5 space-y-2 text-sm break-words';
|
|
detailsDiv.innerHTML = `
|
|
${server.title ? `<div><span class="font-medium">Title:</span> <span class="break-all">${escapeHtml(server.title)}</span></div>` : ''}
|
|
<div><span class="font-medium">Status:</span> <span class="capitalize">${escapeHtml(meta?.status || 'unknown')}</span></div>
|
|
${server.repository?.url ? `
|
|
<div>
|
|
<span class="font-medium">Repository:</span>
|
|
<a href="${escapeAttr(server.repository.url)}" target="_blank" class="text-blue-600 hover:underline break-all" onclick="event.stopPropagation()">${escapeHtml(server.repository.url)}</a>
|
|
</div>
|
|
` : ''}
|
|
${server.websiteUrl ? `
|
|
<div>
|
|
<span class="font-medium">Website:</span>
|
|
<a href="${escapeAttr(server.websiteUrl)}" target="_blank" class="text-blue-600 hover:underline break-all" onclick="event.stopPropagation()">${escapeHtml(server.websiteUrl)}</a>
|
|
</div>
|
|
` : ''}
|
|
<div>
|
|
<a href="${baseUrl}/v0.1/servers/${encodeURIComponent(server.name)}/versions/${encodeURIComponent(server.version)}" target="_blank" class="text-blue-600 hover:underline break-all" onclick="event.stopPropagation()">View in API</a>
|
|
</div>
|
|
${packagesHtml}
|
|
${remotesHtml}
|
|
<details class="mt-4" onclick="event.stopPropagation()">
|
|
<summary class="cursor-pointer text-blue-600 hover:underline">View full server.json</summary>
|
|
<pre class="mt-2 p-3 bg-gray-100 rounded text-xs overflow-x-auto break-all">${escapeHtml(JSON.stringify(item, null, 2))}</pre>
|
|
</details>
|
|
`;
|
|
|
|
card.appendChild(detailsDiv);
|
|
}
|
|
|
|
// Update pagination buttons
|
|
function updatePagination() {
|
|
document.getElementById('prev-btn').disabled = cursorHistory.length === 0;
|
|
document.getElementById('next-btn').disabled = !nextCursor;
|
|
|
|
const info = nextCursor ? 'More available' : `Showing ${servers.length} servers`;
|
|
document.getElementById('page-info').textContent = info;
|
|
}
|
|
|
|
// Search handler
|
|
document.getElementById('search').addEventListener('input', (e) => {
|
|
clearTimeout(searchTimeout);
|
|
document.getElementById('search-loading').classList.remove('hidden');
|
|
searchTimeout = setTimeout(() => {
|
|
cursorHistory = [];
|
|
currentCursor = null;
|
|
fetchServers(null, e.target.value);
|
|
document.getElementById('search-loading').classList.add('hidden');
|
|
}, 300);
|
|
});
|
|
|
|
// Latest-only checkbox handler
|
|
document.getElementById('latest-only').addEventListener('change', () => {
|
|
cursorHistory = [];
|
|
currentCursor = null;
|
|
fetchServers(null, document.getElementById('search').value);
|
|
});
|
|
|
|
// Pagination handlers
|
|
document.getElementById('next-btn').addEventListener('click', () => {
|
|
if (nextCursor) {
|
|
cursorHistory.push(currentCursor);
|
|
currentCursor = nextCursor;
|
|
fetchServers(nextCursor, document.getElementById('search').value);
|
|
}
|
|
});
|
|
|
|
document.getElementById('prev-btn').addEventListener('click', () => {
|
|
if (cursorHistory.length > 0) {
|
|
currentCursor = cursorHistory.pop();
|
|
fetchServers(currentCursor, document.getElementById('search').value);
|
|
}
|
|
});
|
|
|
|
// Utility functions
|
|
// Safe for element-text contexts only: encodes `&`, `<`, `>`. Does NOT
|
|
// encode `"` or `'`, so it is unsafe inside attribute values — use
|
|
// escapeAttr there.
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Safe for HTML attribute values per OWASP attribute-encoding rule.
|
|
function escapeAttr(text) {
|
|
if (text == null) return '';
|
|
return String(text)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return '-';
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleDateString();
|
|
}
|
|
|
|
function showError(message) {
|
|
document.getElementById('loading').classList.add('hidden');
|
|
document.getElementById('error-message').textContent = message;
|
|
document.getElementById('error').classList.remove('hidden');
|
|
}
|
|
|
|
// Initialize
|
|
loadFromURL();
|
|
fetchVersion();
|
|
fetchRecentlyUpdated();
|
|
fetchServers(currentCursor, document.getElementById('search').value);
|
|
</script>
|
|
</body>
|
|
</html>
|