diff --git a/src/ui/hooks/index.ts b/src/ui/hooks/index.ts index e9d3be4c8..168af5dd2 100644 --- a/src/ui/hooks/index.ts +++ b/src/ui/hooks/index.ts @@ -1 +1,2 @@ export { useRenderData } from "./useRenderData.js"; +export { useHostCommunication } from "./useHostCommunication.js"; diff --git a/src/ui/hooks/useHostCommunication.ts b/src/ui/hooks/useHostCommunication.ts new file mode 100644 index 000000000..a212c82e3 --- /dev/null +++ b/src/ui/hooks/useHostCommunication.ts @@ -0,0 +1,140 @@ +import { useCallback, useMemo } from "react"; + +interface SendMessageOptions { + targetOrigin?: string; +} + +/** Return type for the useHostCommunication hook */ +interface UseHostCommunicationResult { + /** Sends an intent message for the host to act on */ + intent: (intent: string, params?: T) => void; + /** Notifies the host of something that happened */ + notify: (message: string) => void; + /** Asks the host to run a prompt */ + prompt: (prompt: string) => void; + /** Asks the host to execute a tool */ + tool: (toolName: string, params?: T) => void; + /** Asks the host to navigate to a URL */ + link: (url: string) => void; + /** Reports iframe size changes to the host */ + reportSizeChange: (dimensions: { width?: number; height?: number }) => void; +} + +/** + * Hook for sending UI actions to the parent window via postMessage + * This is used by iframe-based UI components to communicate back to an MCP client + * + * @example + * ```tsx + * function MyComponent() { + * const { intent, tool, link } = useHostCommunication(); + * + * return ; + * } + * ``` + */ +export function useHostCommunication(defaultOptions?: SendMessageOptions): UseHostCommunicationResult { + const targetOrigin = defaultOptions?.targetOrigin ?? "*"; + + const intent = useCallback( + (intentName: string, params?: T): void => { + window.parent.postMessage( + { + type: "intent", + payload: { + intent: intentName, + params, + }, + }, + targetOrigin + ); + }, + [targetOrigin] + ); + + const notify = useCallback( + (message: string): void => { + window.parent.postMessage( + { + type: "notify", + payload: { + message, + }, + }, + targetOrigin + ); + }, + [targetOrigin] + ); + + const prompt = useCallback( + (promptText: string): void => { + window.parent.postMessage( + { + type: "prompt", + payload: { + prompt: promptText, + }, + }, + targetOrigin + ); + }, + [targetOrigin] + ); + + const tool = useCallback( + (toolName: string, params?: T): void => { + window.parent.postMessage( + { + type: "tool", + payload: { + toolName, + params, + }, + }, + targetOrigin + ); + }, + [targetOrigin] + ); + + const link = useCallback( + (url: string): void => { + window.parent.postMessage( + { + type: "link", + payload: { + url, + }, + }, + targetOrigin + ); + }, + [targetOrigin] + ); + + const reportSizeChange = useCallback( + (dimensions: { width?: number; height?: number }): void => { + window.parent.postMessage( + { + type: "ui-size-change", + payload: dimensions, + }, + targetOrigin + ); + }, + [targetOrigin] + ); + + return useMemo( + () => ({ + intent, + notify, + prompt, + tool, + link, + reportSizeChange, + }), + [intent, notify, prompt, tool, link, reportSizeChange] + ); +} diff --git a/src/ui/hooks/useRenderData.ts b/src/ui/hooks/useRenderData.ts index 461bbc983..3c304c02f 100644 --- a/src/ui/hooks/useRenderData.ts +++ b/src/ui/hooks/useRenderData.ts @@ -13,6 +13,8 @@ interface UseRenderDataResult { data: T | null; isLoading: boolean; error: string | null; + /** The origin of the parent window, captured from the first valid message */ + parentOrigin: string | null; } /** @@ -24,6 +26,7 @@ interface UseRenderDataResult { * - data: The received render data (or null if not yet received) * - isLoading: Whether data is still being loaded * - error: Error message if message validation failed + * - parentOrigin: The origin of the parent window (for secure postMessage calls) * * @example * ```tsx @@ -32,8 +35,9 @@ interface UseRenderDataResult { * } * * function MyComponent() { - * const { data, isLoading, error } = useRenderData(); - * // ... + * const { data, isLoading, error, parentOrigin } = useRenderData(); + * const { intent } = useHostCommunication({ targetOrigin: parentOrigin ?? undefined }); + * return ; * } * ``` */ @@ -41,6 +45,7 @@ export function useRenderData(): UseRenderDataResult { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [parentOrigin, setParentOrigin] = useState(null); useEffect(() => { const handleMessage = (event: MessageEvent): void => { @@ -49,6 +54,8 @@ export function useRenderData(): UseRenderDataResult { return; } + setParentOrigin((current) => current ?? event.origin); + if (!event.data.payload || typeof event.data.payload !== "object") { const errorMsg = "Invalid payload structure received"; setError(errorMsg); @@ -88,5 +95,6 @@ export function useRenderData(): UseRenderDataResult { data, isLoading, error, + parentOrigin, }; } diff --git a/tests/unit/ui/useHostCommunication.test.ts b/tests/unit/ui/useHostCommunication.test.ts new file mode 100644 index 000000000..999160b0d --- /dev/null +++ b/tests/unit/ui/useHostCommunication.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from "vitest"; +import { createElement, type FunctionComponent } from "react"; +import { renderToString } from "react-dom/server"; +import { useHostCommunication } from "../../../src/ui/hooks/useHostCommunication.js"; + +type UseHostCommunicationResult = ReturnType; + +interface HookOptions { + targetOrigin?: string; +} + +/** + * Simple hook testing utility that renders a component using the hook + * and captures the result for assertions. + */ +function testHook(options?: HookOptions): UseHostCommunicationResult { + let hookResult: UseHostCommunicationResult | undefined; + + const TestComponent: FunctionComponent = () => { + hookResult = useHostCommunication(options); + return null; + }; + + renderToString(createElement(TestComponent)); + + if (!hookResult) { + throw new Error("Hook did not return a result"); + } + + return hookResult; +} + +describe("useHostCommunication", () => { + let postMessageMock: Mock; + let originalWindow: typeof globalThis.window; + + beforeEach(() => { + originalWindow = globalThis.window; + postMessageMock = vi.fn(); + + // Create a minimal window mock with parent.postMessage + globalThis.window = { + parent: { + postMessage: postMessageMock, + }, + } as unknown as typeof globalThis.window; + }); + + afterEach(() => { + globalThis.window = originalWindow; + vi.restoreAllMocks(); + }); + + it("intent() sends a message with name and params", () => { + const actions = testHook(); + + actions.intent("create-task", { title: "Test Task" }); + + expect(postMessageMock).toHaveBeenCalledWith( + { + type: "intent", + payload: { + intent: "create-task", + params: { title: "Test Task" }, + }, + }, + "*" + ); + }); + + it("intent() sends a message without params when not provided", () => { + const actions = testHook(); + + actions.intent("cancel"); + + expect(postMessageMock).toHaveBeenCalledWith( + { + type: "intent", + payload: { + intent: "cancel", + params: undefined, + }, + }, + "*" + ); + }); + + it("notify() sends a notification message", () => { + const actions = testHook(); + + actions.notify("Operation completed successfully"); + + expect(postMessageMock).toHaveBeenCalledWith( + { + type: "notify", + payload: { + message: "Operation completed successfully", + }, + }, + "*" + ); + }); + + it("prompt() sends a prompt message", () => { + const actions = testHook(); + + actions.prompt("What is the status of my database?"); + + expect(postMessageMock).toHaveBeenCalledWith( + { + type: "prompt", + payload: { + prompt: "What is the status of my database?", + }, + }, + "*" + ); + }); + + it("tool() sends a tool message with name and params", () => { + const actions = testHook(); + + actions.tool("listDatabases", { connectionString: "mongodb://localhost" }); + + expect(postMessageMock).toHaveBeenCalledWith( + { + type: "tool", + payload: { + toolName: "listDatabases", + params: { connectionString: "mongodb://localhost" }, + }, + }, + "*" + ); + }); + + it("tool() sends a tool message without params when not provided", () => { + const actions = testHook(); + + actions.tool("getServerInfo"); + + expect(postMessageMock).toHaveBeenCalledWith( + { + type: "tool", + payload: { + toolName: "getServerInfo", + params: undefined, + }, + }, + "*" + ); + }); + + it("link() sends a link message with a URL", () => { + const actions = testHook(); + + actions.link("https://mongodb.com/docs"); + + expect(postMessageMock).toHaveBeenCalledWith( + { + type: "link", + payload: { + url: "https://mongodb.com/docs", + }, + }, + "*" + ); + }); + + it("reportSizeChange() sends size change with both dimensions", () => { + const actions = testHook(); + + actions.reportSizeChange({ width: 400, height: 300 }); + + expect(postMessageMock).toHaveBeenCalledWith( + { + type: "ui-size-change", + payload: { width: 400, height: 300 }, + }, + "*" + ); + }); + + it("reportSizeChange() sends size change with only width", () => { + const actions = testHook(); + + actions.reportSizeChange({ width: 500 }); + + expect(postMessageMock).toHaveBeenCalledWith( + { + type: "ui-size-change", + payload: { width: 500 }, + }, + "*" + ); + }); + + it("reportSizeChange() sends size change with only height", () => { + const actions = testHook(); + + actions.reportSizeChange({ height: 250 }); + + expect(postMessageMock).toHaveBeenCalledWith( + { + type: "ui-size-change", + payload: { height: 250 }, + }, + "*" + ); + }); + + it("uses custom targetOrigin when provided in options", () => { + const actions = testHook({ targetOrigin: "https://example.com" }); + + actions.notify("test message"); + + expect(postMessageMock).toHaveBeenCalledWith( + { + type: "notify", + payload: { + message: "test message", + }, + }, + "https://example.com" + ); + }); + + it("defaults targetOrigin to '*' when not provided", () => { + const actions = testHook(); + + actions.notify("test message"); + + expect(postMessageMock).toHaveBeenCalledWith(expect.any(Object), "*"); + }); +}); diff --git a/tests/unit/ui/useRenderData.test.ts b/tests/unit/ui/useRenderData.test.ts new file mode 100644 index 000000000..3cc74bbf6 --- /dev/null +++ b/tests/unit/ui/useRenderData.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createElement, type FunctionComponent } from "react"; +import { renderToString } from "react-dom/server"; +import { useRenderData } from "../../../src/ui/hooks/useRenderData.js"; + +type UseRenderDataResult = ReturnType>; + +interface TestData { + items: string[]; +} + +/** + * Simple hook testing utility that renders a component using the hook + * and captures the result for assertions. + */ +function testHook(): UseRenderDataResult { + let hookResult: UseRenderDataResult | undefined; + + const TestComponent: FunctionComponent = () => { + hookResult = useRenderData(); + return null; + }; + + renderToString(createElement(TestComponent)); + + if (!hookResult) { + throw new Error("Hook did not return a result"); + } + + return hookResult; +} + +describe("useRenderData", () => { + let postMessageMock: ReturnType; + let originalWindow: typeof globalThis.window; + + beforeEach(() => { + originalWindow = globalThis.window; + postMessageMock = vi.fn(); + + globalThis.window = { + parent: { + postMessage: postMessageMock, + }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as typeof globalThis.window; + }); + + afterEach(() => { + globalThis.window = originalWindow; + vi.restoreAllMocks(); + }); + + it("returns initial state with isLoading true", () => { + const result = testHook(); + + expect(result.data).toBeNull(); + expect(result.isLoading).toBe(true); + expect(result.error).toBeNull(); + }); + + it("returns parentOrigin as null initially", () => { + const result = testHook(); + + expect(result.parentOrigin).toBeNull(); + }); + + it("includes parentOrigin in return type", () => { + const result = testHook(); + + // Verify the hook returns the expected shape with parentOrigin + expect(result).toHaveProperty("data"); + expect(result).toHaveProperty("isLoading"); + expect(result).toHaveProperty("error"); + expect(result).toHaveProperty("parentOrigin"); + }); + + it("returns a stable object shape for destructuring", () => { + const { data, isLoading, error, parentOrigin } = testHook(); + + expect(data).toBeNull(); + expect(isLoading).toBe(true); + expect(error).toBeNull(); + expect(parentOrigin).toBeNull(); + }); +});