<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Artifact Viewer</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
color: #333;
background: #fff;
}
#frame {
display: block;
width: 100%;
border: 0;
min-height: 120px;
}
.status {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 32px;
color: #666;
}
.spinner {
width: 18px;
height: 18px;
border: 2px solid #e2e2e8;
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.fallback { padding: 16px; }
.fallback pre {
background: #f6f6f9;
border: 1px solid #e2e2e8;
border-radius: 6px;
padding: 12px;
overflow-x: auto;
font-size: 12px;
}
.error {
margin: 16px;
padding: 12px;
border: 1px solid #f8b4b4;
border-left: 3px solid #ef4444;
border-radius: 6px;
background: #fef4f4;
color: #a00;
}
</style>
</head>
<body>
<div id="root">
<div class="status"><span class="spinner"></span><span>Loading artifact…</span></div>
</div>
<script>
const HTML_MIME_PREFIX = 'text/html';
const PENDING = new Map();
let requestId = 0;
let lastSize = { width: 0, height: 0 };
function post(message) {
window.parent.postMessage(message, '*');
}
function request(method, params) {
return new Promise((resolve, reject) => {
const id = ++requestId;
PENDING.set(id, { resolve, reject });
post({ jsonrpc: '2.0', id, method, params });
});
}
function publishSize() {
const doc = document.documentElement;
const width = Math.ceil(Math.max(doc.scrollWidth, document.body.scrollWidth));
const height = Math.ceil(Math.max(doc.scrollHeight, document.body.scrollHeight));
if (!height || (width === lastSize.width && height === lastSize.height)) {
return;
}
lastSize = { width, height };
post({
jsonrpc: '2.0',
method: MCP_UI.SIZE_CHANGED,
params: { width, height }
});
}
function embeddedHtml(result) {
for (const block of (result && result.content) || []) {
const resource = block && block.resource;
const mime = resource && (resource.mimeType || resource.mime_type);
if (resource && typeof resource.text === 'string' && mime && mime.startsWith(HTML_MIME_PREFIX)) {
return resource.text;
}
}
return null;
}
async function fetchedHtml(result) {
const meta = result && result._meta;
const uri = meta && meta['io.systemprompt/ui-resource-uri'];
if (!uri) {
return null;
}
const read = await request('resources/read', { uri });
for (const contents of (read && read.contents) || []) {
if (typeof contents.text === 'string') {
return contents.text;
}
}
return null;
}
function mount(html) {
const root = document.getElementById('root');
root.innerHTML = '<iframe id="frame" sandbox="allow-scripts allow-popups"></iframe>';
document.getElementById('frame').srcdoc = html;
}
function showFallback(result) {
const structured = (result && result.structuredContent) || result || {};
const payload = structured.artifact || structured;
const wrapper = document.createElement('div');
wrapper.className = 'fallback';
const pre = document.createElement('pre');
pre.textContent = JSON.stringify(payload, null, 2);
wrapper.appendChild(pre);
const root = document.getElementById('root');
root.innerHTML = '';
root.appendChild(wrapper);
publishSize();
}
function showError(message) {
const div = document.createElement('div');
div.className = 'error';
div.textContent = message;
const root = document.getElementById('root');
root.innerHTML = '';
root.appendChild(div);
publishSize();
}
async function render(result) {
const inline = embeddedHtml(result);
if (inline) {
mount(inline);
return;
}
try {
const fetched = await fetchedHtml(result);
if (fetched) {
mount(fetched);
return;
}
} catch (err) {
console.error('resources/read for artifact failed:', err);
}
showFallback(result);
}
window.addEventListener('message', (event) => {
const data = event.data || {};
try {
if (data.id && PENDING.has(data.id)) {
const { resolve, reject } = PENDING.get(data.id);
PENDING.delete(data.id);
if (data.error) {
reject(new Error(data.error.message || 'Request failed'));
} else {
resolve(data.result);
}
return;
}
if (data.method === MCP_UI.SIZE_CHANGED && data.params) {
const frame = document.getElementById('frame');
if (frame && event.source === frame.contentWindow) {
frame.style.height = data.params.height + 'px';
publishSize();
}
return;
}
if (data.method === MCP_UI.TOOL_RESULT) {
render(data.params);
}
} catch (err) {
console.error('Artifact shell failed to render:', err);
showError(err.message || 'Failed to render artifact');
}
});
request(MCP_UI.INITIALIZE, {
appInfo: { name: 'systemprompt-artifact-viewer', version: '1.0.0' },
appCapabilities: {},
protocolVersion: MCP_UI.PROTOCOL_VERSION
}).then(() => {
post({ jsonrpc: '2.0', method: MCP_UI.INITIALIZED, params: {} });
publishSize();
}).catch((err) => {
console.error('MCP Apps initialize failed:', err);
});
</script>
</body>
</html>