diff --git a/docs/src/.vitepress/config.ts b/docs/src/.vitepress/config.ts index 76049f89..7b1a3aaa 100644 --- a/docs/src/.vitepress/config.ts +++ b/docs/src/.vitepress/config.ts @@ -190,7 +190,12 @@ export default withMermaid( { text: 'Overview', link: '/guide/client/overview' }, { text: 'Walkthrough', link: '/guide/client/walkthrough' }, { - text: 'UIResourceRenderer', + text: 'AppRenderer', + link: '/guide/client/app-renderer', + }, + { + text: 'UIResourceRenderer (Legacy)', + collapsed: true, items: [ { text: 'Overview', diff --git a/docs/src/guide/client/app-renderer.md b/docs/src/guide/client/app-renderer.md new file mode 100644 index 00000000..cbe9d2d4 --- /dev/null +++ b/docs/src/guide/client/app-renderer.md @@ -0,0 +1,179 @@ +# AppRenderer Component + +`AppRenderer` is the recommended component for rendering MCP tool UIs in your host application. It implements the [MCP Apps](../mcp-apps) standard, handling the complete lifecycle: resource fetching, sandbox setup, JSON-RPC communication, and tool input/result delivery. + +For lower-level control or when you already have HTML and an `AppBridge` instance, use [`AppFrame`](../mcp-apps#appframe-component) instead. + +## Quick Example + +```tsx +import { AppRenderer, type AppRendererHandle } from '@mcp-ui/client'; + +function ToolUI({ client, toolName, toolInput, toolResult }) { + const appRef = useRef(null); + + return ( + { + window.open(url, '_blank'); + return {}; + }} + onMessage={async (params) => { + console.log('Message from tool UI:', params); + return {}; + }} + onError={(error) => console.error('Tool UI error:', error)} + /> + ); +} +``` + +## Props Reference + +### Core Props + +| Prop | Type | Description | +|------|------|-------------| +| `client` | `Client` | Optional MCP client for automatic resource fetching and MCP request forwarding. Omit to use custom handlers instead. | +| `toolName` | `string` | Name of the MCP tool to render UI for. | +| `sandbox` | `SandboxConfig` | Sandbox configuration with the proxy URL and optional CSP. | +| `html` | `string` | Optional pre-fetched HTML. If provided, skips all resource fetching. | +| `toolResourceUri` | `string` | Optional pre-fetched resource URI. If not provided, fetched via the client. | +| `toolInput` | `Record` | Tool arguments to pass to the guest UI once it initializes. | +| `toolResult` | `CallToolResult` | Tool execution result to pass to the guest UI. | +| `toolInputPartial` | `object` | Partial/streaming tool input to send progressively. | +| `toolCancelled` | `boolean` | Set to `true` to notify the guest UI that tool execution was cancelled. | +| `hostContext` | `McpUiHostContext` | Host context (theme, locale, viewport, etc.) to pass to the guest UI. | + +### Event Handlers + +| Prop | Type | Description | +|------|------|-------------| +| `onOpenLink` | `(params, extra) => Promise` | Handler for open-link requests from the guest UI. | +| `onMessage` | `(params, extra) => Promise` | Handler for message requests from the guest UI. | +| `onLoggingMessage` | `(params) => void` | Handler for logging messages from the guest UI. | +| `onSizeChanged` | `(params) => void` | Handler for size change notifications from the guest UI. | +| `onError` | `(error: Error) => void` | Callback invoked when an error occurs during setup or message handling. | +| `onFallbackRequest` | `(request, extra) => Promise>` | Catch-all for JSON-RPC requests not handled by built-in handlers. See [Handling Custom Requests](#handling-custom-requests). | + +### MCP Request Handlers + +These override the automatic forwarding to the MCP client when provided: + +| Prop | Type | Description | +|------|------|-------------| +| `onCallTool` | `(params, extra) => Promise` | Handler for `tools/call` requests. | +| `onListResources` | `(params, extra) => Promise` | Handler for `resources/list` requests. | +| `onListResourceTemplates` | `(params, extra) => Promise` | Handler for `resources/templates/list` requests. | +| `onReadResource` | `(params, extra) => Promise` | Handler for `resources/read` requests. | +| `onListPrompts` | `(params, extra) => Promise` | Handler for `prompts/list` requests. | + +### Ref Methods + +Access via `useRef`: + +| Method | Description | +|--------|-------------| +| `sendToolListChanged()` | Notify guest UI that the server's tool list has changed. | +| `sendResourceListChanged()` | Notify guest UI that the server's resource list has changed. | +| `sendPromptListChanged()` | Notify guest UI that the server's prompt list has changed. | +| `teardownResource()` | Notify the guest UI before unmounting (graceful shutdown). | + +## Using Without an MCP Client + +You can use `AppRenderer` without a full MCP client by providing custom handlers: + +```tsx + { + return myMcpProxy.readResource({ uri }); + }} + onCallTool={async (params) => { + return myMcpProxy.callTool(params); + }} +/> +``` + +Or provide pre-fetched HTML directly: + +```tsx + +``` + +## Handling Custom Requests + +AppRenderer includes built-in handlers for standard MCP Apps methods (`tools/call`, `ui/message`, `ui/open-link`, etc.). The `onFallbackRequest` prop lets you handle **any JSON-RPC request that doesn't match a built-in handler**. This is useful for: + +- **Experimental methods** -- prototype new capabilities (e.g., `x/clipboard/write`, `x/analytics/track`) +- **MCP methods not yet in the Apps spec** -- support standard MCP methods like `sampling/createMessage` before they're officially added to MCP Apps + +Under the hood, this is wired to `AppBridge`'s `fallbackRequestHandler` from the MCP SDK `Protocol` class. The guest UI sends a standard JSON-RPC request via `postMessage`, and if AppBridge has no registered handler for the method, it delegates to `onFallbackRequest`. + +### Host-side handler + +```tsx +import { AppRenderer, type JSONRPCRequest } from '@mcp-ui/client'; +import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; + + { + switch (request.method) { + case 'x/clipboard/write': + await navigator.clipboard.writeText(request.params?.text as string); + return { success: true }; + case 'sampling/createMessage': + // Forward to MCP server + return client.createMessage(request.params); + default: + throw new McpError(ErrorCode.MethodNotFound, `Unknown method: ${request.method}`); + } + }} +/> +``` + +### Guest-side (inside tool UI HTML) + +```ts +import { sendExperimentalRequest } from '@mcp-ui/server'; + +// Send a custom request to the host -- returns a Promise with the response +const result = await sendExperimentalRequest('x/clipboard/write', { text: 'hello' }); +``` + +The `sendExperimentalRequest` helper sends a properly formatted JSON-RPC request via `window.parent.postMessage`. The full request/response cycle flows through `PostMessageTransport` and the sandbox proxy, just like built-in methods. + +::: tip Method Naming Convention +Use the `x//` prefix for experimental methods (e.g., `x/clipboard/write`). Standard MCP methods not yet in the Apps spec (e.g., `sampling/createMessage`) should use their canonical method names. When an experimental method proves useful, it can be promoted to a standard method in the [ext-apps spec](https://github.com/modelcontextprotocol/ext-apps). +::: + +## Sandbox Proxy + +AppRenderer requires a sandbox proxy HTML file to be served. This provides security isolation for the guest UI by running it inside a double-iframe architecture. The sandbox proxy URL should point to a page that loads the MCP Apps sandbox proxy script. + +See the [Client SDK Walkthrough](./walkthrough#_3-set-up-a-sandbox-proxy) for setup instructions. + +## Related + +- [Client SDK Walkthrough](./walkthrough) -- Step-by-step guide to building an MCP Apps client +- [MCP Apps Overview](../mcp-apps) -- Protocol details and server-side setup +- [Protocol Details](../protocol-details) -- Wire format reference +- [AppFrame Component](../mcp-apps#appframe-component) -- Lower-level rendering component diff --git a/docs/src/guide/client/overview.md b/docs/src/guide/client/overview.md index c554590c..3957925f 100644 --- a/docs/src/guide/client/overview.md +++ b/docs/src/guide/client/overview.md @@ -164,7 +164,8 @@ function SmartResourceRenderer({ resource }) { See the following pages for more details: - [Client SDK Walkthrough](./walkthrough.md) - **Step-by-step guide to building an MCP Apps client** -- [UIResourceRenderer Component](./resource-renderer.md) - Legacy MCP-UI renderer +- [AppRenderer Component](./app-renderer.md) - **Full API reference for the recommended MCP Apps renderer** +- [UIResourceRenderer Component](./resource-renderer.md) - Legacy MCP-UI renderer (deprecated) - [HTMLResourceRenderer Component](./html-resource.md) - [RemoteDOMResourceRenderer Component](./remote-dom-resource.md) - [React Usage & Examples](./react-usage-examples.md) diff --git a/docs/src/guide/client/react-usage-examples.md b/docs/src/guide/client/react-usage-examples.md index 62f5e1f2..ce83db27 100644 --- a/docs/src/guide/client/react-usage-examples.md +++ b/docs/src/guide/client/react-usage-examples.md @@ -1,9 +1,9 @@ -# React Usage & Examples - -::: tip MCP Apps Hosts -For MCP Apps hosts (the standard), use `AppRenderer` instead of `UIResourceRenderer`. See [MCP Apps Integration](../mcp-apps#apprenderer-component) for details. +# React Usage & Examples (Legacy) +::: warning Deprecated This page covers `UIResourceRenderer`, which is for **legacy MCP-UI hosts** that embed resources in tool responses. + +For new integrations, use [`AppRenderer`](./app-renderer) instead. See the [Client SDK Walkthrough](./walkthrough) for a step-by-step guide. ::: Here's how to use the `` component from `@mcp-ui/client` in a React environment. diff --git a/docs/src/guide/client/resource-renderer.md b/docs/src/guide/client/resource-renderer.md index 148bef22..b5457afb 100644 --- a/docs/src/guide/client/resource-renderer.md +++ b/docs/src/guide/client/resource-renderer.md @@ -1,9 +1,9 @@ -# UIResourceRenderer Component - -::: tip For MCP Apps Hosts -If your host supports MCP Apps (the standard), use [`AppRenderer`](../mcp-apps#apprenderer-component) instead. It fetches resources, handles the lifecycle, and provides a complete MCP Apps experience. +# UIResourceRenderer Component (Deprecated) +::: warning Deprecated `UIResourceRenderer` is for **legacy MCP-UI hosts** that embed UI resources directly in tool responses. + +For new integrations, use [`AppRenderer`](./app-renderer) instead. It implements the MCP Apps standard, handles the full lifecycle (resource fetching, communication, sandbox setup), and is the recommended component for all MCP Apps hosts. ::: The `UIResourceRenderer` component renders MCP-UI resources embedded in tool responses. It automatically detects the resource type and renders the appropriate component internally. diff --git a/docs/src/guide/client/walkthrough.md b/docs/src/guide/client/walkthrough.md index 48411267..21ce4f77 100644 --- a/docs/src/guide/client/walkthrough.md +++ b/docs/src/guide/client/walkthrough.md @@ -369,7 +369,7 @@ Or provide pre-fetched HTML directly: ## Next Steps -- [AppRenderer Props Reference](./resource-renderer.md) - Complete API documentation +- [AppRenderer Reference](./app-renderer.md) - Complete API documentation for AppRenderer - [Protocol Details](../protocol-details.md) - Understanding the MCP Apps protocol -- [Legacy MCP-UI Support](../mcp-apps.md) - Supporting older MCP-UI hosts +- [MCP Apps Overview](../mcp-apps.md) - Protocol details and server-side setup - [Supported Hosts](../supported-hosts.md) - See which hosts support MCP Apps diff --git a/docs/src/guide/client/wc-usage-examples.md b/docs/src/guide/client/wc-usage-examples.md index 9aeb4dc8..79edc09d 100644 --- a/docs/src/guide/client/wc-usage-examples.md +++ b/docs/src/guide/client/wc-usage-examples.md @@ -1,9 +1,9 @@ -# Web Component Usage & Examples - -::: tip MCP Apps Hosts -For MCP Apps hosts (the standard), use `AppRenderer` or `AppFrame` React components. See [MCP Apps Integration](../mcp-apps#apprenderer-component) for details. +# Web Component Usage & Examples (Legacy) +::: warning Deprecated The `UIResourceRenderer` Web Component is for **legacy MCP-UI hosts** that embed resources in tool responses. + +For new integrations, use [`AppRenderer`](./app-renderer) instead. See the [Client SDK Walkthrough](./walkthrough) for a step-by-step guide. ::: `UIResourceRenderer` is available as a Web Component and serves as a powerful tool for integrating MCP-UI resources into non-React frameworks such as Vue, Svelte, or vanilla JavaScript. It offers the same core functionality as its React counterpart but is used as a standard HTML element. diff --git a/docs/src/guide/mcp-apps.md b/docs/src/guide/mcp-apps.md index 8f55853a..8ed7217e 100644 --- a/docs/src/guide/mcp-apps.md +++ b/docs/src/guide/mcp-apps.md @@ -510,6 +510,7 @@ function ToolUI({ client, toolName, toolInput, toolResult }) { - `toolInput` / `toolResult` - Tool arguments and results to pass to the UI - `hostContext` - Theme, locale, viewport info for the guest UI - `onOpenLink` / `onMessage` / `onLoggingMessage` - Handlers for guest UI requests +- `onFallbackRequest` - Catch-all for JSON-RPC requests not handled by the built-in handlers (see [Handling Custom Requests](#handling-custom-requests-onfallbackrequest)) **Ref Methods:** - `sendToolListChanged()` - Notify guest when tools change @@ -517,6 +518,55 @@ function ToolUI({ client, toolName, toolInput, toolResult }) { - `sendPromptListChanged()` - Notify guest when prompts change - `teardownResource()` - Clean up before unmounting +### Handling Custom Requests (`onFallbackRequest`) + +AppRenderer includes built-in handlers for standard MCP Apps methods (`tools/call`, `ui/message`, `ui/open-link`, etc.). The `onFallbackRequest` prop lets you handle **any JSON-RPC request that doesn't match a built-in handler**. This is useful for: + +- **Experimental methods** — prototype new capabilities (e.g., `x/clipboard/write`, `x/analytics/track`) +- **MCP methods not yet in the Apps spec** — support standard MCP methods like `sampling/createMessage` before they're officially added to MCP Apps + +Under the hood, this is wired to `AppBridge`'s `fallbackRequestHandler` from the MCP SDK `Protocol` class. The guest UI sends a standard JSON-RPC request via `postMessage`, and if AppBridge has no registered handler for the method, it delegates to `onFallbackRequest`. + +**Host-side handler:** + +```tsx +import { AppRenderer, type JSONRPCRequest } from '@mcp-ui/client'; +import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; + + { + switch (request.method) { + case 'x/clipboard/write': + await navigator.clipboard.writeText(request.params?.text as string); + return { success: true }; + case 'sampling/createMessage': + // Forward to MCP server + return client.createMessage(request.params); + default: + throw new McpError(ErrorCode.MethodNotFound, `Unknown method: ${request.method}`); + } + }} +/> +``` + +**Guest-side (inside tool UI HTML):** + +```ts +import { sendExperimentalRequest } from '@mcp-ui/server'; + +// Send a custom request to the host — returns a Promise with the response +const result = await sendExperimentalRequest('x/clipboard/write', { text: 'hello' }); +``` + +The `sendExperimentalRequest` helper sends a properly formatted JSON-RPC request via `window.parent.postMessage`. The full request/response cycle flows through `PostMessageTransport` and the sandbox proxy, just like built-in methods. + +::: tip Method Naming Convention +Use the `x//` prefix for experimental methods (e.g., `x/clipboard/write`). Standard MCP methods not yet in the Apps spec (e.g., `sampling/createMessage`) should use their canonical method names. When an experimental method proves useful, it can be promoted to a standard method in the [ext-apps spec](https://github.com/modelcontextprotocol/ext-apps). +::: + ### Using Without an MCP Client You can use `AppRenderer` without a full MCP client by providing custom handlers: diff --git a/sdks/typescript/client/src/components/AppRenderer.tsx b/sdks/typescript/client/src/components/AppRenderer.tsx index e1eb6cca..71766946 100644 --- a/sdks/typescript/client/src/components/AppRenderer.tsx +++ b/sdks/typescript/client/src/components/AppRenderer.tsx @@ -4,6 +4,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { type CallToolRequest, type CallToolResult, + type JSONRPCRequest, type ListPromptsRequest, type ListPromptsResult, type ListResourcesRequest, @@ -153,6 +154,39 @@ export interface AppRendererProps { params: ListPromptsRequest['params'], extra: RequestHandlerExtra, ) => Promise; + + // --- Fallback Handler --- + + /** + * Handler for JSON-RPC requests from the guest UI that don't match any + * built-in handler (e.g., experimental methods like "x/clipboard/write", + * 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 + * a McpError for unsupported methods. + * + * @example + * ```tsx + * { + * switch (request.method) { + * case 'x/clipboard/write': + * await navigator.clipboard.writeText(request.params?.text); + * return { success: true }; + * case 'sampling/createMessage': + * return mcpClient.createMessage(request.params); + * default: + * throw new McpError(ErrorCode.MethodNotFound, `Unknown method: ${request.method}`); + * } + * }} + * /> + * ``` + */ + onFallbackRequest?: ( + request: JSONRPCRequest, + extra: RequestHandlerExtra, + ) => Promise>; } /** @@ -246,6 +280,7 @@ export const AppRenderer = forwardRef((prop onListResourceTemplates, onReadResource, onListPrompts, + onFallbackRequest, } = props; // State @@ -264,6 +299,7 @@ export const AppRenderer = forwardRef((prop const onListResourceTemplatesRef = useRef(onListResourceTemplates); const onReadResourceRef = useRef(onReadResource); const onListPromptsRef = useRef(onListPrompts); + const onFallbackRequestRef = useRef(onFallbackRequest); useEffect(() => { onMessageRef.current = onMessage; @@ -276,6 +312,7 @@ export const AppRenderer = forwardRef((prop onListResourceTemplatesRef.current = onListResourceTemplates; onReadResourceRef.current = onReadResource; onListPromptsRef.current = onListPrompts; + onFallbackRequestRef.current = onFallbackRequest; }); // Expose send methods via ref for Host → Guest notifications @@ -353,6 +390,19 @@ export const AppRenderer = forwardRef((prop bridge.onlistprompts = (params, extra) => onListPromptsRef.current!(params, extra); } + // Register fallback handler for unregistered JSON-RPC methods + // (e.g., experimental events like "x/clipboard/write" or MCP methods + // not yet in the Apps spec like "sampling/createMessage") + bridge.fallbackRequestHandler = async (request, extra) => { + if (onFallbackRequestRef.current) { + return onFallbackRequestRef.current(request, extra); + } + throw new McpError( + ErrorCode.MethodNotFound, + `No handler for method: ${request.method}`, + ); + }; + if (!mounted) return; setAppBridge(bridge); } catch (err) { diff --git a/sdks/typescript/client/src/components/__tests__/AppRenderer.test.tsx b/sdks/typescript/client/src/components/__tests__/AppRenderer.test.tsx index 5187ee66..22b5acaf 100644 --- a/sdks/typescript/client/src/components/__tests__/AppRenderer.test.tsx +++ b/sdks/typescript/client/src/components/__tests__/AppRenderer.test.tsx @@ -44,6 +44,7 @@ vi.mock('@modelcontextprotocol/ext-apps/app-bridge', () => { onlistresourcetemplates: undefined, onreadresource: undefined, onlistprompts: undefined, + fallbackRequestHandler: undefined, setHostContext: vi.fn(), sendToolInputPartial: vi.fn(), sendToolCancelled: vi.fn(), @@ -66,6 +67,15 @@ const mockClient = { }), }; +function createMockExtra() { + return { + signal: new AbortController().signal, + requestId: 1, + sendNotification: vi.fn(), + sendRequest: vi.fn(), + }; +} + describe('', () => { const defaultProps: AppRendererProps = { client: mockClient as unknown as Client, @@ -472,6 +482,118 @@ describe('', () => { }); }); + describe('onFallbackRequest prop', () => { + it('should register fallbackRequestHandler on AppBridge', async () => { + const onFallbackRequest = vi.fn().mockResolvedValue({ success: true }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + // fallbackRequestHandler should always be set (even without the prop, it throws MethodNotFound) + expect(mockBridgeInstance?.fallbackRequestHandler).toBeDefined(); + }); + + it('should invoke onFallbackRequest when fallbackRequestHandler is called', async () => { + const onFallbackRequest = vi.fn().mockResolvedValue({ clipboard: 'written' }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + // Simulate AppBridge calling the fallback handler with a custom method + const mockRequest = { + jsonrpc: '2.0' as const, + id: 1, + method: 'x/clipboard/write', + params: { text: 'hello' }, + }; + const mockExtra = createMockExtra(); + + const result = await mockBridgeInstance?.fallbackRequestHandler?.(mockRequest, mockExtra as never); + + expect(onFallbackRequest).toHaveBeenCalledWith(mockRequest, mockExtra); + expect(result).toEqual({ clipboard: 'written' }); + }); + + it('should throw MethodNotFound when onFallbackRequest is not provided', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + const mockRequest = { + jsonrpc: '2.0' as const, + id: 1, + method: 'x/unknown/method', + params: {}, + }; + const mockExtra = createMockExtra(); + + await expect( + mockBridgeInstance?.fallbackRequestHandler?.(mockRequest, mockExtra as never), + ).rejects.toThrow('No handler for method: x/unknown/method'); + }); + + it('should use the latest onFallbackRequest callback (ref stability)', async () => { + const firstHandler = vi.fn().mockResolvedValue({ version: 1 }); + const secondHandler = vi.fn().mockResolvedValue({ version: 2 }); + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + // Update the handler + rerender(); + + const mockRequest = { + jsonrpc: '2.0' as const, + id: 1, + method: 'x/test/method', + params: {}, + }; + const mockExtra = createMockExtra(); + + const result = await mockBridgeInstance?.fallbackRequestHandler?.(mockRequest, mockExtra as never); + + // Should use the second (latest) handler + expect(firstHandler).not.toHaveBeenCalled(); + expect(secondHandler).toHaveBeenCalledWith(mockRequest, mockExtra); + expect(result).toEqual({ version: 2 }); + }); + + it('should propagate errors from onFallbackRequest', async () => { + const onFallbackRequest = vi.fn().mockRejectedValue(new Error('Permission denied')); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + const mockRequest = { + jsonrpc: '2.0' as const, + id: 1, + method: 'x/restricted/action', + params: {}, + }; + const mockExtra = createMockExtra(); + + await expect( + mockBridgeInstance?.fallbackRequestHandler?.(mockRequest, mockExtra as never), + ).rejects.toThrow('Permission denied'); + }); + }); + describe('no client', () => { it('should work without client when html is provided', async () => { const props: AppRendererProps = { diff --git a/sdks/typescript/client/src/index.ts b/sdks/typescript/client/src/index.ts index a71e3a8f..2ac99349 100644 --- a/sdks/typescript/client/src/index.ts +++ b/sdks/typescript/client/src/index.ts @@ -31,6 +31,9 @@ export { type McpUiHostContext, } from '@modelcontextprotocol/ext-apps/app-bridge'; +// Re-export JSONRPCRequest for typing onFallbackRequest handlers +export type { JSONRPCRequest } from '@modelcontextprotocol/sdk/types.js'; + // The types needed to create a custom component library export type { ComponentLibrary, diff --git a/sdks/typescript/server/src/__tests__/index.test.ts b/sdks/typescript/server/src/__tests__/index.test.ts index 500dd055..dd977e3a 100644 --- a/sdks/typescript/server/src/__tests__/index.test.ts +++ b/sdks/typescript/server/src/__tests__/index.test.ts @@ -1,5 +1,6 @@ import { createUIResource, + sendExperimentalRequest, uiActionResultToolCall, uiActionResultPrompt, uiActionResultLink, @@ -329,3 +330,189 @@ describe('UI Action Result Creators', () => { }); }); }); + +describe('sendExperimentalRequest', () => { + let originalParent: typeof window.parent; + const mockParent = { + postMessage: vi.fn(), + }; + + beforeEach(() => { + vi.useFakeTimers(); + originalParent = window.parent; + // Simulate being inside an iframe by making parent !== window + Object.defineProperty(window, 'parent', { + value: mockParent, + writable: true, + configurable: true, + }); + mockParent.postMessage.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + Object.defineProperty(window, 'parent', { + value: originalParent, + writable: true, + configurable: true, + }); + }); + + /** Simulate the host responding to a JSON-RPC request via postMessage */ + function simulateResponse(data: Record, source: unknown = mockParent) { + const event = new MessageEvent('message', { data, source: source as Window }); + window.dispatchEvent(event); + } + + it('should reject when not inside an iframe', async () => { + // Restore parent === window (top-level context) + Object.defineProperty(window, 'parent', { + value: window, + writable: true, + configurable: true, + }); + + await expect(sendExperimentalRequest('x/test')).rejects.toThrow( + 'sendExperimentalRequest must be called from within an iframe', + ); + }); + + it('should post a JSON-RPC request to the parent window', () => { + sendExperimentalRequest('x/clipboard/write', { text: 'hello' }); + + expect(mockParent.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: '2.0', + method: 'x/clipboard/write', + params: { text: 'hello' }, + }), + '*', + ); + }); + + it('should omit params when not provided', () => { + sendExperimentalRequest('x/ping'); + + const posted = mockParent.postMessage.mock.calls[0][0]; + expect(posted).not.toHaveProperty('params'); + }); + + it('should resolve with the result on a successful response', async () => { + const promise = sendExperimentalRequest('x/test', { key: 'val' }); + const sentId = mockParent.postMessage.mock.calls[0][0].id; + + simulateResponse({ jsonrpc: '2.0', id: sentId, result: { success: true } }); + + await expect(promise).resolves.toEqual({ success: true }); + }); + + it('should reject with the error on an error response', async () => { + const promise = sendExperimentalRequest('x/test'); + const sentId = mockParent.postMessage.mock.calls[0][0].id; + + const error = { code: -32601, message: 'Method not found' }; + simulateResponse({ jsonrpc: '2.0', id: sentId, error }); + + await expect(promise).rejects.toEqual(error); + }); + + it('should ignore messages from non-parent sources', async () => { + const promise = sendExperimentalRequest('x/test', undefined, { timeoutMs: 100 }); + const sentId = mockParent.postMessage.mock.calls[0][0].id; + + // Message from a different source — should be ignored + simulateResponse({ jsonrpc: '2.0', id: sentId, result: { spoofed: true } }, {} as Window); + + // The promise should still be pending; advance timers to trigger timeout + vi.advanceTimersByTime(100); + + await expect(promise).rejects.toThrow('timed out'); + }); + + it('should ignore messages with non-matching ids', async () => { + const promise = sendExperimentalRequest('x/test', undefined, { timeoutMs: 100 }); + const sentId = mockParent.postMessage.mock.calls[0][0].id; + + // Response with a different id + simulateResponse({ jsonrpc: '2.0', id: sentId + 999, result: { wrong: true } }); + + vi.advanceTimersByTime(100); + + await expect(promise).rejects.toThrow('timed out'); + }); + + it('should reject after default timeout', async () => { + const promise = sendExperimentalRequest('x/slow'); + + vi.advanceTimersByTime(30_000); + + await expect(promise).rejects.toThrow('timed out after 30000ms'); + }); + + it('should reject after custom timeout', async () => { + const promise = sendExperimentalRequest('x/slow', undefined, { timeoutMs: 500 }); + + vi.advanceTimersByTime(500); + + await expect(promise).rejects.toThrow('timed out after 500ms'); + }); + + it('should not timeout when timeoutMs is 0', async () => { + const promise = sendExperimentalRequest('x/test', undefined, { timeoutMs: 0 }); + const sentId = mockParent.postMessage.mock.calls[0][0].id; + + // Advance far into the future — should not reject + vi.advanceTimersByTime(999_999); + + // Now respond — should still resolve + simulateResponse({ jsonrpc: '2.0', id: sentId, result: { late: true } }); + + await expect(promise).resolves.toEqual({ late: true }); + }); + + it('should reject immediately when signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + + await expect( + sendExperimentalRequest('x/test', undefined, { signal: controller.signal }), + ).rejects.toThrow('was aborted'); + }); + + it('should reject when signal is aborted mid-request', async () => { + const controller = new AbortController(); + const promise = sendExperimentalRequest('x/test', undefined, { + signal: controller.signal, + timeoutMs: 0, + }); + + controller.abort(); + + await expect(promise).rejects.toThrow('was aborted'); + }); + + it('should clean up the message listener after resolving', async () => { + const removeSpy = vi.spyOn(window, 'removeEventListener'); + + const promise = sendExperimentalRequest('x/test'); + const sentId = mockParent.postMessage.mock.calls[0][0].id; + + simulateResponse({ jsonrpc: '2.0', id: sentId, result: {} }); + await promise; + + expect(removeSpy).toHaveBeenCalledWith('message', expect.any(Function)); + removeSpy.mockRestore(); + }); + + it('should clean up the message listener after timeout', async () => { + const removeSpy = vi.spyOn(window, 'removeEventListener'); + + const promise = sendExperimentalRequest('x/test', undefined, { timeoutMs: 100 }); + + vi.advanceTimersByTime(100); + + await expect(promise).rejects.toThrow('timed out'); + expect(removeSpy).toHaveBeenCalledWith('message', expect.any(Function)); + removeSpy.mockRestore(); + }); +}); diff --git a/sdks/typescript/server/src/index.ts b/sdks/typescript/server/src/index.ts index 4d4763d5..56e9c055 100644 --- a/sdks/typescript/server/src/index.ts +++ b/sdks/typescript/server/src/index.ts @@ -200,3 +200,104 @@ export function uiActionResultNotification(message: string): UIActionResultNotif }, }; } + +// --- Experimental JSON-RPC helpers --- +// These enable guest UIs to send custom JSON-RPC requests to the host's +// onFallbackRequest handler on AppRenderer, using the existing PostMessageTransport. + +let _experimentalRequestId = 0; + +const DEFAULT_EXPERIMENTAL_REQUEST_TIMEOUT_MS = 30_000; + +/** + * Send an experimental JSON-RPC request to the host from inside a guest UI iframe. + * + * The host must have an `onFallbackRequest` handler registered on AppRenderer. + * The request flows through PostMessageTransport and AppBridge's fallbackRequestHandler. + * + * @param method - JSON-RPC method name. Convention: use "x//" for + * 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 + * @param options.signal - AbortSignal to cancel the request + * @param options.timeoutMs - Timeout in milliseconds (default: 30000). Set to 0 to disable. + * @returns Promise that resolves with the host's JSON-RPC response result, or rejects + * with the JSON-RPC error + * + * @example + * ```ts + * const result = await sendExperimentalRequest('x/clipboard/write', { text: 'hello' }); + * ``` + */ +export function sendExperimentalRequest( + method: string, + params?: Record, + options?: { signal?: AbortSignal; timeoutMs?: number }, +): Promise { + if (window.parent === window) { + return Promise.reject( + new Error('sendExperimentalRequest must be called from within an iframe'), + ); + } + + const id = ++_experimentalRequestId; + const timeoutMs = options?.timeoutMs ?? DEFAULT_EXPERIMENTAL_REQUEST_TIMEOUT_MS; + + return new Promise((resolve, reject) => { + let timeoutId: ReturnType | undefined; + + const cleanup = () => { + window.removeEventListener('message', handler); + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + options?.signal?.removeEventListener('abort', onAbort); + }; + + const handler = (event: MessageEvent) => { + // Only accept responses from the parent window + if (event.source !== window.parent) return; + + const data = event.data; + if (data?.jsonrpc === '2.0' && data?.id === id) { + cleanup(); + if (data.error) { + reject(data.error); + } else { + resolve(data.result); + } + } + }; + + const onAbort = () => { + cleanup(); + reject(new Error(`Experimental request "${method}" was aborted`)); + }; + + if (options?.signal?.aborted) { + reject(new Error(`Experimental request "${method}" was aborted`)); + return; + } + + options?.signal?.addEventListener('abort', onAbort); + window.addEventListener('message', handler); + + if (timeoutMs > 0) { + timeoutId = setTimeout(() => { + cleanup(); + reject(new Error(`Experimental request "${method}" timed out after ${timeoutMs}ms`)); + }, timeoutMs); + } + + window.parent.postMessage( + { + jsonrpc: '2.0', + id, + method, + ...(params !== undefined && { params }), + }, + '*', + ); + }); +}