Skip to content

Commit fece80e

Browse files
ochafikclaude
andcommitted
feat(client): make Client optional with onReadResource alternative
Addresses PR feedback: - Changed `client: Client | null` to `client?: Client` for cleaner API - Added support for using `onReadResource` + `toolResourceUri` to fetch HTML without requiring the full MCP Client instance - This enables decoupled architectures where the MCP client lives in a different context (e.g., server-side) Usage without client: ```tsx <AppRenderer toolName="my-tool" toolResourceUri="ui://my-server/my-tool" onReadResource={async ({ uri }) => myProxy.readResource({ uri })} onCallTool={async (params) => myProxy.callTool(params)} /> ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent b2673b9 commit fece80e

File tree

2 files changed

+97
-20
lines changed

2 files changed

+97
-20
lines changed

sdks/typescript/client/src/components/AppRenderer.tsx

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919

2020
import {
2121
AppBridge,
22+
RESOURCE_MIME_TYPE,
2223
type McpUiMessageRequest,
2324
type McpUiMessageResult,
2425
type McpUiOpenLinkRequest,
@@ -56,8 +57,8 @@ export interface AppRendererHandle {
5657
* Props for the AppRenderer component.
5758
*/
5859
export interface AppRendererProps {
59-
/** MCP client connected to the server providing the tool. Pass `null` to disable automatic MCP forwarding and use custom handlers instead. */
60-
client: Client | null;
60+
/** MCP client connected to the server providing the tool. Omit to disable automatic MCP forwarding and use custom handlers instead. */
61+
client?: Client;
6162

6263
/** Name of the MCP tool to render UI for */
6364
toolName: string;
@@ -211,19 +212,25 @@ export interface AppRendererProps {
211212
* <AppRenderer ref={appRef} ... />
212213
* ```
213214
*
214-
* @example With custom MCP request handlers
215+
* @example With custom MCP request handlers (no client)
215216
* ```tsx
216217
* <AppRenderer
217-
* client={null} // Disable automatic forwarding
218-
* oncalltool={async (params) => {
218+
* // client omitted - use toolResourceUri + onReadResource to fetch HTML
219+
* sandbox={{ url: sandboxUrl }}
220+
* toolName="my-tool"
221+
* toolResourceUri="ui://my-server/my-tool"
222+
* onReadResource={async ({ uri }) => {
223+
* // Proxy to your MCP client (e.g., in a different context)
224+
* return myMcpProxy.readResource({ uri });
225+
* }}
226+
* onCallTool={async (params) => {
219227
* // Custom tool call handling with caching/filtering
220228
* return myCustomToolCall(params);
221229
* }}
222-
* onlistresources={async () => {
230+
* onListResources={async () => {
223231
* // Aggregate resources from multiple servers
224232
* return { resources: [...server1Resources, ...server2Resources] };
225233
* }}
226-
* ...
227234
* />
228235
* ```
229236
*/
@@ -317,7 +324,7 @@ export const AppRenderer = forwardRef<AppRendererHandle, AppRendererProps>((prop
317324
try {
318325
const serverCapabilities = client?.getServerCapabilities();
319326
const bridge = new AppBridge(
320-
client,
327+
client ?? null,
321328
{
322329
name: 'MCP-UI Host',
323330
version: '1.0.0',
@@ -434,9 +441,16 @@ export const AppRenderer = forwardRef<AppRendererHandle, AppRendererProps>((prop
434441
return;
435442
}
436443

437-
// If no client and no HTML provided, we can't fetch
438-
if (!client) {
439-
setError(new Error("Either 'html' prop or 'client' must be provided to fetch UI resource"));
444+
// Determine if we can fetch HTML
445+
const canFetchWithClient = !!client;
446+
const canFetchWithCallback = !!toolResourceUri && !!onReadResourceRef.current;
447+
448+
if (!canFetchWithClient && !canFetchWithCallback) {
449+
setError(
450+
new Error(
451+
"Either 'html' prop, 'client', or ('toolResourceUri' + 'onReadResource') must be provided to fetch UI resource",
452+
),
453+
);
440454
return;
441455
}
442456

@@ -449,7 +463,7 @@ export const AppRenderer = forwardRef<AppRendererHandle, AppRendererProps>((prop
449463
if (toolResourceUri) {
450464
uri = toolResourceUri;
451465
console.log(`[AppRenderer] Using provided resource URI: ${uri}`);
452-
} else {
466+
} else if (client) {
453467
console.log(`[AppRenderer] Fetching resource URI for tool: ${toolName}`);
454468
const info = await getToolUiResourceUri(client, toolName);
455469
if (!info) {
@@ -459,13 +473,41 @@ export const AppRenderer = forwardRef<AppRendererHandle, AppRendererProps>((prop
459473
}
460474
uri = info.uri;
461475
console.log(`[AppRenderer] Got resource URI: ${uri}`);
476+
} else {
477+
throw new Error('Cannot determine resource URI without client or toolResourceUri');
462478
}
463479

464480
if (!mounted) return;
465481

466-
// Read HTML content
482+
// Read HTML content - use client if available, otherwise use onReadResource callback
467483
console.log(`[AppRenderer] Reading resource HTML from: ${uri}`);
468-
const htmlContent = await readToolUiResourceHtml(client, { uri });
484+
let htmlContent: string;
485+
486+
if (client) {
487+
htmlContent = await readToolUiResourceHtml(client, { uri });
488+
} else if (onReadResourceRef.current) {
489+
// Use the onReadResource callback to fetch the HTML
490+
const result = await onReadResourceRef.current({ uri }, {} as RequestHandlerExtra);
491+
if (!result.contents || result.contents.length !== 1) {
492+
throw new Error('Unsupported UI resource content length: ' + result.contents?.length);
493+
}
494+
const content = result.contents[0];
495+
const isHtml = (t?: string) => t === RESOURCE_MIME_TYPE;
496+
497+
if ('text' in content && typeof content.text === 'string' && isHtml(content.mimeType)) {
498+
htmlContent = content.text;
499+
} else if (
500+
'blob' in content &&
501+
typeof content.blob === 'string' &&
502+
isHtml(content.mimeType)
503+
) {
504+
htmlContent = atob(content.blob);
505+
} else {
506+
throw new Error('Unsupported UI resource content format: ' + JSON.stringify(content));
507+
}
508+
} else {
509+
throw new Error('No way to read resource HTML');
510+
}
469511

470512
if (!mounted) return;
471513

sdks/typescript/client/src/components/__tests__/AppRenderer.test.tsx

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ let mockBridgeInstance: any = null;
3232
// Mock AppBridge constructor
3333
vi.mock('@modelcontextprotocol/ext-apps/app-bridge', () => {
3434
return {
35-
AppBridge: vi.fn().mockImplementation(function() {
35+
AppBridge: vi.fn().mockImplementation(function () {
3636
mockBridgeInstance = {
3737
onmessage: null,
3838
onopenlink: null,
@@ -52,6 +52,7 @@ vi.mock('@modelcontextprotocol/ext-apps/app-bridge', () => {
5252
};
5353
return mockBridgeInstance;
5454
}),
55+
RESOURCE_MIME_TYPE: 'text/html',
5556
};
5657
});
5758

@@ -488,10 +489,10 @@ describe('<AppRenderer />', () => {
488489
});
489490
});
490491

491-
describe('null client', () => {
492-
it('should work with null client when html is provided', async () => {
492+
describe('no client', () => {
493+
it('should work without client when html is provided', async () => {
493494
const props: AppRendererProps = {
494-
client: null,
495+
// client omitted - using html prop instead
495496
toolName: 'test-tool',
496497
sandbox: { url: new URL('http://localhost:8081/sandbox.html') },
497498
html: '<html><body>Static HTML</body></html>',
@@ -508,9 +509,9 @@ describe('<AppRenderer />', () => {
508509
});
509510
});
510511

511-
it('should show error with null client and no html', async () => {
512+
it('should show error without client and no html', async () => {
512513
const props: AppRendererProps = {
513-
client: null,
514+
// client omitted, no html provided
514515
toolName: 'test-tool',
515516
sandbox: { url: new URL('http://localhost:8081/sandbox.html') },
516517
};
@@ -521,5 +522,39 @@ describe('<AppRenderer />', () => {
521522
expect(screen.getByText(/Error:/)).toBeInTheDocument();
522523
});
523524
});
525+
526+
it('should work with onReadResource and toolResourceUri instead of client', async () => {
527+
const mockReadResource = vi.fn().mockResolvedValue({
528+
contents: [
529+
{
530+
uri: 'ui://test/tool',
531+
mimeType: 'text/html',
532+
text: '<html><body>Custom fetched HTML</body></html>',
533+
},
534+
],
535+
});
536+
537+
const props: AppRendererProps = {
538+
// client omitted - using onReadResource + toolResourceUri instead
539+
toolName: 'test-tool',
540+
sandbox: { url: new URL('http://localhost:8081/sandbox.html') },
541+
toolResourceUri: 'ui://test/tool',
542+
onReadResource: mockReadResource,
543+
};
544+
545+
render(<AppRenderer {...props} />);
546+
547+
await waitFor(() => {
548+
expect(mockReadResource).toHaveBeenCalledWith(
549+
{ uri: 'ui://test/tool' },
550+
expect.anything(),
551+
);
552+
expect(screen.getByTestId('app-frame')).toBeInTheDocument();
553+
expect(screen.getByTestId('app-frame')).toHaveAttribute(
554+
'data-html',
555+
'<html><body>Custom fetched HTML</body></html>',
556+
);
557+
});
558+
});
524559
});
525560
});

0 commit comments

Comments
 (0)