integrate MCP UI (#2948)

Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
Andrew Harvard
2025-08-01 08:09:06 -04:00
committed by GitHub
parent b4ab774329
commit 900698777b
9 changed files with 256 additions and 62 deletions
@@ -0,0 +1,73 @@
import { UIResourceRenderer, UIActionResult } from '@mcp-ui/client';
import { ResourceContent } from '../types/message';
import { useState, useCallback } from 'react';
// Extend UIActionResult to include size-change type
type ExtendedUIActionResult =
| UIActionResult
| {
type: 'size-change';
payload: {
height: string;
};
};
interface MCPUIResourceRendererProps {
content: ResourceContent;
}
export default function MCPUIResourceRenderer({ content }: MCPUIResourceRendererProps) {
console.log('MCPUIResourceRenderer', content);
const [iframeHeight, setIframeHeight] = useState('200px');
const handleUIAction = useCallback(async (result: ExtendedUIActionResult) => {
console.log('Handle action from MCP UI Action:', result);
// Handle UI actions here
switch (result.type) {
case 'intent':
// TODO: Implement intent handling
break;
case 'link':
// TODO: Implement link handling
break;
case 'notify':
// TODO: Implement notification handling
break;
case 'prompt':
// TODO: Implement prompt handling
break;
case 'tool':
// TODO: Implement tool handling
break;
// Currently, `size-change` is non-standard
case 'size-change': {
// We expect the height to be a string with a unit
console.log('Setting iframe height to:', result.payload.height);
setIframeHeight(result.payload.height);
break;
}
}
return { status: 'handled' };
}, []);
return (
<div className="mt-3 p-4 border border-borderSubtle rounded-lg bg-background-muted">
<div className="overflow-hidden rounded-sm">
<UIResourceRenderer
resource={content.resource}
onUIAction={handleUIAction}
htmlProps={{
style: { minHeight: iframeHeight },
}}
/>
</div>
</div>
);
}
@@ -6,8 +6,9 @@ import { Content, ToolRequestMessageContent, ToolResponseMessageContent } from '
import { cn, snakeToTitleCase } from '../utils';
import Dot, { LoadingStatus } from './ui/Dot';
import { NotificationEvent } from '../hooks/useMessageStream';
import { ChevronRight, LoaderCircle } from 'lucide-react';
import { ChevronRight, FlaskConical, LoaderCircle } from 'lucide-react';
import { TooltipWrapper } from './settings/providers/subcomponents/buttons/TooltipWrapper';
import MCPUIResourceRenderer from './MCPUIResourceRenderer';
interface ToolCallWithResponseProps {
isCancelledMessage: boolean;
@@ -30,15 +31,47 @@ export default function ToolCallWithResponse({
}
return (
<div
className={cn(
'w-full text-sm rounded-lg overflow-hidden border-borderSubtle border bg-background-muted'
)}
>
<ToolCallView
{...{ isCancelledMessage, toolCall, toolResponse, notifications, isStreamingMessage }}
/>
</div>
<>
<div
className={cn(
'w-full text-sm rounded-lg overflow-hidden border-borderSubtle border bg-background-muted'
)}
>
<ToolCallView
{...{
isCancelledMessage,
toolCall,
toolResponse,
notifications,
isStreamingMessage,
}}
/>
</div>
{/* MCP UI — Inline */}
{toolResponse?.toolResult?.value &&
toolResponse.toolResult.value.map((content, index, results) => {
if (content.type === 'resource' && content.resource.uri?.startsWith('ui://')) {
if (index === results.length - 1) {
return (
<>
<MCPUIResourceRenderer key={`${content.type}-${index}`} content={content} />
{/* Append a disclaimer if this is the last item in the array */}
<div className="mt-3 p-4 py-3 border border-borderSubtle rounded-lg bg-background-muted flex items-center">
<FlaskConical className="mr-2" size={20} />
<div className="text-sm font-medium mono">
MCP UI is experimental and may change at any time.
</div>
</div>
</>
);
} else {
return <MCPUIResourceRenderer key={`${content.type}-${index}`} content={content} />;
}
} else {
return null;
}
})}
</>
);
}
@@ -536,6 +569,7 @@ function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) {
}}
/>
)}
{result.type === 'resource' && <pre>{JSON.stringify(result, null, 2)}</pre>}
</div>
</ToolCallExpandable>
);
@@ -60,7 +60,7 @@ export function convertApiMessageToFrontendMessage(
sendToLLM: sendToLLM ?? true,
id: generateId(),
role: apiMessage.role as Role,
created: apiMessage.created ?? 0,
created: apiMessage.created ?? Math.floor(Date.now() / 1000),
content: apiMessage.content
.map((apiContent) => mapApiContentToFrontendMessageContent(apiContent))
.filter((content): content is FrontendMessageContent => content !== null),
+2 -2
View File
@@ -1727,8 +1727,8 @@ app.whenReady().then(async () => {
"connect-src 'self' http://127.0.0.1:* https://api.github.com https://github.com https://objects.githubusercontent.com" +
// Don't allow any plugins
"object-src 'none';" +
// Don't allow any frames
"frame-src 'none';" +
// Allow all frames (iframes)
"frame-src 'self' https: http:;" +
// Font sources - allow self, data URLs, and external fonts
"font-src 'self' data: https:;" +
// Media sources - allow microphone
+12 -1
View File
@@ -18,7 +18,18 @@ export interface ImageContent {
annotations?: Record<string, unknown>;
}
export type Content = TextContent | ImageContent;
export interface ResourceContent {
type: 'resource';
resource: {
uri: string;
mimeType: string;
text?: string;
blob?: string;
};
annotations?: Record<string, unknown>;
}
export type Content = TextContent | ImageContent | ResourceContent;
export interface ToolCall {
name: string;