From 017412c1810c80be3cdf27d1b4e32b02754b435e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 01:20:51 +0000 Subject: [PATCH 1/3] Initial plan From e50f5fc54c9e39436a3ead52c3f1febd72d4675c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 01:27:18 +0000 Subject: [PATCH 2/3] Address code review comments on experimental messages - Add timeout/AbortSignal support to sendExperimentalRequest - Validate event.source to prevent spoofing attacks - Change targetOrigin from '*' to configurable parameter - Update onFallbackRequest return type to Promise - Extract mockExtra helper in AppRenderer tests Co-authored-by: idosal <18148989+idosal@users.noreply.github.com> --- .../client/src/components/AppRenderer.tsx | 7 ++- .../components/__tests__/AppRenderer.test.tsx | 18 ++++-- .../__tests__/UIResourceRendererWC.test.tsx | 2 +- sdks/typescript/server/src/index.ts | 61 ++++++++++++++++++- 4 files changed, 78 insertions(+), 10 deletions(-) diff --git a/sdks/typescript/client/src/components/AppRenderer.tsx b/sdks/typescript/client/src/components/AppRenderer.tsx index 06543e3f..a0e6ecb7 100644 --- a/sdks/typescript/client/src/components/AppRenderer.tsx +++ b/sdks/typescript/client/src/components/AppRenderer.tsx @@ -21,6 +21,7 @@ import { import { AppBridge, RESOURCE_MIME_TYPE, + type AppResult, type McpUiMessageRequest, type McpUiMessageResult, type McpUiOpenLinkRequest, @@ -163,7 +164,7 @@ export interface AppRendererProps { * or standard MCP methods not yet in the Apps spec like "sampling/createMessage"). * * This is wired to AppBridge's `fallbackRequestHandler` from the MCP SDK Protocol class. - * It receives the full JSON-RPC request and should return a result object or throw + * It receives the full JSON-RPC request and should return a result value or throw * an McpError for unsupported methods. * * @example @@ -186,7 +187,7 @@ export interface AppRendererProps { onFallbackRequest?: ( request: JSONRPCRequest, extra: RequestHandlerExtra, - ) => Promise>; + ) => Promise; } /** @@ -395,7 +396,7 @@ export const AppRenderer = forwardRef((prop // not yet in the Apps spec like "sampling/createMessage") bridge.fallbackRequestHandler = async (request, extra) => { if (onFallbackRequestRef.current) { - return onFallbackRequestRef.current(request, extra); + return onFallbackRequestRef.current(request, extra) as Promise; } throw new McpError( ErrorCode.MethodNotFound, diff --git a/sdks/typescript/client/src/components/__tests__/AppRenderer.test.tsx b/sdks/typescript/client/src/components/__tests__/AppRenderer.test.tsx index 44b71add..8ce900ac 100644 --- a/sdks/typescript/client/src/components/__tests__/AppRenderer.test.tsx +++ b/sdks/typescript/client/src/components/__tests__/AppRenderer.test.tsx @@ -67,6 +67,16 @@ const mockClient = { }), }; +// Helper to create mock RequestHandlerExtra +function createMockExtra(): import('../AppRenderer').RequestHandlerExtra { + return { + signal: new AbortController().signal, + requestId: 1, + sendNotification: vi.fn(), + sendRequest: vi.fn(), + } as import('../AppRenderer').RequestHandlerExtra; +} + describe('', () => { const defaultProps: AppRendererProps = { client: mockClient as unknown as Client, @@ -503,7 +513,7 @@ describe('', () => { method: 'x/clipboard/write', params: { text: 'hello' }, }; - const mockExtra = { signal: new AbortController().signal, requestId: 1, sendNotification: vi.fn(), sendRequest: vi.fn() }; + const mockExtra = createMockExtra(); const result = await mockBridgeInstance?.fallbackRequestHandler?.(mockRequest, mockExtra as never); @@ -524,7 +534,7 @@ describe('', () => { method: 'x/unknown/method', params: {}, }; - const mockExtra = { signal: new AbortController().signal, requestId: 1, sendNotification: vi.fn(), sendRequest: vi.fn() }; + const mockExtra = createMockExtra(); await expect( mockBridgeInstance?.fallbackRequestHandler?.(mockRequest, mockExtra as never), @@ -552,7 +562,7 @@ describe('', () => { method: 'x/test/method', params: {}, }; - const mockExtra = { signal: new AbortController().signal, requestId: 1, sendNotification: vi.fn(), sendRequest: vi.fn() }; + const mockExtra = createMockExtra(); const result = await mockBridgeInstance?.fallbackRequestHandler?.(mockRequest, mockExtra as never); @@ -577,7 +587,7 @@ describe('', () => { method: 'x/restricted/action', params: {}, }; - const mockExtra = { signal: new AbortController().signal, requestId: 1, sendNotification: vi.fn(), sendRequest: vi.fn() }; + const mockExtra = createMockExtra(); await expect( mockBridgeInstance?.fallbackRequestHandler?.(mockRequest, mockExtra as never), diff --git a/sdks/typescript/client/src/components/__tests__/UIResourceRendererWC.test.tsx b/sdks/typescript/client/src/components/__tests__/UIResourceRendererWC.test.tsx index c13dfbf7..2a395db7 100644 --- a/sdks/typescript/client/src/components/__tests__/UIResourceRendererWC.test.tsx +++ b/sdks/typescript/client/src/components/__tests__/UIResourceRendererWC.test.tsx @@ -141,7 +141,7 @@ describe('UIResourceRendererWC', () => { const el = document.createElement('ui-resource-renderer'); // Verify the element has the connectedMoveCallback method - // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect('connectedMoveCallback' in el).toBe(true); // The element should be an HTMLElement diff --git a/sdks/typescript/server/src/index.ts b/sdks/typescript/server/src/index.ts index a468c98a..6bb2ce17 100644 --- a/sdks/typescript/server/src/index.ts +++ b/sdks/typescript/server/src/index.ts @@ -217,6 +217,10 @@ let _experimentalRequestId = 0; * experimental methods (e.g., "x/clipboard/write"). Standard MCP methods not yet * in the Apps spec (e.g., "sampling/createMessage") can use their canonical names. * @param params - Request parameters + * @param options - Optional configuration for the request + * @param options.signal - Optional AbortSignal to cancel the request + * @param options.timeout - Optional timeout in milliseconds (default: 30000) + * @param options.targetOrigin - Optional target origin for postMessage (default: '*') * @returns Promise that resolves with the host's JSON-RPC response result, or rejects * with the JSON-RPC error * @@ -228,13 +232,37 @@ let _experimentalRequestId = 0; export function sendExperimentalRequest( method: string, params?: Record, + options?: { + signal?: AbortSignal; + timeout?: number; + targetOrigin?: string; + }, ): Promise { const id = ++_experimentalRequestId; + const timeout = options?.timeout ?? 30000; + const targetOrigin = options?.targetOrigin ?? '*'; + return new Promise((resolve, reject) => { + let isSettled = false; + + const cleanup = (timeoutId: ReturnType) => { + if (!isSettled) { + isSettled = true; + window.removeEventListener('message', handler); + clearTimeout(timeoutId); + options?.signal?.removeEventListener('abort', abortHandler); + } + }; + const handler = (event: MessageEvent) => { + // Validate that the message comes from the parent window + if (event.source !== window.parent) { + return; + } + const data = event.data; if (data?.jsonrpc === '2.0' && data?.id === id) { - window.removeEventListener('message', handler); + cleanup(timeoutId); if (data.error) { reject(data.error); } else { @@ -242,8 +270,37 @@ export function sendExperimentalRequest( } } }; + + const abortHandler = () => { + cleanup(timeoutId); + reject(new Error('Request aborted')); + }; + + // Set up timeout + const timeoutId = setTimeout(() => { + cleanup(timeoutId); + reject(new Error(`Request timeout after ${timeout}ms`)); + }, timeout); + + // Set up abort signal + if (options?.signal) { + if (options.signal.aborted) { + cleanup(timeoutId); + reject(new Error('Request aborted')); + return; + } + options.signal.addEventListener('abort', abortHandler); + } + window.addEventListener('message', handler); + // Check if window.parent is available + if (!window.parent || window.parent === window) { + cleanup(timeoutId); + reject(new Error('No parent window available')); + return; + } + window.parent.postMessage( { jsonrpc: '2.0', @@ -251,7 +308,7 @@ export function sendExperimentalRequest( method, params: params ?? {}, }, - '*', + targetOrigin, ); }); } From d214b7b5386f30a9e62c2f4920751fef1f009890 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 01:28:54 +0000 Subject: [PATCH 3/3] Refactor sendExperimentalRequest for better code structure - Move parent window check to start of function - Reorder function declarations to avoid forward references - Remove unnecessary blank line in test file Co-authored-by: idosal <18148989+idosal@users.noreply.github.com> --- .../__tests__/UIResourceRendererWC.test.tsx | 1 - sdks/typescript/server/src/index.ts | 38 +++++++++---------- 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/sdks/typescript/client/src/components/__tests__/UIResourceRendererWC.test.tsx b/sdks/typescript/client/src/components/__tests__/UIResourceRendererWC.test.tsx index 2a395db7..273fdcdf 100644 --- a/sdks/typescript/client/src/components/__tests__/UIResourceRendererWC.test.tsx +++ b/sdks/typescript/client/src/components/__tests__/UIResourceRendererWC.test.tsx @@ -141,7 +141,6 @@ describe('UIResourceRendererWC', () => { const el = document.createElement('ui-resource-renderer'); // Verify the element has the connectedMoveCallback method - expect('connectedMoveCallback' in el).toBe(true); // The element should be an HTMLElement diff --git a/sdks/typescript/server/src/index.ts b/sdks/typescript/server/src/index.ts index 6bb2ce17..ba0cb035 100644 --- a/sdks/typescript/server/src/index.ts +++ b/sdks/typescript/server/src/index.ts @@ -242,18 +242,14 @@ export function sendExperimentalRequest( const timeout = options?.timeout ?? 30000; const targetOrigin = options?.targetOrigin ?? '*'; + // Check if window.parent is available before setting up anything + if (!window.parent || window.parent === window) { + return Promise.reject(new Error('No parent window available')); + } + return new Promise((resolve, reject) => { let isSettled = false; - const cleanup = (timeoutId: ReturnType) => { - if (!isSettled) { - isSettled = true; - window.removeEventListener('message', handler); - clearTimeout(timeoutId); - options?.signal?.removeEventListener('abort', abortHandler); - } - }; - const handler = (event: MessageEvent) => { // Validate that the message comes from the parent window if (event.source !== window.parent) { @@ -262,7 +258,7 @@ export function sendExperimentalRequest( const data = event.data; if (data?.jsonrpc === '2.0' && data?.id === id) { - cleanup(timeoutId); + cleanup(); if (data.error) { reject(data.error); } else { @@ -272,20 +268,29 @@ export function sendExperimentalRequest( }; const abortHandler = () => { - cleanup(timeoutId); + cleanup(); reject(new Error('Request aborted')); }; + const cleanup = () => { + if (!isSettled) { + isSettled = true; + window.removeEventListener('message', handler); + clearTimeout(timeoutId); + options?.signal?.removeEventListener('abort', abortHandler); + } + }; + // Set up timeout const timeoutId = setTimeout(() => { - cleanup(timeoutId); + cleanup(); reject(new Error(`Request timeout after ${timeout}ms`)); }, timeout); // Set up abort signal if (options?.signal) { if (options.signal.aborted) { - cleanup(timeoutId); + cleanup(); reject(new Error('Request aborted')); return; } @@ -294,13 +299,6 @@ export function sendExperimentalRequest( window.addEventListener('message', handler); - // Check if window.parent is available - if (!window.parent || window.parent === window) { - cleanup(timeoutId); - reject(new Error('No parent window available')); - return; - } - window.parent.postMessage( { jsonrpc: '2.0',