177 lines
5.5 KiB
TypeScript
177 lines
5.5 KiB
TypeScript
import {
|
|
JSONRPCMessage,
|
|
JSONRPCMessageSchema,
|
|
MessageExtraInfo,
|
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
import {
|
|
Transport,
|
|
TransportSendOptions,
|
|
} from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
|
|
/**
|
|
* JSON-RPC transport using window.postMessage for iframe↔parent communication.
|
|
*
|
|
* This transport enables bidirectional communication between MCP Apps running in
|
|
* iframes and their host applications using the browser's postMessage API. It
|
|
* implements the MCP SDK's Transport interface.
|
|
*
|
|
* ## Security
|
|
*
|
|
* The `eventSource` parameter provides origin validation by filtering messages
|
|
* from specific sources. Guest UIs typically don't need to specify this (they only
|
|
* communicate with their parent), but hosts should validate the iframe source for
|
|
* security.
|
|
*
|
|
* ## Usage
|
|
*
|
|
* **Guest UI (default)**:
|
|
* ```typescript
|
|
* const transport = new PostMessageTransport(window.parent);
|
|
* await app.connect(transport);
|
|
* ```
|
|
*
|
|
* **Host (with source validation)**:
|
|
* ```typescript
|
|
* const iframe = document.getElementById('app-iframe') as HTMLIFrameElement;
|
|
* const transport = new PostMessageTransport(
|
|
* iframe.contentWindow!,
|
|
* iframe.contentWindow // Validate messages from this iframe only
|
|
* );
|
|
* await bridge.connect(transport);
|
|
* ```
|
|
*
|
|
* @see {@link app.App.connect} for Guest UI usage
|
|
* @see {@link app-bridge.AppBridge.connect} for Host usage
|
|
*/
|
|
export class PostMessageTransport implements Transport {
|
|
private messageListener: (
|
|
this: Window,
|
|
ev: WindowEventMap["message"],
|
|
) => any | undefined;
|
|
|
|
/**
|
|
* Create a new PostMessageTransport.
|
|
*
|
|
* @param eventTarget - Target window to send messages to (default: window.parent)
|
|
* @param eventSource - Optional source validation. If specified, only messages from
|
|
* this source will be accepted. Guest UIs typically don't need this (they only
|
|
* receive from parent), but hosts should validate the iframe source.
|
|
*
|
|
* @example Guest UI connecting to parent
|
|
* ```typescript
|
|
* const transport = new PostMessageTransport(window.parent);
|
|
* ```
|
|
*
|
|
* @example Host connecting to iframe with validation
|
|
* ```typescript
|
|
* const iframe = document.getElementById('app') as HTMLIFrameElement;
|
|
* const transport = new PostMessageTransport(
|
|
* iframe.contentWindow!,
|
|
* iframe.contentWindow // Only accept messages from this iframe
|
|
* );
|
|
* ```
|
|
*/
|
|
constructor(
|
|
private eventTarget: Window = window.parent,
|
|
private eventSource: MessageEventSource,
|
|
) {
|
|
this.messageListener = (event) => {
|
|
if (eventSource && event.source !== this.eventSource) {
|
|
console.error("Ignoring message from unknown source", event);
|
|
return;
|
|
}
|
|
const parsed = JSONRPCMessageSchema.safeParse(event.data);
|
|
if (parsed.success) {
|
|
console.debug("Parsed message", parsed.data);
|
|
this.onmessage?.(parsed.data);
|
|
} else {
|
|
console.error("Failed to parse message", parsed.error.message, event);
|
|
this.onerror?.(
|
|
new Error(
|
|
"Invalid JSON-RPC message received: " + parsed.error.message,
|
|
),
|
|
);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Begin listening for messages from the event source.
|
|
*
|
|
* Registers a message event listener on the window. Must be called before
|
|
* messages can be received.
|
|
*/
|
|
async start() {
|
|
window.addEventListener("message", this.messageListener);
|
|
}
|
|
|
|
/**
|
|
* Send a JSON-RPC message to the target window.
|
|
*
|
|
* Messages are sent using postMessage with "*" origin, meaning they are visible
|
|
* to all frames. The receiver should validate the message source for security.
|
|
*
|
|
* @param message - JSON-RPC message to send
|
|
* @param options - Optional send options (currently unused)
|
|
*/
|
|
async send(message: JSONRPCMessage, options?: TransportSendOptions) {
|
|
console.debug("Sending message", message);
|
|
this.eventTarget.postMessage(message, "*");
|
|
}
|
|
|
|
/**
|
|
* Stop listening for messages and cleanup.
|
|
*
|
|
* Removes the message event listener and calls the {@link onclose} callback if set.
|
|
*/
|
|
async close() {
|
|
window.removeEventListener("message", this.messageListener);
|
|
this.onclose?.();
|
|
}
|
|
|
|
/**
|
|
* Called when the transport is closed.
|
|
*
|
|
* Set this handler to be notified when {@link close} is called.
|
|
*/
|
|
onclose?: () => void;
|
|
|
|
/**
|
|
* Called when a message parsing error occurs.
|
|
*
|
|
* This handler is invoked when a received message fails JSON-RPC schema
|
|
* validation. The error parameter contains details about the validation failure.
|
|
*
|
|
* @param error - Error describing the validation failure
|
|
*/
|
|
onerror?: (error: Error) => void;
|
|
|
|
/**
|
|
* Called when a valid JSON-RPC message is received.
|
|
*
|
|
* This handler is invoked after message validation succeeds. The {@link start}
|
|
* method must be called before messages will be received.
|
|
*
|
|
* @param message - The validated JSON-RPC message
|
|
* @param extra - Optional metadata about the message (unused in this transport)
|
|
*/
|
|
onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;
|
|
|
|
/**
|
|
* Optional session identifier for this transport connection.
|
|
*
|
|
* Set by the MCP SDK to track the connection session. Not required for
|
|
* PostMessageTransport functionality.
|
|
*/
|
|
sessionId?: string;
|
|
|
|
/**
|
|
* Callback to set the negotiated protocol version.
|
|
*
|
|
* The MCP SDK calls this during initialization to communicate the protocol
|
|
* version negotiated with the peer.
|
|
*
|
|
* @param version - The negotiated protocol version string
|
|
*/
|
|
setProtocolVersion?: (version: string) => void;
|
|
}
|