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..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 - // 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..ba0cb035 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,33 @@ 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 ?? '*'; + + // 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 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(); if (data.error) { reject(data.error); } else { @@ -242,6 +266,37 @@ export function sendExperimentalRequest( } } }; + + const abortHandler = () => { + 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(); + reject(new Error(`Request timeout after ${timeout}ms`)); + }, timeout); + + // Set up abort signal + if (options?.signal) { + if (options.signal.aborted) { + cleanup(); + reject(new Error('Request aborted')); + return; + } + options.signal.addEventListener('abort', abortHandler); + } + window.addEventListener('message', handler); window.parent.postMessage( @@ -251,7 +306,7 @@ export function sendExperimentalRequest( method, params: params ?? {}, }, - '*', + targetOrigin, ); }); }