From 1830c1305db0151ef989b423050f028bcc3176a1 Mon Sep 17 00:00:00 2001 From: Abe Wheeler Date: Wed, 25 Mar 2026 10:30:18 -0500 Subject: [PATCH] Log MCP protocol messages in the inspector console Adds colored console logs for all MCP App protocol messages flowing between host and app in the inspector, giving developers visibility into the message passing without sunpeak-internal noise. --- .../sunpeak/src/inspector/mcp-app-host.ts | 160 +++++++++++++++--- 1 file changed, 134 insertions(+), 26 deletions(-) diff --git a/packages/sunpeak/src/inspector/mcp-app-host.ts b/packages/sunpeak/src/inspector/mcp-app-host.ts index 174d710..6e32d19 100644 --- a/packages/sunpeak/src/inspector/mcp-app-host.ts +++ b/packages/sunpeak/src/inspector/mcp-app-host.ts @@ -66,6 +66,7 @@ export class McpAppHost { private _prevDisplayMode: string | undefined; private _pendingToolInput: McpUiToolInputNotification['params'] | null = null; private _pendingToolResult: McpUiToolResultNotification['params'] | null = null; + private _messageListener: ((event: MessageEvent) => void) | null = null; constructor(options: McpAppHostOptions = {}) { this.options = options; @@ -92,7 +93,6 @@ export class McpAppHost { }; this.bridge.onopenlink = async ({ url }) => { - console.log('[MCP App] openLink:', url); if (this.options.onOpenLink) { this.options.onOpenLink(url); } else { @@ -101,35 +101,63 @@ export class McpAppHost { const parsed = new URL(url); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { console.warn('[MCP App] openLink blocked non-http(s) URL:', url); - return {}; + } else { + window.open(url, '_blank'); } } catch { console.warn('[MCP App] openLink blocked invalid URL:', url); - return {}; } - window.open(url, '_blank'); } - return {}; + const ack = {}; + console.log( + `%c[MCP ↓]%c host → app: %copenLink ack`, + 'color:#f9a8d4', + 'color:inherit', + 'color:#93c5fd', + ack + ); + return ack; }; this.bridge.onmessage = async ({ role, content }) => { if (this.options.onMessage) { this.options.onMessage(role, content); - } else { - // Default: log to console - console.log('[MCP App] sendMessage:', { role, content }); } - return {}; + const ack = {}; + console.log( + `%c[MCP ↓]%c host → app: %csendMessage ack`, + 'color:#f9a8d4', + 'color:inherit', + 'color:#93c5fd', + ack + ); + return ack; }; this.bridge.onrequestdisplaymode = async ({ mode }) => { this.options.onDisplayModeChange?.(mode); - return { mode }; + const result = { mode }; + console.log( + `%c[MCP ↓]%c host → app: %crequestDisplayMode result`, + 'color:#f9a8d4', + 'color:inherit', + 'color:#93c5fd', + result + ); + return result; }; this.bridge.onupdatemodelcontext = async ({ content, structuredContent }) => { this.options.onUpdateModelContext?.(content ?? [], structuredContent); - return {}; + const ack = {}; + console.log( + `%c[MCP ↓]%c host → app: %cupdateModelContext ack`, + 'color:#f9a8d4', + 'color:inherit', + 'color:#93c5fd', + ack + ); + return ack; }; this.bridge.onsizechange = (params) => { @@ -161,28 +189,42 @@ export class McpAppHost { }; this.bridge.oncalltool = async (params) => { + let result: CallToolResult; if (this.options.onCallTool) { - return this.options.onCallTool(params); + result = await this.options.onCallTool(params); + } else { + result = { + content: [ + { + type: 'text', + text: `[Inspector] Tool "${params.name}" called (no handler configured)`, + }, + ], + }; } - // Default: log to console and return empty result - console.log('[MCP App] callServerTool:', params.name, params.arguments); - return { - content: [ - { - type: 'text', - text: `[Inspector] Tool "${params.name}" called (no handler configured)`, - }, - ], - }; + console.log( + `%c[MCP ↓]%c host → app: %ccallServerTool result(${params.name})`, + 'color:#f9a8d4', + 'color:inherit', + 'color:#93c5fd', + result + ); + return result; }; this.bridge.ondownloadfile = async ({ contents }) => { if (this.options.onDownloadFile) { this.options.onDownloadFile(contents); - } else { - console.log('[MCP App] downloadFile:', contents.length, 'item(s)'); } - return {}; + const ack = {}; + console.log( + `%c[MCP ↓]%c host → app: %cdownloadFile ack`, + 'color:#f9a8d4', + 'color:inherit', + 'color:#93c5fd', + ack + ); + return ack; }; // Double-iframe sandbox support: when the proxy signals readiness, @@ -196,7 +238,33 @@ export class McpAppHost { * Connect to an iframe's contentWindow. */ async connectToIframe(contentWindow: Window): Promise { + // Clean up previous listener if reconnecting + if (this._messageListener) { + window.removeEventListener('message', this._messageListener); + } + this._contentWindow = contentWindow; + + // Log incoming MCP protocol messages from the app (skip sunpeak internals) + this._messageListener = (event: MessageEvent) => { + if (event.source !== contentWindow) return; + const data = event.data; + if (!data || typeof data !== 'object') return; + const method: string | undefined = data.method; + // Skip sunpeak-internal and sandbox infrastructure messages + if (method?.startsWith('sunpeak/') || method === 'ui/notifications/sandbox-proxy-ready') + return; + const label = method ?? (data.id != null ? `response #${data.id}` : 'unknown'); + console.log( + `%c[MCP ↑]%c app → host: %c${label}`, + 'color:#6ee7b7', + 'color:inherit', + 'color:#93c5fd', + data + ); + }; + window.addEventListener('message', this._messageListener); + const transport = new PostMessageTransport(contentWindow, contentWindow); await this.bridge.connect(transport); } @@ -244,7 +312,8 @@ export class McpAppHost { // Format as a valid JSON-RPC 2.0 notification so the SDK's // PostMessageTransport parses it without error. Unknown notification // methods are silently ignored by the bridge. - win.postMessage({ jsonrpc: '2.0', method: 'sunpeak/fence', params: { fenceId: id } }, '*'); + const fenceMsg = { jsonrpc: '2.0', method: 'sunpeak/fence', params: { fenceId: id } }; + win.postMessage(fenceMsg, '*'); } catch { // Detached or cross-origin window cleanup(); @@ -259,6 +328,13 @@ export class McpAppHost { * to commit its DOM before firing onDisplayModeReady. */ setHostContext(context: McpUiHostContext): void { + console.log( + `%c[MCP ↓]%c host → app: %csetHostContext`, + 'color:#f9a8d4', + 'color:inherit', + 'color:#93c5fd', + context + ); this.bridge.setHostContext(context); const currentMode = context.displayMode; @@ -277,6 +353,13 @@ export class McpAppHost { */ sendToolInput(args: Record): void { const params: McpUiToolInputNotification['params'] = { arguments: args }; + console.log( + `%c[MCP ↓]%c host → app: %csendToolInput`, + 'color:#f9a8d4', + 'color:inherit', + 'color:#93c5fd', + params + ); if (this._initialized) { this.bridge.sendToolInput(params); } else { @@ -289,6 +372,13 @@ export class McpAppHost { * If the app hasn't initialized yet, the result is queued. */ sendToolResult(result: CallToolResult): void { + console.log( + `%c[MCP ↓]%c host → app: %csendToolResult`, + 'color:#f9a8d4', + 'color:inherit', + 'color:#93c5fd', + result + ); if (this._initialized) { this.bridge.sendToolResult(result); } else { @@ -302,6 +392,13 @@ export class McpAppHost { */ sendToolInputPartial(args: Record): void { const params: McpUiToolInputPartialNotification['params'] = { arguments: args }; + console.log( + `%c[MCP ↓]%c host → app: %csendToolInputPartial`, + 'color:#f9a8d4', + 'color:inherit', + 'color:#93c5fd', + params + ); if (this._initialized) { this.bridge.sendToolInputPartial(params); } @@ -314,6 +411,13 @@ export class McpAppHost { */ sendToolCancelled(reason?: string): void { const params: McpUiToolCancelledNotification['params'] = reason ? { reason } : {}; + console.log( + `%c[MCP ↓]%c host → app: %csendToolCancelled`, + 'color:#f9a8d4', + 'color:inherit', + 'color:#93c5fd', + params + ); if (this._initialized) { this.bridge.sendToolCancelled(params); } @@ -344,6 +448,10 @@ export class McpAppHost { * Close the connection. */ async close(): Promise { + if (this._messageListener) { + window.removeEventListener('message', this._messageListener); + this._messageListener = null; + } this._fenceCleanup?.(); this._fenceCleanup = null; try {