feat(debug-server): add interactive debug server example (#321)

* feat(examples): add debug-server example

A comprehensive debug/development server for testing MCP Apps SDK features:
- Vanilla TypeScript UI with collapsible panels
- Message visualization (sent, received, errors)
- Server tool calling interface
- Multiple tool endpoints for testing

* refactor(debug-server): convert to React

- Replace vanilla TypeScript with React components
- Add comprehensive Host Styles panel showing all CSS variables grouped by category
- Use React hooks (useApp, useHostStyles, useAutoResize)
- Cleaner state management with useReducer for event log
- Collapsible sections for better organization
- CSS modules for scoped styling

* fix(debug-server): correct server name in main.ts

* Revert "refactor(debug-server): convert to React"

This reverts commit 5d22d2daa9a98ba412892447202ef10fbdb62f5b.

* fix(debug-server): update default values for multipleBlocks and includeMeta

* fix(debug-server): correct serve script and server name

- serve script now runs main.ts instead of server.ts
- Updated server name to 'Debug Server'
- Fixed npx command in comment

* fix: increase maxHeight to 6000px and add tools capability to debug app

- basic-host: Increase maxHeight from 600 to 6000px
- debug-server: Add tools capability to enable oncalltool/onlisttools handlers

* fix(debug-server): use consistent callback names for event tracking

Event types now match callback names (ontoolinput, ontoolresult, etc.)
so the Callback Status panel shows correct counts and payloads.

* feat(debug-server): add Update (Image) button for model context

* feat(debug-server): add expandable event log entries

- Click on any event log row to expand/collapse
- Arrow indicator rotates when expanded
- Full JSON payload shown indented in pre block
- Max height with scroll for large payloads

* style(debug-server): format long line in fullPayload function

* test(debug-server): add to E2E tests and screenshot specs

* chore: regenerate package-lock.json for cross-platform compatibility

* update dep
This commit is contained in:
Olivier Chafik 2026-01-23 12:39:40 +00:00 • committed by GitHub
commit 8a877b36b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 2253 additions and 435 deletions

View file

@ -0,0 +1,33 @@
* {
box-sizing: border-box;
}
html, body {
font-family: system-ui, -apple-system, sans-serif;
font-size: 14px;
margin: 0;
padding: 0;
}
code {
font-family: ui-monospace, "SF Mono", Monaco, "Cascadia Code", Consolas, monospace;
font-size: 0.9em;
background: rgba(0, 0, 0, 0.05);
padding: 0.1em 0.3em;
border-radius: 3px;
}
@media (prefers-color-scheme: dark) {
code {
background: rgba(255, 255, 255, 0.1);
}
}
button {
cursor: pointer;
}
input, select, button {
font-family: inherit;
font-size: inherit;
}

View file

@ -0,0 +1,367 @@
.main {
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-success: #16a34a;
--color-warning: #ca8a04;
--color-error: #dc2626;
--color-border: #e5e7eb;
--color-bg-subtle: #f9fafb;
width: 100%;
max-width: 800px;
padding: 1rem;
margin: 0 auto;
}
@media (prefers-color-scheme: dark) {
.main {
--color-border: #374151;
--color-bg-subtle: #1f2937;
}
}
/* Section styling */
.section {
border: 1px solid var(--color-border);
border-radius: 8px;
margin-bottom: 1rem;
overflow: hidden;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
margin: 0;
font-size: 1rem;
font-weight: 600;
background: var(--color-bg-subtle);
border-bottom: 1px solid var(--color-border);
}
.collapsible .section-header {
cursor: pointer;
user-select: none;
}
.collapsible .section-header:hover {
background: var(--color-border);
}
.toggle-icon {
transition: transform 0.2s;
}
.collapsed .toggle-icon {
transform: rotate(-90deg);
}
.collapsed .section-content {
display: none;
}
.section-content {
padding: 1rem;
}
.header-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
/* Event log */
.event-log {
max-height: 200px;
overflow-y: auto;
padding: 0.5rem;
font-family: ui-monospace, monospace;
font-size: 0.85rem;
background: var(--color-bg-subtle);
}
.log-entry {
padding: 0.25rem 0;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
}
.log-entry:hover {
background: var(--color-bg-hover, rgba(0, 0, 0, 0.03));
}
.log-entry:last-child {
border-bottom: none;
}
.log-entry-header {
display: flex;
align-items: center;
gap: 0.25rem;
}
.log-toggle {
color: #9ca3af;
font-size: 0.7rem;
width: 1rem;
transition: transform 0.15s ease;
user-select: none;
}
.log-entry.expanded .log-toggle {
transform: rotate(90deg);
}
.log-time {
color: #6b7280;
margin-right: 0.5rem;
}
.log-type {
font-weight: 600;
margin-right: 0.5rem;
}
.log-type.ontoolinput { color: var(--color-primary); }
.log-type.ontoolinputpartial { color: #8b5cf6; }
.log-type.ontoolresult { color: var(--color-success); }
.log-type.ontoolcancelled { color: var(--color-warning); }
.log-type.onhostcontextchanged { color: #7c3aed; }
.log-type.onteardown { color: #f97316; }
.log-type.oncalltool { color: #ec4899; }
.log-type.onlisttools { color: #14b8a6; }
.log-type.onerror { color: var(--color-error); }
.log-type.connected { color: var(--color-success); }
.log-type.update-context { color: #0891b2; }
.log-payload-preview {
color: #6b7280;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.log-payload-full {
display: none;
margin: 0.5rem 0 0.25rem 1.25rem;
padding: 0.5rem;
background: var(--color-bg-subtle, #f3f4f6);
border-radius: 4px;
font-size: 0.8rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
max-height: 300px;
overflow-y: auto;
}
.log-entry.expanded .log-payload-full {
display: block;
}
/* Info grid */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
.info-group h3 {
margin: 0 0 0.5rem 0;
font-size: 0.9rem;
color: #6b7280;
}
.info-group dl {
margin: 0;
font-size: 0.85rem;
}
.info-group dt {
font-weight: 600;
color: #374151;
}
.info-group dd {
margin: 0 0 0.5rem 0;
color: #6b7280;
}
@media (prefers-color-scheme: dark) {
.info-group dt { color: #d1d5db; }
.info-group dd { color: #9ca3af; }
}
.styles-sample {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.style-swatch {
width: 24px;
height: 24px;
border-radius: 4px;
border: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: white;
text-shadow: 0 0 2px black;
}
/* Callback table */
.callback-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.callback-table th,
.callback-table td {
padding: 0.5rem;
text-align: left;
border-bottom: 1px solid var(--color-border);
}
.callback-table th {
background: var(--color-bg-subtle);
font-weight: 600;
}
.callback-table .registered-yes {
color: var(--color-success);
}
.callback-table .registered-no {
color: #9ca3af;
}
.callback-table .payload-preview {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
font-family: ui-monospace, monospace;
font-size: 0.8rem;
}
.callback-table .payload-preview:hover {
white-space: normal;
word-break: break-all;
}
/* Action groups */
.action-group {
margin-bottom: 1.5rem;
}
.action-group:last-child {
margin-bottom: 0;
}
.action-group h3 {
margin: 0 0 0.75rem 0;
font-size: 0.9rem;
font-weight: 600;
color: #374151;
border-bottom: 1px solid var(--color-border);
padding-bottom: 0.25rem;
}
@media (prefers-color-scheme: dark) {
.action-group h3 { color: #d1d5db; }
}
.action-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.5rem;
}
.action-row:last-child {
margin-bottom: 0;
}
.action-row input[type="text"],
.action-row input[type="url"],
.action-row input[type="number"] {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
}
.action-row input[type="file"] {
flex: 1;
}
.action-row button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
background: var(--color-primary);
color: white;
font-weight: 500;
}
.action-row button:hover {
background: var(--color-primary-hover);
}
.btn-row {
flex-wrap: wrap;
}
.btn-small {
padding: 0.375rem 0.75rem !important;
font-size: 0.85rem;
}
/* Tool config */
.tool-config {
background: var(--color-bg-subtle);
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 0.75rem;
}
.config-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.config-row:last-child {
margin-bottom: 0;
}
.config-row label {
min-width: 120px;
font-size: 0.85rem;
}
.config-row select,
.config-row input[type="number"] {
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
}
/* Filter select */
#log-filter {
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
font-size: 0.85rem;
}

View file

@ -0,0 +1,642 @@
/**
* @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<string, number>;
lastPayloads: Map<string, unknown>;
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, "&lt;")
.replace(/>/g, "&gt;");
eventLogEl.innerHTML = filtered
.map(
(entry, index) => `
<div class="log-entry" data-index="${index}">
<div class="log-entry-header">
<span class="log-toggle">▶</span>
<span class="log-time">[${formatTime(entry.time)}]</span>
<span class="log-type ${entry.type}">${entry.type}:</span>
<span class="log-payload-preview">${truncatePayload(entry.payload)}</span>
</div>
<pre class="log-payload-full">${fullPayload(entry.payload)}</pre>
</div>
`,
)
.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 `
<tr>
<td><code>${name}</code></td>
<td class="${registered ? "registered-yes" : "registered-no"}">${registered ? "✓" : "✗"}</td>
<td>${count}</td>
<td class="payload-preview">${lastPayload ? truncatePayload(lastPayload) : "-"}</td>
</tr>
`;
}).join("");
}
function renderHostInfo(): void {
const ctx = app.getHostContext();
const caps = app.getHostCapabilities();
const version = app.getHostVersion();
// Context info
if (ctx) {
hostContextInfoEl.innerHTML = `
<dt>Theme</dt><dd>${ctx.theme ?? "unknown"}</dd>
<dt>Locale</dt><dd>${ctx.locale ?? "unknown"}</dd>
<dt>TimeZone</dt><dd>${ctx.timeZone ?? "unknown"}</dd>
<dt>Platform</dt><dd>${ctx.platform ?? "unknown"}</dd>
<dt>Display Mode</dt><dd>${ctx.displayMode ?? "unknown"}</dd>
<dt>Host</dt><dd>${version?.name ?? "unknown"} v${version?.version ?? "?"}</dd>
`;
} else {
hostContextInfoEl.innerHTML = "<dd>No context available</dd>";
}
// Capabilities
if (caps) {
hostCapabilitiesInfoEl.innerHTML = `
<dt>openLinks</dt><dd>${caps.openLinks ? "✓" : "✗"}</dd>
<dt>serverTools</dt><dd>${caps.serverTools ? "✓" : "✗"}</dd>
<dt>serverResources</dt><dd>${caps.serverResources ? "✓" : "✗"}</dd>
<dt>logging</dt><dd>${caps.logging ? "✓" : "✗"}</dd>
<dt>message</dt><dd>${caps.message ? "✓" : "✗"}</dd>
<dt>updateModelContext</dt><dd>${caps.updateModelContext ? "✓" : "✗"}</dd>
`;
} else {
hostCapabilitiesInfoEl.innerHTML = "<dd>No capabilities available</dd>";
}
// Container info
if (ctx?.containerDimensions) {
const dims = ctx.containerDimensions;
hostContainerInfoEl.innerHTML = `
<dt>Width</dt><dd>${"width" in dims ? dims.width + "px" : `max ${dims.maxWidth ?? "?"}px`}</dd>
<dt>Height</dt><dd>${"height" in dims ? dims.height + "px" : `max ${dims.maxHeight ?? "?"}px`}</dd>
<dt>Safe Area</dt><dd>${ctx.safeAreaInsets ? `T${ctx.safeAreaInsets.top} R${ctx.safeAreaInsets.right} B${ctx.safeAreaInsets.bottom} L${ctx.safeAreaInsets.left}` : "none"}</dd>
`;
} else {
hostContainerInfoEl.innerHTML = "<dd>No container info</dd>";
}
// 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 `<div class="style-swatch" style="background: ${color};" title="${key}: ${color}"></div>`;
})
.join("");
} else {
hostStylesSampleEl.innerHTML = "<span>No styles</span>";
}
}
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<void> {
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<void> {
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);
});