/** * @file Debug App - Comprehensive testing/debugging tool for the MCP Apps SDK. * * This app exercises every capability, callback, and result format combination. */ import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; import "./global.css"; import "./mcp-app.css"; // ============================================================================ // Types // ============================================================================ interface LogEntry { time: number; type: string; payload: unknown; } interface AppState { eventLog: LogEntry[]; callbackCounts: Map; lastPayloads: Map; autoResizeCleanup: (() => void) | null; logFilter: string; } // ============================================================================ // State // ============================================================================ const state: AppState = { eventLog: [], callbackCounts: new Map(), lastPayloads: new Map(), autoResizeCleanup: null, logFilter: "all", }; // Callbacks we track const CALLBACKS = [ "ontoolinput", "ontoolinputpartial", "ontoolresult", "ontoolcancelled", "onhostcontextchanged", "onteardown", "oncalltool", "onlisttools", "onerror", ] as const; // ============================================================================ // DOM Elements // ============================================================================ const mainEl = document.querySelector(".main") as HTMLElement; const eventLogEl = document.getElementById("event-log")!; const logFilterEl = document.getElementById("log-filter") as HTMLSelectElement; const clearLogBtn = document.getElementById("clear-log-btn")!; // Host info const hostContextInfoEl = document.getElementById("host-context-info")!; const hostCapabilitiesInfoEl = document.getElementById( "host-capabilities-info", )!; const hostContainerInfoEl = document.getElementById("host-container-info")!; const hostStylesSampleEl = document.getElementById("host-styles-sample")!; // Callback status const callbackTableBodyEl = document.getElementById("callback-table-body")!; // Action elements const messageTextEl = document.getElementById( "message-text", ) as HTMLInputElement; const sendMessageTextBtn = document.getElementById("send-message-text-btn")!; const sendMessageImageBtn = document.getElementById("send-message-image-btn")!; const logDataEl = document.getElementById("log-data") as HTMLInputElement; const logDebugBtn = document.getElementById("log-debug-btn")!; const logInfoBtn = document.getElementById("log-info-btn")!; const logWarningBtn = document.getElementById("log-warning-btn")!; const logErrorBtn = document.getElementById("log-error-btn")!; const contextTextEl = document.getElementById( "context-text", ) as HTMLInputElement; const updateContextTextBtn = document.getElementById( "update-context-text-btn", )!; const updateContextStructuredBtn = document.getElementById( "update-context-structured-btn", )!; const updateContextImageBtn = document.getElementById( "update-context-image-btn", )!; const displayInlineBtn = document.getElementById("display-inline-btn")!; const displayFullscreenBtn = document.getElementById("display-fullscreen-btn")!; const displayPipBtn = document.getElementById("display-pip-btn")!; const linkUrlEl = document.getElementById("link-url") as HTMLInputElement; const openLinkBtn = document.getElementById("open-link-btn")!; const autoResizeToggleEl = document.getElementById( "auto-resize-toggle", ) as HTMLInputElement; const resize200x100Btn = document.getElementById("resize-200x100-btn")!; const resize400x300Btn = document.getElementById("resize-400x300-btn")!; const resize800x600Btn = document.getElementById("resize-800x600-btn")!; const currentSizeEl = document.getElementById("current-size")!; // Tool config elements const toolContentTypeEl = document.getElementById( "tool-content-type", ) as HTMLSelectElement; const toolMultipleBlocksEl = document.getElementById( "tool-multiple-blocks", ) as HTMLInputElement; const toolStructuredContentEl = document.getElementById( "tool-structured-content", ) as HTMLInputElement; const toolIncludeMetaEl = document.getElementById( "tool-include-meta", ) as HTMLInputElement; const toolSimulateErrorEl = document.getElementById( "tool-simulate-error", ) as HTMLInputElement; const toolDelayMsEl = document.getElementById( "tool-delay-ms", ) as HTMLInputElement; const callDebugToolBtn = document.getElementById("call-debug-tool-btn")!; const callDebugRefreshBtn = document.getElementById("call-debug-refresh-btn")!; // ============================================================================ // Utility Functions // ============================================================================ function formatTime(timestamp: number): string { const date = new Date(timestamp); const h = date.getHours().toString().padStart(2, "0"); const m = date.getMinutes().toString().padStart(2, "0"); const s = date.getSeconds().toString().padStart(2, "0"); const ms = date.getMilliseconds().toString().padStart(3, "0"); return `${h}:${m}:${s}.${ms}`; } function truncatePayload(payload: unknown): string { const str = JSON.stringify(payload); if (str.length > 100) { return str.slice(0, 100) + "..."; } return str; } // ============================================================================ // Rendering Functions // ============================================================================ function renderEventLog(): void { const filtered = state.logFilter === "all" ? state.eventLog : state.eventLog.filter((e) => e.type === state.logFilter); const fullPayload = (payload: unknown) => JSON.stringify(payload, null, 2) .replace(//g, ">"); eventLogEl.innerHTML = filtered .map( (entry, index) => `
▶ [${formatTime(entry.time)}] ${entry.type}: ${truncatePayload(entry.payload)}
${fullPayload(entry.payload)}
`, ) .join(""); // Add click handlers for toggling eventLogEl.querySelectorAll(".log-entry").forEach((el) => { el.addEventListener("click", () => { el.classList.toggle("expanded"); }); }); // Auto-scroll to bottom eventLogEl.scrollTop = eventLogEl.scrollHeight; } function renderCallbackStatus(): void { callbackTableBodyEl.innerHTML = CALLBACKS.map((name) => { const count = state.callbackCounts.get(name) ?? 0; const lastPayload = state.lastPayloads.get(name); const registered = name !== "onerror"; // All callbacks are registered return ` ${name} ${registered ? "✓" : "✗"} ${count} ${lastPayload ? truncatePayload(lastPayload) : "-"} `; }).join(""); } function renderHostInfo(): void { const ctx = app.getHostContext(); const caps = app.getHostCapabilities(); const version = app.getHostVersion(); // Context info if (ctx) { hostContextInfoEl.innerHTML = `
Theme
${ctx.theme ?? "unknown"}
Locale
${ctx.locale ?? "unknown"}
TimeZone
${ctx.timeZone ?? "unknown"}
Platform
${ctx.platform ?? "unknown"}
Display Mode
${ctx.displayMode ?? "unknown"}
Host
${version?.name ?? "unknown"} v${version?.version ?? "?"}
`; } else { hostContextInfoEl.innerHTML = "
No context available
"; } // Capabilities if (caps) { hostCapabilitiesInfoEl.innerHTML = `
openLinks
${caps.openLinks ? "✓" : "✗"}
serverTools
${caps.serverTools ? "✓" : "✗"}
serverResources
${caps.serverResources ? "✓" : "✗"}
logging
${caps.logging ? "✓" : "✗"}
message
${caps.message ? "✓" : "✗"}
updateModelContext
${caps.updateModelContext ? "✓" : "✗"}
`; } else { hostCapabilitiesInfoEl.innerHTML = "
No capabilities available
"; } // Container info if (ctx?.containerDimensions) { const dims = ctx.containerDimensions; hostContainerInfoEl.innerHTML = `
Width
${"width" in dims ? dims.width + "px" : `max ${dims.maxWidth ?? "?"}px`}
Height
${"height" in dims ? dims.height + "px" : `max ${dims.maxHeight ?? "?"}px`}
Safe Area
${ctx.safeAreaInsets ? `T${ctx.safeAreaInsets.top} R${ctx.safeAreaInsets.right} B${ctx.safeAreaInsets.bottom} L${ctx.safeAreaInsets.left}` : "none"}
`; } else { hostContainerInfoEl.innerHTML = "
No container info
"; } // Styles sample if (ctx?.styles) { const styleVars = Object.entries(ctx.styles).slice(0, 6); hostStylesSampleEl.innerHTML = styleVars .map(([key, value]) => { const color = String(value); return `
`; }) .join(""); } else { hostStylesSampleEl.innerHTML = "No styles"; } } function updateCurrentSize(): void { const w = document.documentElement.scrollWidth; const h = document.documentElement.scrollHeight; currentSizeEl.textContent = `${w}x${h}`; } // ============================================================================ // Event Logging // ============================================================================ /** * Send a log entry to the server's debug-log tool (writes to file) */ async function sendToServerLog(type: string, payload: unknown): Promise { try { await app.callServerTool({ name: "debug-log", arguments: { type, payload }, }); } catch (e) { // Log to console only - don't call logEvent to avoid infinite loop console.error("[debug-app] Failed to send log to server:", e); } } function logEvent(type: string, payload: unknown): void { const time = Date.now(); // Log to console console.log(`[debug-app] ${type}:`, payload); // Update state const count = (state.callbackCounts.get(type) ?? 0) + 1; state.callbackCounts.set(type, count); state.lastPayloads.set(type, payload); state.eventLog.push({ time, type, payload }); // Keep log manageable (max 100 entries) if (state.eventLog.length > 100) { state.eventLog.shift(); } renderEventLog(); renderCallbackStatus(); // Send to server log file (async, fire-and-forget) // Skip sending debug-log results to avoid noise if ( type !== "server-tool-result" || (payload as { name?: string })?.name !== "debug-log" ) { sendToServerLog(type, payload); } } // ============================================================================ // Safe Area Handling // ============================================================================ function handleHostContextChanged(ctx: McpUiHostContext): void { if (ctx.safeAreaInsets) { mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`; mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`; mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`; mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`; } renderHostInfo(); } // ============================================================================ // App Instance & Callbacks // ============================================================================ const app = new App( { name: "Debug App", version: "1.0.0" }, { tools: { listChanged: true } }, // Declare tools capability for oncalltool/onlisttools { autoResize: false }, // We'll manage auto-resize ourselves for toggle demo ); // Register ALL callbacks BEFORE connecting app.ontoolinput = (params) => { logEvent("ontoolinput", params); }; app.ontoolinputpartial = (params) => { logEvent("ontoolinputpartial", params); }; app.ontoolresult = (result) => { logEvent("ontoolresult", result); }; app.ontoolcancelled = (params) => { logEvent("ontoolcancelled", params); }; app.onhostcontextchanged = (ctx) => { logEvent("onhostcontextchanged", ctx); handleHostContextChanged(ctx); }; app.onteardown = async (params) => { logEvent("onteardown", params); return {}; }; app.oncalltool = async (params) => { logEvent("oncalltool", params); return { content: [{ type: "text", text: "App handled tool call" }], }; }; app.onlisttools = async (params) => { logEvent("onlisttools", params); return { tools: [] }; }; app.onerror = (error) => { logEvent("onerror", error); }; // ============================================================================ // Section Collapsing // ============================================================================ document.querySelectorAll(".section-header[data-toggle]").forEach((header) => { header.addEventListener("click", () => { const section = header.closest(".section"); section?.classList.toggle("collapsed"); }); }); // ============================================================================ // Event Log Controls // ============================================================================ logFilterEl.addEventListener("change", () => { state.logFilter = logFilterEl.value; renderEventLog(); }); clearLogBtn.addEventListener("click", () => { state.eventLog = []; renderEventLog(); }); // ============================================================================ // Message Actions // ============================================================================ sendMessageTextBtn.addEventListener("click", async () => { try { const result = await app.sendMessage({ role: "user", content: [{ type: "text", text: messageTextEl.value }], }); logEvent("send-message-result", result); } catch (e) { logEvent("error", e); } }); sendMessageImageBtn.addEventListener("click", async () => { // 1x1 red PNG for testing const redPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; try { const result = await app.sendMessage({ role: "user", content: [{ type: "image", data: redPng, mimeType: "image/png" }], }); logEvent("send-message-result", result); } catch (e) { logEvent("error", e); } }); // ============================================================================ // Logging Actions // ============================================================================ function sendLog(level: "debug" | "info" | "warning" | "error"): void { app.sendLog({ level, data: logDataEl.value }); logEvent("send-log", { level, data: logDataEl.value }); } logDebugBtn.addEventListener("click", () => sendLog("debug")); logInfoBtn.addEventListener("click", () => sendLog("info")); logWarningBtn.addEventListener("click", () => sendLog("warning")); logErrorBtn.addEventListener("click", () => sendLog("error")); // ============================================================================ // Model Context Actions // ============================================================================ updateContextTextBtn.addEventListener("click", async () => { try { await app.updateModelContext({ content: [{ type: "text", text: contextTextEl.value }], }); logEvent("update-context", { type: "text", value: contextTextEl.value }); } catch (e) { logEvent("error", e); } }); updateContextStructuredBtn.addEventListener("click", async () => { try { await app.updateModelContext({ structuredContent: { debugState: { eventCount: state.eventLog.length, timestamp: new Date().toISOString(), }, }, }); logEvent("update-context", { type: "structured" }); } catch (e) { logEvent("error", e); } }); updateContextImageBtn.addEventListener("click", async () => { // 1x1 red PNG for testing const redPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; try { await app.updateModelContext({ content: [{ type: "image", data: redPng, mimeType: "image/png" }], }); logEvent("update-context", { type: "image" }); } catch (e) { logEvent("error", e); } }); // ============================================================================ // Display Mode Actions // ============================================================================ async function requestDisplayMode( mode: "inline" | "fullscreen" | "pip", ): Promise { try { const result = await app.requestDisplayMode({ mode }); logEvent("display-mode-result", { mode, result }); } catch (e) { logEvent("error", e); } } displayInlineBtn.addEventListener("click", () => requestDisplayMode("inline")); displayFullscreenBtn.addEventListener("click", () => requestDisplayMode("fullscreen"), ); displayPipBtn.addEventListener("click", () => requestDisplayMode("pip")); // ============================================================================ // Link Action // ============================================================================ openLinkBtn.addEventListener("click", async () => { try { const result = await app.openLink({ url: linkUrlEl.value }); logEvent("open-link-result", result); } catch (e) { logEvent("error", e); } }); // ============================================================================ // Size Controls // ============================================================================ autoResizeToggleEl.addEventListener("change", () => { if (autoResizeToggleEl.checked) { if (!state.autoResizeCleanup) { state.autoResizeCleanup = app.setupSizeChangedNotifications(); } } else { if (state.autoResizeCleanup) { state.autoResizeCleanup(); state.autoResizeCleanup = null; } } logEvent("auto-resize-toggle", { enabled: autoResizeToggleEl.checked }); }); function manualResize(width: number, height: number): void { app.sendSizeChanged({ width, height }); logEvent("manual-resize", { width, height }); } resize200x100Btn.addEventListener("click", () => manualResize(200, 100)); resize400x300Btn.addEventListener("click", () => manualResize(400, 300)); resize800x600Btn.addEventListener("click", () => manualResize(800, 600)); // Update current size periodically setInterval(updateCurrentSize, 1000); // ============================================================================ // Server Tool Actions // ============================================================================ callDebugToolBtn.addEventListener("click", async () => { const args = { contentType: toolContentTypeEl.value, multipleBlocks: toolMultipleBlocksEl.checked, includeStructuredContent: toolStructuredContentEl.checked, includeMeta: toolIncludeMetaEl.checked, simulateError: toolSimulateErrorEl.checked, delayMs: parseInt(toolDelayMsEl.value, 10) || undefined, }; try { logEvent("call-server-tool", { name: "debug-tool", arguments: args }); const result = await app.callServerTool({ name: "debug-tool", arguments: args, }); logEvent("server-tool-result", result); } catch (e) { logEvent("error", e); } }); callDebugRefreshBtn.addEventListener("click", async () => { try { logEvent("call-server-tool", { name: "debug-refresh", arguments: {} }); const result = await app.callServerTool({ name: "debug-refresh", arguments: {}, }); logEvent("server-tool-result", result); } catch (e) { logEvent("error", e); } }); // ============================================================================ // Initialization // ============================================================================ // Initial render renderCallbackStatus(); // Connect to host app .connect() .then(() => { logEvent("connected", { success: true }); const ctx = app.getHostContext(); if (ctx) { handleHostContextChanged(ctx); } renderHostInfo(); updateCurrentSize(); // Auto-resize is enabled by default in App, capture cleanup if we want to toggle // We'll set it up ourselves since we want toggle control state.autoResizeCleanup = app.setupSizeChangedNotifications(); }) .catch((e) => { logEvent("error", e); });