From f9f084e4933c636604f665be291aae49355a582b Mon Sep 17 00:00:00 2001 From: Anakin Date: Wed, 14 Oct 2026 22:04:45 +0300 Subject: [PATCH] feat(transport): add logLevel; keep message traffic out of the console by default PostMessageTransport logged every message it sent, received, or ignored with console.debug, and hosts had no way to turn it off. With several Apps on a page, each View message produced one line per card, and full payloads reached anything that records the console. Add a logLevel option using MCP's LoggingLevel names. Message traffic is logged at "debug" and the default is "warning", so it stays off unless asked for. A malformed JSON-RPC message is logged at "error" and onerror fires regardless of level. An unrecognized level falls back to the default. App and useApp pass the level to the transport they create. The integration example opts in to "debug": the cross-App injection e2e test proves the source check by reading the transport's debug line. Fixes #786 Co-Authored-By: Claude Opus 5.5 --- examples/integration-server/src/mcp-app.tsx | 2 + src/app-bridge.ts | 5 +- src/app.ts | 16 ++- src/message-transport.examples.ts | 11 ++ src/message-transport.test.ts | 131 +++++++++++++++++++- src/message-transport.ts | 81 ++++++++++-- src/react/useApp.tsx | 4 +- 7 files changed, 231 insertions(+), 19 deletions(-) diff --git a/examples/integration-server/src/mcp-app.tsx b/examples/integration-server/src/mcp-app.tsx index 281a35674..401751ce2 100644 --- a/examples/integration-server/src/mcp-app.tsx +++ b/examples/integration-server/src/mcp-app.tsx @@ -29,6 +29,8 @@ function GetTimeApp() { const { app, error } = useApp({ appInfo: IMPLEMENTATION, capabilities: {}, + // tests/e2e/security.spec.ts reads the transport's debug lines. + logLevel: "debug", onAppCreated: (app) => { app.onteardown = async () => { log.info("App is being torn down"); diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 1ac4db485..5ad875d40 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -98,7 +98,10 @@ export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "./constants.js"; import { RESOURCE_URI_META_KEY } from "./constants.js"; type UntypedHandlerSetter = (this: unknown, ...args: unknown[]) => void; -export { PostMessageTransport } from "./message-transport.js"; +export { + PostMessageTransport, + type PostMessageTransportOptions, +} from "./message-transport.js"; /** * Extract UI resource URI from tool metadata. diff --git a/src/app.ts b/src/app.ts index 89c6c35e7..b16616241 100644 --- a/src/app.ts +++ b/src/app.ts @@ -13,6 +13,7 @@ import { type ListResourcesResult, type ListToolsRequest, type ListToolsResult, + type LoggingLevel, type LoggingMessageNotification, type ReadResourceRequest, type ReadResourceResult, @@ -110,7 +111,10 @@ export type { StandardSchemaWithJSON, } from "./standard-schema.js"; -export { PostMessageTransport } from "./message-transport.js"; +export { + PostMessageTransport, + type PostMessageTransportOptions, +} from "./message-transport.js"; export * from "./types.js"; export { applyHostStyleVariables, @@ -164,6 +168,15 @@ export type AppOptions = Omit & { * @default false */ allowUnsafeEval?: boolean; + /** + * Console log level of the {@link PostMessageTransport `PostMessageTransport`} + * that {@link App.connect `connect`} creates when called without a + * transport. A transport you pass in keeps its own setting. + * + * @see {@link PostMessageTransportOptions.logLevel `PostMessageTransportOptions.logLevel`} + * @default "warning" + */ + logLevel?: LoggingLevel; }; type RequestHandlerExtra = BaseContext; @@ -2002,6 +2015,7 @@ export class App extends Protocol { transport: Transport = new PostMessageTransport( window.parent, window.parent, + { logLevel: this.options?.logLevel }, ), options?: RequestOptions, ): Promise { diff --git a/src/message-transport.examples.ts b/src/message-transport.examples.ts index b42184bf9..85352a514 100644 --- a/src/message-transport.examples.ts +++ b/src/message-transport.examples.ts @@ -35,6 +35,17 @@ async function PostMessageTransport_host(bridge: AppBridge) { //#endregion PostMessageTransport_host } +/** + * Example: Logging message traffic while debugging. + */ +function PostMessageTransport_logLevel() { + //#region PostMessageTransport_logLevel + const transport = new PostMessageTransport(window.parent, window.parent, { + logLevel: "debug", + }); + //#endregion PostMessageTransport_logLevel +} + /** * Example: Creating transport for view (constructor only). */ diff --git a/src/message-transport.test.ts b/src/message-transport.test.ts index e2518cce4..7265aada8 100644 --- a/src/message-transport.test.ts +++ b/src/message-transport.test.ts @@ -1,7 +1,15 @@ import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"; -import type { JSONRPCMessage } from "@modelcontextprotocol/client"; - -import { PostMessageTransport } from "./message-transport.js"; +import type { + JSONRPCMessage, + LoggingLevel, +} from "@modelcontextprotocol/client"; + +import { App } from "./app.js"; +import { + PostMessageTransport, + type PostMessageTransportOptions, +} from "./message-transport.js"; +import { TOOL_INPUT_PARTIAL_METHOD } from "./spec.types.js"; /** * Minimal `window` stub for bun's DOM-less test environment. @@ -53,8 +61,8 @@ describe("PostMessageTransport", () => { let targetPostMessage: ReturnType; let eventTarget: { postMessage: typeof targetPostMessage }; - // The transport logs liberally at debug/error level. Silence during tests - // so failures are readable; restore afterwards so we don't leak state. + // The transport logs at debug/error level. Silence during tests so failures + // are readable; restore afterwards so we don't leak state. let restoreConsole: () => void; beforeEach(() => { @@ -84,10 +92,11 @@ describe("PostMessageTransport", () => { }); /** Create and start a transport wired to the shared fakes. */ - async function createStartedTransport() { + async function createStartedTransport(options?: PostMessageTransportOptions) { const transport = new PostMessageTransport( eventTarget as unknown as Window, trustedSource as MessageEventSource, + options, ); await transport.start(); return transport; @@ -387,4 +396,114 @@ describe("PostMessageTransport", () => { await transportB.close(); }); }); + + // ========================================================================== + // Logging — traffic is logged at "debug", below the default level. + // ========================================================================== + + describe("logging", () => { + let debug: ReturnType; + let error: ReturnType; + + beforeEach(() => { + debug = mock(() => {}); + error = mock(() => {}); + console.debug = debug; + console.error = error; + }); + + /** One send, plus one receive down each branch of the listener. */ + async function exchangeTraffic(transport: PostMessageTransport) { + await transport.send(validRequest); + for (const event of [ + { source: trustedSource, data: validRequest }, + { source: untrustedSource, data: validRequest }, + { source: trustedSource, data: "not JSON-RPC" }, + ]) { + fakeWindow.dispatch("message", event); + } + } + + function dispatchMalformed() { + fakeWindow.dispatch("message", { + source: trustedSource, + data: { jsonrpc: "2.0" }, + }); + } + + it.each([ + ["the default level", undefined], + ["an unrecognized level", "warn" as LoggingLevel], + ])( + "keeps message traffic out of the console at %s", + async (_, logLevel) => { + await exchangeTraffic(await createStartedTransport({ logLevel })); + + expect(debug).not.toHaveBeenCalled(); + }, + ); + + it('logs message traffic at logLevel "debug"', async () => { + await exchangeTraffic( + await createStartedTransport({ logLevel: "debug" }), + ); + + expect(debug.mock.calls.map(([label]) => label)).toEqual([ + "Sending message", + "Parsed message", + "Ignoring message from unknown source", + "Ignoring non-JSON-RPC message", + ]); + }); + + it('does not log tool-input-partial sends, even at "debug"', async () => { + const transport = await createStartedTransport({ logLevel: "debug" }); + + await transport.send({ + jsonrpc: "2.0", + method: TOOL_INPUT_PARTIAL_METHOD, + params: { arguments: {} }, + }); + + expect(debug).not.toHaveBeenCalled(); + expect(targetPostMessage).toHaveBeenCalledTimes(1); + }); + + it.each([ + [undefined, 1], + ["error", 1], + ["critical", 0], + ] as const)( + "at logLevel %p, logs a malformed message %p time(s) and still calls onerror", + async (logLevel, logged) => { + const transport = await createStartedTransport({ logLevel }); + const errors: Error[] = []; + transport.onerror = (err) => errors.push(err); + + dispatchMalformed(); + + expect(error).toHaveBeenCalledTimes(logged); + expect(errors).toHaveLength(1); + }, + ); + + it("App.connect() creates its transport at the App's logLevel", async () => { + (fakeWindow as { parent?: unknown }).parent = eventTarget; + const app = new App( + { name: "test", version: "1.0.0" }, + {}, + { logLevel: "debug" }, + ); + + const connecting = app.connect().catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 0)); + await app.close(); + await connecting; + + expect(debug).toHaveBeenCalledWith( + "Sending message", + expect.objectContaining({ method: "ui/initialize" }), + ); + }); + }); }); diff --git a/src/message-transport.ts b/src/message-transport.ts index afc85e935..58659010e 100644 --- a/src/message-transport.ts +++ b/src/message-transport.ts @@ -1,12 +1,39 @@ -import { JSONRPCMessageSchema } from "@modelcontextprotocol/core"; +import { + JSONRPCMessageSchema, + LoggingLevelSchema, +} from "@modelcontextprotocol/core"; import type { JSONRPCMessage, + LoggingLevel, MessageExtraInfo, Transport, TransportSendOptions, } from "@modelcontextprotocol/client"; import { TOOL_INPUT_PARTIAL_METHOD } from "./spec.types.js"; +/** + * Options for {@link PostMessageTransport `PostMessageTransport`}. + */ +export interface PostMessageTransportOptions { + /** + * Lowest level the transport writes to the console, using MCP's + * {@link https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging#log-levels log levels}. + * Messages below it are dropped. + * + * The transport logs message traffic at `"debug"`, so it stays out of the + * console unless this is `"debug"`. A malformed JSON-RPC message is logged + * at `"error"`; {@link PostMessageTransport.onerror `onerror`} is called + * either way. + * + * An unrecognized level falls back to the default. + * + * @default "warning" + */ + logLevel?: LoggingLevel; +} + +const LOG_LEVELS = LoggingLevelSchema.options; + /** * JSON-RPC transport using `window.postMessage` for iframe↔parent communication. * @@ -38,6 +65,17 @@ import { TOOL_INPUT_PARTIAL_METHOD } from "./spec.types.js"; * await bridge.connect(transport); * ``` * + * ## Logging + * + * Message traffic is logged at `"debug"` and is off by default. Pass a + * {@link PostMessageTransportOptions.logLevel `logLevel`} to see it: + * + * ```ts source="./message-transport.examples.ts#PostMessageTransport_logLevel" + * const transport = new PostMessageTransport(window.parent, window.parent, { + * logLevel: "debug", + * }); + * ``` + * * @see {@link app!App.connect `App.connect`} for View usage * @see {@link app-bridge!AppBridge.connect `AppBridge.connect`} for Host usage */ @@ -46,6 +84,7 @@ export class PostMessageTransport implements Transport { this: Window, ev: WindowEventMap["message"], ) => any | undefined; + private readonly minLogSeverity: number; /** * Create a new PostMessageTransport. @@ -53,6 +92,8 @@ export class PostMessageTransport implements Transport { * @param eventTarget - Target window to send messages to (default: `window.parent`) * @param eventSource - Source window for message validation. For views, pass * `window.parent`. For hosts, pass `iframe.contentWindow`. + * @param options - Transport options, such as the console + * {@link PostMessageTransportOptions.logLevel `logLevel`} * * @example View connecting to parent * ```ts source="./message-transport.examples.ts#PostMessageTransport_constructor_view" @@ -71,28 +112,40 @@ export class PostMessageTransport implements Transport { constructor( private eventTarget: Window = window.parent, private eventSource: MessageEventSource, + { logLevel = "warning" }: PostMessageTransportOptions = {}, ) { + const severity = LOG_LEVELS.indexOf(logLevel); + this.minLogSeverity = + severity < 0 ? LOG_LEVELS.indexOf("warning") : severity; this.messageListener = (event) => { if (eventSource && event.source !== this.eventSource) { - console.debug("Ignoring message from unknown source", event); + if (this.logs("debug")) { + console.debug("Ignoring message from unknown source", event); + } return; } const parsed = JSONRPCMessageSchema.safeParse(event.data); if (parsed.success) { - console.debug("Parsed message", parsed.data); + if (this.logs("debug")) { + console.debug("Parsed message", parsed.data); + } this.onmessage?.(parsed.data); } else if (event.data?.jsonrpc !== "2.0") { // Not a JSON-RPC message at all (e.g. internal frames injected by // the host environment). Ignore silently so the transport stays alive. - console.debug( - "Ignoring non-JSON-RPC message", - parsed.error.message, - event, - ); + if (this.logs("debug")) { + console.debug( + "Ignoring non-JSON-RPC message", + parsed.error.message, + event, + ); + } } else { // Has jsonrpc: "2.0" but is otherwise malformed — surface as a real // protocol error. - console.error("Failed to parse message", parsed.error.message, event); + if (this.logs("error")) { + console.error("Failed to parse message", parsed.error.message, event); + } this.onerror?.( new Error( "Invalid JSON-RPC message received: " + parsed.error.message, @@ -102,6 +155,11 @@ export class PostMessageTransport implements Transport { }; } + /** Whether a message at `level` reaches the console. */ + private logs(level: LoggingLevel): boolean { + return LOG_LEVELS.indexOf(level) >= this.minLogSeverity; + } + /** * Begin listening for messages from the event source. * @@ -124,7 +182,10 @@ export class PostMessageTransport implements Transport { async send(message: JSONRPCMessage, options?: TransportSendOptions) { // Skip debug log for high-frequency streaming notifications — these // can fire dozens of times per second and flood the console. - if ((message as { method?: string }).method !== TOOL_INPUT_PARTIAL_METHOD) { + if ( + this.logs("debug") && + (message as { method?: string }).method !== TOOL_INPUT_PARTIAL_METHOD + ) { console.debug("Sending message", message); } this.eventTarget.postMessage(message, "*"); diff --git a/src/react/useApp.tsx b/src/react/useApp.tsx index a8e1a099e..70112ff19 100644 --- a/src/react/useApp.tsx +++ b/src/react/useApp.tsx @@ -20,7 +20,7 @@ export * from "../app.js"; */ export interface UseAppOptions extends Pick< AppOptions, - "autoResize" | "strict" + "autoResize" | "strict" | "logLevel" > { /** App identification (name and version) */ appInfo: Implementation; @@ -130,6 +130,7 @@ export function useApp({ onAppCreated, autoResize = true, strict, + logLevel, }: UseAppOptions): AppState { const [app, setApp] = useState(null); const [isConnected, setIsConnected] = useState(false); @@ -144,6 +145,7 @@ export function useApp({ const transport = new PostMessageTransport( window.parent, window.parent, + { logLevel }, ); appInstance = new App(appInfo, capabilities, { autoResize, strict });