feat: add MCP tool call/result rendering to Chat UI

Port MCP event handling from Playground to the standalone Chat UI:

- ChatPage: add mcpEvents state, handleMCPEvent callback, fetch
  MCPServer[] objects, pass onMCPEvent + mcpServers to
  makeOpenAIChatCompletionRequest and streamToModel
- ChatMessages: accept mcpEvents prop, render MCPEventsDisplay
  (list tools, tool calls with request/response) inside the last
  assistant bubble, reusing the existing Playground component
- Reset mcpEvents on each new send

Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-03-07 06:12:18 +00:00
parent bc9a48d407
commit efe079de23
2 changed files with 55 additions and 5 deletions

View file

@ -8,6 +8,7 @@ import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import ReasoningContent from "../playground/chat_ui/ReasoningContent";
import MCPEventsDisplay, { MCPEvent } from "../playground/chat_ui/MCPEventsDisplay";
import { ChatMessage } from "./types";
const { Panel } = Collapse;
@ -237,6 +238,7 @@ interface AssistantBubbleProps {
isLastMessage: boolean;
isStreaming: boolean;
isTypingIndicator: boolean;
mcpEvents?: MCPEvent[];
}
function AssistantBubble({
@ -244,6 +246,7 @@ function AssistantBubble({
isLastMessage,
isStreaming,
isTypingIndicator,
mcpEvents,
}: AssistantBubbleProps) {
// Ref to control ReasoningContent collapse on streaming end.
// ReasoningContent manages its own expanded state; we use a key to
@ -299,6 +302,13 @@ function AssistantBubble({
)
)}
{/* MCP tool events (list tools, tool calls, results) */}
{mcpEvents && mcpEvents.length > 0 && (
<div style={{ marginBottom: 8 }}>
<MCPEventsDisplay events={mcpEvents} />
</div>
)}
<div
style={{
fontSize: 14,
@ -531,9 +541,10 @@ interface Props {
messages: ChatMessage[];
isStreaming: boolean;
onEditMessage?: (messageId: string, newContent: string) => void;
mcpEvents?: MCPEvent[];
}
const ChatMessages: React.FC<Props> = ({ messages, isStreaming, onEditMessage }) => {
const ChatMessages: React.FC<Props> = ({ messages, isStreaming, onEditMessage, mcpEvents }) => {
// Scrolling is managed by ChatPage.tsx (scroll lock during streaming,
// scroll-to-bottom on new message). No auto-scroll here.
@ -566,6 +577,7 @@ const ChatMessages: React.FC<Props> = ({ messages, isStreaming, onEditMessage })
isLastMessage={isLastMessage}
isStreaming={isStreaming}
isTypingIndicator={isLastMessage && isTypingIndicator}
mcpEvents={isLastMessage ? mcpEvents : undefined}
/>
);
})}

View file

@ -26,7 +26,9 @@ import MCPConnectPicker from "./MCPConnectPicker";
import MCPAppsPanel from "./MCPAppsPanel";
import { fetchAvailableModels } from "../playground/llm_calls/fetch_models";
import { makeOpenAIChatCompletionRequest } from "../playground/llm_calls/chat_completion";
import { serverRootPath, getProxyBaseUrl } from "@/components/networking";
import { MCPEvent } from "../playground/chat_ui/MCPEventsDisplay";
import { serverRootPath, getProxyBaseUrl, fetchMCPServers } from "@/components/networking";
import { MCPServer } from "../mcp_tools/types";
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
interface ChatPageProps {
@ -100,6 +102,8 @@ async function streamToModel(
signal: AbortSignal,
onChunk: (model: string, chunk: string) => void,
onDone: (model: string) => void,
mcpServerObjects?: MCPServer[],
onMCPEvent?: (event: MCPEvent) => void,
): Promise<void> {
try {
await makeOpenAIChatCompletionRequest(
@ -112,9 +116,12 @@ async function streamToModel(
undefined, // onReasoningContent
undefined, undefined, undefined, undefined, undefined, undefined, // positions 8-13
mcpServers.length > 0 ? mcpServers : undefined, // position 14: selectedMCPServers
undefined, undefined, undefined, undefined, undefined, undefined, // positions 15-20
mcpServerObjects, // position 21: mcpServers (MCPServer[])
undefined, // position 22: mcpServerToolRestrictions
onMCPEvent, // position 23: onMCPEvent
);
} catch (err: unknown) {
// Surface real errors in the response card; ignore user-triggered aborts
if (!(err instanceof Error && err.name === "AbortError")) {
const msg = err instanceof Error ? err.message : String(err);
onChunk(model, `\n\n_Error: ${msg}_`);
@ -137,6 +144,8 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
const [modelSearchText, setModelSearchText] = useState("");
const [selectedMCPServers, setSelectedMCPServers] = useState<string[]>([]);
const [mcpServerObjects, setMcpServerObjects] = useState<MCPServer[]>([]);
const [mcpEvents, setMcpEvents] = useState<MCPEvent[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const [inputText, setInputText] = useState("");
const [mcpPopoverOpen, setMcpPopoverOpen] = useState(false);
@ -201,6 +210,27 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
.finally(() => setIsLoadingModels(false));
}, [accessToken]);
// Load MCP server objects for tool resolution
useEffect(() => {
if (!accessToken) return;
fetchMCPServers(accessToken)
.then((data) => {
const list: MCPServer[] = Array.isArray(data) ? data : (data?.data ?? []);
setMcpServerObjects(list);
})
.catch(() => setMcpServerObjects([]));
}, [accessToken]);
const handleMCPEvent = useCallback((event: MCPEvent) => {
setMcpEvents((prev) => {
const isDuplicate = event.item_id
? prev.some((e) => e.item_id === event.item_id && e.type === event.type)
: false;
if (isDuplicate) return prev;
return [...prev, event];
});
}, []);
useEffect(() => {
if (staleId) router.replace(getChatUrl());
}, [staleId, router]);
@ -254,6 +284,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
let accumulatedContent = "";
let accumulatedReasoning = "";
setMcpEvents([]);
try {
await makeOpenAIChatCompletionRequest(
@ -272,6 +303,10 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
},
undefined, undefined, undefined, undefined, undefined, undefined,
selectedMCPServers.length > 0 ? selectedMCPServers : undefined,
undefined, undefined, undefined, undefined, undefined, undefined,
mcpServerObjects.length > 0 ? mcpServerObjects : undefined,
undefined,
handleMCPEvent,
);
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") {
@ -289,7 +324,8 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
}
},
[activeConversationId, activeConversation, selectedModels, selectedMCPServers, accessToken,
createConversation, appendMessage, updateLastAssistantMessage, router, isStreaming],
createConversation, appendMessage, updateLastAssistantMessage, router, isStreaming,
mcpServerObjects, handleMCPEvent],
);
const handleSendComparison = useCallback(
@ -297,6 +333,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
const trimmed = text.trim();
if (!trimmed || selectedModels.length === 0 || isAnyStreaming) return;
setInputText("");
setMcpEvents([]);
// Append a new exchange with empty responses
const newExchange: ComparisonExchange = { userMessage: trimmed, responses: {} };
@ -326,7 +363,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
accessToken,
selectedMCPServers,
controllers[model].signal,
(m, chunk) => setComparisonExchanges((prev) => {
(m, chunk: string) => setComparisonExchanges((prev) => {
const updated = [...prev];
const ex = { ...updated[newExchangeIdx] };
ex.responses = { ...ex.responses, [m]: (ex.responses[m] ?? "") + chunk };
@ -1111,6 +1148,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
messages={activeConversation!.messages}
isStreaming={isStreaming}
onEditMessage={handleEditAndResend}
mcpEvents={mcpEvents}
/>
)}
</div>