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 });