Feat: MCP UI component (#414)

* init MCP action-step ui component

* Update apps/web/src/components/gen-ui/action-step.tsx

Co-authored-by: Brace Sproul <braceasproul@gmail.com>

* Update apps/web/src/components/thread/messages/ai.tsx

Co-authored-by: Brace Sproul <braceasproul@gmail.com>

* Update apps/web/src/components/gen-ui/action-step.tsx

Co-authored-by: Brace Sproul <braceasproul@gmail.com>

* Code Review

---------

Co-authored-by: Brace Sproul <braceasproul@gmail.com>
This commit is contained in:
Dylan Boudro 2025-07-21 20:44:00 -04:00 • committed by GitHub
parent 984fc68556
commit ced98ae029
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 119 additions and 2 deletions

View file

@ -12,6 +12,7 @@ import {
XCircle,
Loader2,
Globe,
Zap,
FileCode,
CloudDownload,
} from "lucide-react";
@ -101,6 +102,13 @@ type SearchActionProps = BaseActionProps &
errorCode?: number;
};
type McpActionProps = BaseActionProps & {
actionType: "mcp";
toolName: string;
args: Record<string, any>;
output?: string;
};
export type ActionItemProps =
| (BaseActionProps & { status: "loading" })
| ShellActionProps
@ -108,6 +116,7 @@ export type ActionItemProps =
| InstallDependenciesActionProps
| PlannerNotesActionProps
| GetURLContentActionProps
| McpActionProps
| SearchActionProps;
export type ActionStepProps = {
@ -200,6 +209,10 @@ function ActionItem(props: ActionItemProps) {
: "Failed to fetch URL content";
} else if (props.actionType === "search") {
return props.success ? "Search completed" : "Search failed";
} else if (props.actionType === "mcp") {
return props.success
? `${props.toolName} completed`
: `${props.toolName} failed`;
}
}
@ -221,6 +234,10 @@ function ActionItem(props: ActionItemProps) {
return !!props.diff;
} else if (props.actionType === "planner_notes") {
return !!(props.notes && props.notes.length > 0);
} else if (props.actionType === "mcp") {
const hasArgs = props.args && Object.keys(props.args).length > 0;
const hasOutput = !!props.output;
return hasArgs || hasOutput;
}
return false;
@ -267,6 +284,13 @@ function ActionItem(props: ActionItemProps) {
icon={<Globe className={cn(defaultIconStyling)} />}
/>
);
} else if (props.actionType === "mcp") {
return (
<ToolIconWithTooltip
toolNamePretty={props.toolName}
icon={<Zap className={cn(defaultIconStyling)} />}
/>
);
} else if (props.actionType === "search") {
return (
<ToolIconWithTooltip
@ -390,6 +414,14 @@ function ActionItem(props: ActionItemProps) {
</code>
</div>
);
} else if (props.actionType === "mcp") {
return (
<div className="flex items-center">
<span className="text-foreground/80 text-xs font-normal">
{props.toolName}
</span>
</div>
);
} else {
return (
<code className="text-foreground/80 flex items-center text-xs font-normal">
@ -423,6 +455,59 @@ function ActionItem(props: ActionItemProps) {
)}
</div>
);
} else if (props.actionType === "mcp") {
const hasArgs = props.args && Object.keys(props.args).length > 0;
const hasOutput = !!props.output;
if (!hasArgs && !hasOutput) {
return null;
}
return (
<div className="bg-muted overflow-x-auto p-2 dark:bg-gray-900">
{hasArgs && (
<div className="mb-3">
<div className="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">
Arguments
</div>
<div className="bg-muted-foreground/5 rounded border p-3">
{Object.entries(props.args).map(([key, value]) => (
<div
key={key}
className="mb-2 last:mb-0"
>
<div className="text-foreground mb-1 text-xs font-medium">
{key}
</div>
<div className="text-foreground/80 text-xs">
{typeof value === "object" ? (
<code className="bg-muted rounded px-2 py-1 font-mono text-xs break-all">
{JSON.stringify(value, null, 2)}
</code>
) : (
<code className="bg-muted rounded px-2 py-1 font-mono text-xs break-all">
{String(value)}
</code>
)}
</div>
</div>
))}
</div>
</div>
)}
{hasOutput && (
<>
<div className="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">
Output
</div>
<pre className="text-foreground/90 text-xs font-normal whitespace-pre-wrap">
{props.output}
</pre>
</>
)}
</div>
);
} else if (props.actionType === "get_url_content" && props.output) {
return (
<div className="bg-muted text-foreground/90 overflow-x-auto p-2 dark:bg-gray-900">

View file

@ -104,6 +104,20 @@ type ConversationHistorySummaryToolArgs = z.infer<
typeof conversationHistorySummaryTool.schema
>;
// Helper function to detect MCP tools by checking if tool name is NOT in known tools
function isMcpTool(toolName: string): boolean {
const knownToolNames = [
shellTool.name,
applyPatchTool.name,
installDependenciesTool.name,
plannerNotesTool.name,
getURLContentTool.name,
openPrTool.name,
diagnoseErrorTool.name,
];
return !knownToolNames.some((t) => t === toolName);
}
function CustomComponent({
message,
thread,
@ -247,6 +261,16 @@ export function mapToolMessageToActionStepProps(
output: getContentString(message.content),
reasoningText,
};
} else if (toolCall && isMcpTool(toolCall.name)) {
return {
actionType: "mcp",
status,
success,
toolName: toolCall.name,
args: toolCall.args as Record<string, any>,
output: getContentString(message.content),
reasoningText,
};
}
return {
status: "loading",
@ -314,7 +338,8 @@ export function AssistantMessage({
tc.name === searchTool.name ||
tc.name === installDependenciesTool.name ||
tc.name === plannerNotesTool.name ||
tc.name === getURLContentTool.name,
tc.name === getURLContentTool.name ||
isMcpTool(tc.name),
)
: [];
@ -613,7 +638,14 @@ export function AssistantMessage({
output: "",
} as ActionItemProps;
} else {
if (isShellTool) {
if (isMcpTool(toolCall.name)) {
return {
actionType: "mcp",
status: "generating",
toolName: toolCall.name,
args: toolCall.args as Record<string, any>,
} as ActionItemProps;
} else if (isShellTool) {
const args = toolCall.args as ShellToolArgs;
return {
actionType: "shell",