Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions examples/integration-server/src/mcp-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
5 changes: 4 additions & 1 deletion src/app-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 15 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type ListResourcesResult,
type ListToolsRequest,
type ListToolsResult,
type LoggingLevel,
type LoggingMessageNotification,
type ReadResourceRequest,
type ReadResourceResult,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -164,6 +168,15 @@ export type AppOptions = Omit<ProtocolOptions, "supportedProtocolVersions"> & {
* @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;
Expand Down Expand Up @@ -2002,6 +2015,7 @@ export class App extends Protocol<BaseContext> {
transport: Transport = new PostMessageTransport(
window.parent,
window.parent,
{ logLevel: this.options?.logLevel },
),
options?: RequestOptions,
): Promise<void> {
Expand Down
11 changes: 11 additions & 0 deletions src/message-transport.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand Down
131 changes: 125 additions & 6 deletions src/message-transport.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -53,8 +61,8 @@ describe("PostMessageTransport", () => {
let targetPostMessage: ReturnType<typeof mock>;
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(() => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -387,4 +396,114 @@ describe("PostMessageTransport", () => {
await transportB.close();
});
});

// ==========================================================================
// Logging — traffic is logged at "debug", below the default level.
// ==========================================================================

describe("logging", () => {
let debug: ReturnType<typeof mock>;
let error: ReturnType<typeof mock>;

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" }),
);
});
});
});
81 changes: 71 additions & 10 deletions src/message-transport.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -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
*/
Expand All @@ -46,13 +84,16 @@ export class PostMessageTransport implements Transport {
this: Window,
ev: WindowEventMap["message"],
) => any | undefined;
private readonly minLogSeverity: number;

/**
* Create a new PostMessageTransport.
*
* @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"
Expand All @@ -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,
Expand All @@ -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.
*
Expand All @@ -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, "*");
Expand Down
Loading