diff --git a/docs/src/guide/mcp-apps.md b/docs/src/guide/mcp-apps.md index a3ec54b5..8f9afb0f 100644 --- a/docs/src/guide/mcp-apps.md +++ b/docs/src/guide/mcp-apps.md @@ -497,6 +497,8 @@ function ToolUI({ client, toolName, toolInput, toolResult }) { - `toolResourceUri` - Optional pre-fetched resource URI - `toolInput` / `toolResult` - Tool arguments and results to pass to the UI - `hostContext` - Theme, locale, viewport info for the guest UI +- `hostInfo` - Host application identification (name and version). Defaults to `{ name: 'MCP-UI Host', version: '1.0.0' }` +- `hostCapabilities` - Host capabilities to advertise to the MCP app (e.g., `openLinks`, `serverTools`, `logging`) - `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)) @@ -506,6 +508,43 @@ function ToolUI({ client, toolName, toolInput, toolResult }) { - `sendPromptListChanged()` - Notify guest when prompts change - `teardownResource()` - Clean up before unmounting +<<<<<<< copilot/add-hostinfo-hostcapabilities-props +### Customizing Host Identity + +By default, `AppRenderer` identifies itself as "MCP-UI Host" to guest apps. You can customize the host identity and capabilities to properly identify your application: + +```tsx +import { AppRenderer } from '@mcp-ui/client'; +import type { Implementation, McpUiHostCapabilities } from '@mcp-ui/client'; + +function ToolUI({ client, toolName }) { + const hostInfo: Implementation = { + name: 'goose', + version: '2.3.4', + }; + + const hostCapabilities: McpUiHostCapabilities = { + openLinks: {}, + serverTools: { listChanged: true }, + serverResources: { listChanged: true }, + logging: {}, + }; + + return ( + window.open(url)} + /> + ); +} +``` + +This allows guest apps to know they're running in your specific host application and what capabilities are available. +======= ### 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: @@ -554,6 +593,7 @@ The `sendExperimentalRequest` helper sends a properly formatted JSON-RPC request ::: 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). ::: +>>>>>>> main ### Using Without an MCP Client diff --git a/sdks/typescript/client/src/components/AppRenderer.tsx b/sdks/typescript/client/src/components/AppRenderer.tsx index 8ad7fcbd..addaa336 100644 --- a/sdks/typescript/client/src/components/AppRenderer.tsx +++ b/sdks/typescript/client/src/components/AppRenderer.tsx @@ -4,6 +4,7 @@ import { type Client } from '@modelcontextprotocol/sdk/client/index.js'; import { type CallToolRequest, type CallToolResult, + type Implementation, type JSONRPCRequest, type ListPromptsRequest, type ListPromptsResult, @@ -28,6 +29,7 @@ import { type McpUiSizeChangedNotification, type McpUiToolInputPartialNotification, type McpUiHostContext, + type McpUiHostCapabilities, } from '@modelcontextprotocol/ext-apps/app-bridge'; import { AppFrame, type SandboxConfig } from './AppFrame'; @@ -87,6 +89,12 @@ export interface AppRendererProps { /** Host context (theme, viewport, locale, etc.) to pass to the guest UI */ hostContext?: McpUiHostContext; + /** Host application identification (name and version). Defaults to { name: 'MCP-UI Host', version: '1.0.0' } */ + hostInfo?: Implementation; + + /** Host capabilities to advertise to the MCP app. If not provided, capabilities are derived from serverCapabilities. */ + hostCapabilities?: McpUiHostCapabilities; + /** Handler for open-link requests from the guest UI */ onOpenLink?: ( params: McpUiOpenLinkRequest['params'], @@ -268,6 +276,8 @@ export const AppRenderer = forwardRef((prop toolInputPartial, toolCancelled, hostContext, + hostInfo, + hostCapabilities, onMessage, onOpenLink, onLoggingMessage, @@ -328,23 +338,33 @@ export const AppRenderer = forwardRef((prop // Effect 1: Create and configure AppBridge useEffect(() => { let mounted = true; + let currentBridge: AppBridge | null = null; const createBridge = () => { try { const serverCapabilities = client?.getServerCapabilities(); + + // Use provided hostInfo or defaults + const finalHostInfo: Implementation = hostInfo ?? { + name: 'MCP-UI Host', + version: '1.0.0', + }; + + // Use provided hostCapabilities or build from serverCapabilities + const finalHostCapabilities: McpUiHostCapabilities = hostCapabilities ?? { + openLinks: {}, + serverTools: serverCapabilities?.tools, + serverResources: serverCapabilities?.resources, + }; + const bridge = new AppBridge( client ?? null, - { - name: 'MCP-UI Host', - version: '1.0.0', - }, - { - openLinks: {}, - serverTools: serverCapabilities?.tools, - serverResources: serverCapabilities?.resources, - }, + finalHostInfo, + finalHostCapabilities, ); + currentBridge = bridge; + // Register message handler bridge.onmessage = async (params, extra) => { if (onMessageRef.current) { @@ -416,8 +436,14 @@ export const AppRenderer = forwardRef((prop return () => { mounted = false; + // Clean up the bridge connection to prevent message listener accumulation + if (currentBridge) { + currentBridge.close().catch((err) => { + console.error('[AppRenderer] Error closing bridge:', err); + }); + } }; - }, [client]); + }, [client, hostInfo, hostCapabilities]); // Effect 2: Fetch HTML if not provided useEffect(() => { diff --git a/sdks/typescript/client/src/components/__tests__/AppRenderer.test.tsx b/sdks/typescript/client/src/components/__tests__/AppRenderer.test.tsx index c2138dde..5cd3d444 100644 --- a/sdks/typescript/client/src/components/__tests__/AppRenderer.test.tsx +++ b/sdks/typescript/client/src/components/__tests__/AppRenderer.test.tsx @@ -59,6 +59,7 @@ vi.mock('@modelcontextprotocol/ext-apps/app-bridge', () => { sendResourceListChanged: vi.fn(), sendPromptListChanged: vi.fn(), teardownResource: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), }; return mockBridgeInstance; }), @@ -667,4 +668,93 @@ describe('', () => { }); }); }); + + describe('hostInfo prop', () => { + it('should use default hostInfo when not provided', async () => { + const AppBridgeMock = vi.mocked( + (await import('@modelcontextprotocol/ext-apps/app-bridge')).AppBridge, + ); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + expect(AppBridgeMock).toHaveBeenCalledWith( + mockClient, + { name: 'MCP-UI Host', version: '1.0.0' }, + expect.any(Object), + ); + }); + + it('should use provided hostInfo', async () => { + const AppBridgeMock = vi.mocked( + (await import('@modelcontextprotocol/ext-apps/app-bridge')).AppBridge, + ); + + const customHostInfo = { name: 'goose', version: '2.3.4' }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + expect(AppBridgeMock).toHaveBeenCalledWith( + mockClient, + customHostInfo, + expect.any(Object), + ); + }); + }); + + describe('hostCapabilities prop', () => { + it('should derive hostCapabilities from serverCapabilities when not provided', async () => { + const AppBridgeMock = vi.mocked( + (await import('@modelcontextprotocol/ext-apps/app-bridge')).AppBridge, + ); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + expect(AppBridgeMock).toHaveBeenCalledWith( + mockClient, + expect.any(Object), + { + openLinks: {}, + serverTools: {}, + serverResources: {}, + }, + ); + }); + + it('should use provided hostCapabilities', async () => { + const AppBridgeMock = vi.mocked( + (await import('@modelcontextprotocol/ext-apps/app-bridge')).AppBridge, + ); + + const customCapabilities = { + openLinks: {}, + serverTools: { listChanged: true }, + serverResources: { listChanged: true }, + logging: {}, + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + expect(AppBridgeMock).toHaveBeenCalledWith( + mockClient, + expect.any(Object), + customCapabilities, + ); + }); + }); }); diff --git a/sdks/typescript/client/src/index.ts b/sdks/typescript/client/src/index.ts index 11b33d94..5e0f0cdd 100644 --- a/sdks/typescript/client/src/index.ts +++ b/sdks/typescript/client/src/index.ts @@ -28,9 +28,10 @@ export { AppBridge, PostMessageTransport, type McpUiHostContext, + type McpUiHostCapabilities, } from '@modelcontextprotocol/ext-apps/app-bridge'; -// Re-export JSONRPCRequest for typing onFallbackRequest handlers -export type { JSONRPCRequest } from '@modelcontextprotocol/sdk/types.js'; +// Re-export MCP SDK types commonly used with AppRenderer +export type { Implementation, JSONRPCRequest } from '@modelcontextprotocol/sdk/types.js'; export type { UIResourceMetadata } from './types';