Add MCP support (#1028)
This commit is contained in:
@@ -2,15 +2,25 @@ import { ContextFilesPicker } from "./ContextFilesPicker";
|
||||
import { ModelPicker } from "./ModelPicker";
|
||||
import { ProModeSelector } from "./ProModeSelector";
|
||||
import { ChatModeSelector } from "./ChatModeSelector";
|
||||
import { McpToolsPicker } from "@/components/McpToolsPicker";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
|
||||
export function ChatInputControls({
|
||||
showContextFilesPicker = false,
|
||||
}: {
|
||||
showContextFilesPicker?: boolean;
|
||||
}) {
|
||||
const { settings } = useSettings();
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<ChatModeSelector />
|
||||
{settings?.selectedChatMode === "agent" && (
|
||||
<>
|
||||
<div className="w-1.5"></div>
|
||||
<McpToolsPicker />
|
||||
</>
|
||||
)}
|
||||
<div className="w-1.5"></div>
|
||||
<ModelPicker />
|
||||
<div className="w-1.5"></div>
|
||||
|
||||
@@ -29,6 +29,8 @@ export function ChatModeSelector() {
|
||||
return "Build";
|
||||
case "ask":
|
||||
return "Ask";
|
||||
case "agent":
|
||||
return "Agent";
|
||||
default:
|
||||
return "Build";
|
||||
}
|
||||
@@ -70,6 +72,14 @@ export function ChatModeSelector() {
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="agent">
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium">Agent (experimental)</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Agent can use tools (MCP) and generate code
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
200
src/components/McpConsentToast.tsx
Normal file
200
src/components/McpConsentToast.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { X, ShieldAlert } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface McpConsentToastProps {
|
||||
toastId: string | number;
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
toolDescription?: string | null;
|
||||
inputPreview?: string | null;
|
||||
onDecision: (decision: "accept-once" | "accept-always" | "decline") => void;
|
||||
}
|
||||
|
||||
export function McpConsentToast({
|
||||
toastId,
|
||||
serverName,
|
||||
toolName,
|
||||
toolDescription,
|
||||
inputPreview,
|
||||
onDecision,
|
||||
}: McpConsentToastProps) {
|
||||
const handleClose = () => toast.dismiss(toastId);
|
||||
|
||||
const handle = (d: "accept-once" | "accept-always" | "decline") => {
|
||||
onDecision(d);
|
||||
toast.dismiss(toastId);
|
||||
};
|
||||
|
||||
// Collapsible tool description state
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const [collapsedMaxHeight, setCollapsedMaxHeight] = React.useState<number>(0);
|
||||
const [hasOverflow, setHasOverflow] = React.useState(false);
|
||||
const descRef = React.useRef<HTMLParagraphElement | null>(null);
|
||||
|
||||
// Collapsible input preview state
|
||||
const [isInputExpanded, setIsInputExpanded] = React.useState(false);
|
||||
const [inputCollapsedMaxHeight, setInputCollapsedMaxHeight] =
|
||||
React.useState<number>(0);
|
||||
const [inputHasOverflow, setInputHasOverflow] = React.useState(false);
|
||||
const inputRef = React.useRef<HTMLPreElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!toolDescription) {
|
||||
setHasOverflow(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const element = descRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const compute = () => {
|
||||
const computedStyle = window.getComputedStyle(element);
|
||||
const lineHeight = parseFloat(computedStyle.lineHeight || "20");
|
||||
const maxLines = 4; // show first few lines by default
|
||||
const maxHeightPx = Math.max(0, Math.round(lineHeight * maxLines));
|
||||
setCollapsedMaxHeight(maxHeightPx);
|
||||
// Overflow if full height exceeds our collapsed height
|
||||
setHasOverflow(element.scrollHeight > maxHeightPx + 1);
|
||||
};
|
||||
|
||||
// Compute initially and on resize
|
||||
compute();
|
||||
const onResize = () => compute();
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [toolDescription]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!inputPreview) {
|
||||
setInputHasOverflow(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const element = inputRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const compute = () => {
|
||||
const computedStyle = window.getComputedStyle(element);
|
||||
const lineHeight = parseFloat(computedStyle.lineHeight || "16");
|
||||
const maxLines = 6; // show first few lines by default
|
||||
const maxHeightPx = Math.max(0, Math.round(lineHeight * maxLines));
|
||||
setInputCollapsedMaxHeight(maxHeightPx);
|
||||
setInputHasOverflow(element.scrollHeight > maxHeightPx + 1);
|
||||
};
|
||||
|
||||
compute();
|
||||
const onResize = () => compute();
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [inputPreview]);
|
||||
|
||||
return (
|
||||
<div className="relative bg-amber-50/95 dark:bg-slate-800/95 backdrop-blur-sm border border-amber-200 dark:border-slate-600 rounded-xl shadow-lg min-w-[420px] max-w-[560px] overflow-hidden">
|
||||
<div className="p-5">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-6 h-6 bg-gradient-to-br from-amber-500 to-amber-600 dark:from-amber-400 dark:to-amber-500 rounded-full flex items-center justify-center shadow-sm">
|
||||
<ShieldAlert className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="ml-3 text-base font-semibold text-amber-900 dark:text-amber-100">
|
||||
Tool wants to run
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="ml-auto flex-shrink-0 p-1.5 text-amber-500 dark:text-slate-400 hover:text-amber-700 dark:hover:text-slate-200 transition-colors duration-200 rounded-md hover:bg-amber-100/50 dark:hover:bg-slate-700/50"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>
|
||||
<span className="font-semibold">{toolName}</span> from
|
||||
<span className="font-semibold"> {serverName}</span> requests
|
||||
your consent.
|
||||
</p>
|
||||
{toolDescription && (
|
||||
<div>
|
||||
<p
|
||||
ref={descRef}
|
||||
className="text-muted-foreground whitespace-pre-wrap"
|
||||
style={{
|
||||
maxHeight: isExpanded ? "40vh" : collapsedMaxHeight,
|
||||
overflow: isExpanded ? "auto" : "hidden",
|
||||
}}
|
||||
>
|
||||
{toolDescription}
|
||||
</p>
|
||||
{hasOverflow && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 text-xs font-medium text-amber-700 hover:underline dark:text-amber-300"
|
||||
onClick={() => setIsExpanded((v) => !v)}
|
||||
>
|
||||
{isExpanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{inputPreview && (
|
||||
<div>
|
||||
<pre
|
||||
ref={inputRef}
|
||||
className="bg-amber-100/60 dark:bg-slate-700/60 p-2 rounded text-xs whitespace-pre-wrap"
|
||||
style={{
|
||||
maxHeight: isInputExpanded
|
||||
? "40vh"
|
||||
: inputCollapsedMaxHeight,
|
||||
overflow: isInputExpanded ? "auto" : "hidden",
|
||||
}}
|
||||
>
|
||||
{inputPreview}
|
||||
</pre>
|
||||
{inputHasOverflow && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 text-xs font-medium text-amber-700 hover:underline dark:text-amber-300"
|
||||
onClick={() => setIsInputExpanded((v) => !v)}
|
||||
>
|
||||
{isInputExpanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
<Button
|
||||
onClick={() => handle("accept-once")}
|
||||
size="sm"
|
||||
className="px-6"
|
||||
>
|
||||
Allow once
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handle("accept-always")}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="px-6"
|
||||
>
|
||||
Always allow
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handle("decline")}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="px-6"
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
src/components/McpToolsPicker.tsx
Normal file
130
src/components/McpToolsPicker.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Wrench } from "lucide-react";
|
||||
import { useMcp } from "@/hooks/useMcp";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export function McpToolsPicker() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { servers, toolsByServer, consentsMap, setToolConsent } = useMcp();
|
||||
|
||||
// Removed activation toggling – consent governs execution time behavior
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="has-[>svg]:px-2"
|
||||
size="sm"
|
||||
data-testid="mcp-tools-button"
|
||||
>
|
||||
<Wrench className="size-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Tools</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<PopoverContent
|
||||
className="w-120 max-h-[80vh] overflow-y-auto"
|
||||
align="start"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-medium">Tools (MCP)</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enable tools from your configured MCP servers.
|
||||
</p>
|
||||
</div>
|
||||
{servers.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||
No MCP servers configured. Configure them in Settings → Tools
|
||||
(MCP).
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{servers.map((s) => (
|
||||
<div key={s.id} className="border rounded-md p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium text-sm truncate">{s.name}</div>
|
||||
{s.enabled ? (
|
||||
<Badge variant="secondary">Enabled</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Disabled</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{(toolsByServer[s.id] || []).map((t) => (
|
||||
<div
|
||||
key={t.name}
|
||||
className="flex items-center justify-between gap-2 rounded border p-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-sm truncate">
|
||||
{t.name}
|
||||
</div>
|
||||
{t.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{t.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
value={
|
||||
consentsMap[`${s.id}:${t.name}`] ||
|
||||
t.consent ||
|
||||
"ask"
|
||||
}
|
||||
onValueChange={(v) =>
|
||||
setToolConsent(s.id, t.name, v as any)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ask">Ask</SelectItem>
|
||||
<SelectItem value="always">Always allow</SelectItem>
|
||||
<SelectItem value="denied">Deny</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
{(toolsByServer[s.id] || []).length === 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
No tools discovered.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const SETTINGS_SECTIONS = [
|
||||
{ id: "provider-settings", label: "Model Providers" },
|
||||
{ id: "telemetry", label: "Telemetry" },
|
||||
{ id: "integrations", label: "Integrations" },
|
||||
{ id: "tools-mcp", label: "Tools (MCP)" },
|
||||
{ id: "experiments", label: "Experiments" },
|
||||
{ id: "danger-zone", label: "Danger Zone" },
|
||||
];
|
||||
|
||||
@@ -17,6 +17,8 @@ import { CustomTagState } from "./stateTypes";
|
||||
import { DyadOutput } from "./DyadOutput";
|
||||
import { DyadProblemSummary } from "./DyadProblemSummary";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { DyadMcpToolCall } from "./DyadMcpToolCall";
|
||||
import { DyadMcpToolResult } from "./DyadMcpToolResult";
|
||||
|
||||
interface DyadMarkdownParserProps {
|
||||
content: string;
|
||||
@@ -124,6 +126,8 @@ function preprocessUnclosedTags(content: string): {
|
||||
"dyad-codebase-context",
|
||||
"think",
|
||||
"dyad-command",
|
||||
"dyad-mcp-tool-call",
|
||||
"dyad-mcp-tool-result",
|
||||
];
|
||||
|
||||
let processedContent = content;
|
||||
@@ -191,6 +195,8 @@ function parseCustomTags(content: string): ContentPiece[] {
|
||||
"dyad-codebase-context",
|
||||
"think",
|
||||
"dyad-command",
|
||||
"dyad-mcp-tool-call",
|
||||
"dyad-mcp-tool-result",
|
||||
];
|
||||
|
||||
const tagPattern = new RegExp(
|
||||
@@ -399,6 +405,34 @@ function renderCustomTag(
|
||||
</DyadCodebaseContext>
|
||||
);
|
||||
|
||||
case "dyad-mcp-tool-call":
|
||||
return (
|
||||
<DyadMcpToolCall
|
||||
node={{
|
||||
properties: {
|
||||
serverName: attributes.server || "",
|
||||
toolName: attributes.tool || "",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DyadMcpToolCall>
|
||||
);
|
||||
|
||||
case "dyad-mcp-tool-result":
|
||||
return (
|
||||
<DyadMcpToolResult
|
||||
node={{
|
||||
properties: {
|
||||
serverName: attributes.server || "",
|
||||
toolName: attributes.tool || "",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DyadMcpToolResult>
|
||||
);
|
||||
|
||||
case "dyad-output":
|
||||
return (
|
||||
<DyadOutput
|
||||
|
||||
73
src/components/chat/DyadMcpToolCall.tsx
Normal file
73
src/components/chat/DyadMcpToolCall.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Wrench, ChevronsUpDown, ChevronsDownUp } from "lucide-react";
|
||||
import { CodeHighlight } from "./CodeHighlight";
|
||||
|
||||
interface DyadMcpToolCallProps {
|
||||
node?: any;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DyadMcpToolCall: React.FC<DyadMcpToolCallProps> = ({
|
||||
node,
|
||||
children,
|
||||
}) => {
|
||||
const serverName: string = node?.properties?.serverName || "";
|
||||
const toolName: string = node?.properties?.toolName || "";
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const raw = typeof children === "string" ? children : String(children ?? "");
|
||||
|
||||
const prettyJson = useMemo(() => {
|
||||
if (!expanded) return "";
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch (e) {
|
||||
console.error("Error parsing JSON for dyad-mcp-tool-call", e);
|
||||
return raw;
|
||||
}
|
||||
}, [expanded, raw]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{/* Top-left label badge */}
|
||||
<div
|
||||
className="absolute top-3 left-2 flex items-center gap-1 px-2 py-0.5 rounded text-xs font-semibold text-blue-600 bg-white dark:bg-zinc-900"
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<Wrench size={16} className="text-blue-600" />
|
||||
<span>Tool Call</span>
|
||||
</div>
|
||||
|
||||
{/* Right chevron */}
|
||||
<div className="absolute top-2 right-2 p-1 text-gray-500">
|
||||
{expanded ? <ChevronsDownUp size={18} /> : <ChevronsUpDown size={18} />}
|
||||
</div>
|
||||
|
||||
{/* Header content */}
|
||||
<div className="flex items-start gap-2 pl-24 pr-8 py-1">
|
||||
{serverName ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 dark:bg-zinc-800 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-zinc-700">
|
||||
{serverName}
|
||||
</span>
|
||||
) : null}
|
||||
{toolName ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-gray-200 border border-border">
|
||||
{toolName}
|
||||
</span>
|
||||
) : null}
|
||||
{/* Intentionally no preview or content when collapsed */}
|
||||
</div>
|
||||
|
||||
{/* JSON content */}
|
||||
{expanded ? (
|
||||
<div className="mt-2 pr-4 pb-2">
|
||||
<CodeHighlight className="language-json">{prettyJson}</CodeHighlight>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
73
src/components/chat/DyadMcpToolResult.tsx
Normal file
73
src/components/chat/DyadMcpToolResult.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { CheckCircle, ChevronsUpDown, ChevronsDownUp } from "lucide-react";
|
||||
import { CodeHighlight } from "./CodeHighlight";
|
||||
|
||||
interface DyadMcpToolResultProps {
|
||||
node?: any;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DyadMcpToolResult: React.FC<DyadMcpToolResultProps> = ({
|
||||
node,
|
||||
children,
|
||||
}) => {
|
||||
const serverName: string = node?.properties?.serverName || "";
|
||||
const toolName: string = node?.properties?.toolName || "";
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const raw = typeof children === "string" ? children : String(children ?? "");
|
||||
|
||||
const prettyJson = useMemo(() => {
|
||||
if (!expanded) return "";
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch (e) {
|
||||
console.error("Error parsing JSON for dyad-mcp-tool-result", e);
|
||||
return raw;
|
||||
}
|
||||
}, [expanded, raw]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{/* Top-left label badge */}
|
||||
<div
|
||||
className="absolute top-3 left-2 flex items-center gap-1 px-2 py-0.5 rounded text-xs font-semibold text-emerald-600 bg-white dark:bg-zinc-900"
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<CheckCircle size={16} className="text-emerald-600" />
|
||||
<span>Tool Result</span>
|
||||
</div>
|
||||
|
||||
{/* Right chevron */}
|
||||
<div className="absolute top-2 right-2 p-1 text-gray-500">
|
||||
{expanded ? <ChevronsDownUp size={18} /> : <ChevronsUpDown size={18} />}
|
||||
</div>
|
||||
|
||||
{/* Header content */}
|
||||
<div className="flex items-start gap-2 pl-24 pr-8 py-1">
|
||||
{serverName ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-50 dark:bg-zinc-800 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-zinc-700">
|
||||
{serverName}
|
||||
</span>
|
||||
) : null}
|
||||
{toolName ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-gray-200 border border-border">
|
||||
{toolName}
|
||||
</span>
|
||||
) : null}
|
||||
{/* Intentionally no preview or content when collapsed */}
|
||||
</div>
|
||||
|
||||
{/* JSON content */}
|
||||
{expanded ? (
|
||||
<div className="mt-2 pr-4 pb-2">
|
||||
<CodeHighlight className="language-json">{prettyJson}</CodeHighlight>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
504
src/components/settings/ToolsMcpSettings.tsx
Normal file
504
src/components/settings/ToolsMcpSettings.tsx
Normal file
@@ -0,0 +1,504 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useMcp, type Transport } from "@/hooks/useMcp";
|
||||
import { showError, showSuccess } from "@/lib/toast";
|
||||
import { Edit2, Plus, Save, Trash2, X } from "lucide-react";
|
||||
|
||||
type KeyValue = { key: string; value: string };
|
||||
|
||||
function parseEnvJsonToArray(
|
||||
envJson?: Record<string, string> | string | null,
|
||||
): KeyValue[] {
|
||||
if (!envJson) return [];
|
||||
try {
|
||||
const obj =
|
||||
typeof envJson === "string"
|
||||
? (JSON.parse(envJson) as unknown as Record<string, string>)
|
||||
: (envJson as Record<string, string>);
|
||||
return Object.entries(obj).map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value ?? ""),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function arrayToEnvObject(envVars: KeyValue[]): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const { key, value } of envVars) {
|
||||
if (key.trim().length === 0) continue;
|
||||
env[key.trim()] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function EnvVarsEditor({
|
||||
serverId,
|
||||
envJson,
|
||||
disabled,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: {
|
||||
serverId: number;
|
||||
envJson?: Record<string, string> | null;
|
||||
disabled?: boolean;
|
||||
onSave: (envVars: KeyValue[]) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
}) {
|
||||
const initial = useMemo(() => parseEnvJsonToArray(envJson), [envJson]);
|
||||
const [envVars, setEnvVars] = useState<KeyValue[]>(initial);
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editingKeyValue, setEditingKeyValue] = useState("");
|
||||
const [editingValue, setEditingValue] = useState("");
|
||||
const [newKey, setNewKey] = useState("");
|
||||
const [newValue, setNewValue] = useState("");
|
||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setEnvVars(initial);
|
||||
}, [serverId, initial]);
|
||||
|
||||
const saveAll = async (next: KeyValue[]) => {
|
||||
await onSave(next);
|
||||
setEnvVars(next);
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newKey.trim() || !newValue.trim()) {
|
||||
showError("Both key and value are required");
|
||||
return;
|
||||
}
|
||||
if (envVars.some((e) => e.key === newKey.trim())) {
|
||||
showError("Environment variable with this key already exists");
|
||||
return;
|
||||
}
|
||||
const next = [...envVars, { key: newKey.trim(), value: newValue.trim() }];
|
||||
await saveAll(next);
|
||||
setNewKey("");
|
||||
setNewValue("");
|
||||
setIsAddingNew(false);
|
||||
showSuccess("Environment variables saved");
|
||||
};
|
||||
|
||||
const handleEdit = (kv: KeyValue) => {
|
||||
setEditingKey(kv.key);
|
||||
setEditingKeyValue(kv.key);
|
||||
setEditingValue(kv.value);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingKey) return;
|
||||
if (!editingKeyValue.trim() || !editingValue.trim()) {
|
||||
showError("Both key and value are required");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
envVars.some(
|
||||
(e) => e.key === editingKeyValue.trim() && e.key !== editingKey,
|
||||
)
|
||||
) {
|
||||
showError("Environment variable with this key already exists");
|
||||
return;
|
||||
}
|
||||
const next = envVars.map((e) =>
|
||||
e.key === editingKey
|
||||
? { key: editingKeyValue.trim(), value: editingValue.trim() }
|
||||
: e,
|
||||
);
|
||||
await saveAll(next);
|
||||
setEditingKey(null);
|
||||
setEditingKeyValue("");
|
||||
setEditingValue("");
|
||||
showSuccess("Environment variables saved");
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingKey(null);
|
||||
setEditingKeyValue("");
|
||||
setEditingValue("");
|
||||
};
|
||||
|
||||
const handleDelete = async (key: string) => {
|
||||
const next = envVars.filter((e) => e.key !== key);
|
||||
await saveAll(next);
|
||||
showSuccess("Environment variables saved");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3">
|
||||
{isAddingNew ? (
|
||||
<div className="space-y-3 p-3 border rounded-md bg-muted/50">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`env-new-key-${serverId}`}>Key</Label>
|
||||
<Input
|
||||
id={`env-new-key-${serverId}`}
|
||||
placeholder="e.g., PATH"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
autoFocus
|
||||
disabled={disabled || isSaving}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`env-new-value-${serverId}`}>Value</Label>
|
||||
<Input
|
||||
id={`env-new-value-${serverId}`}
|
||||
placeholder="e.g., /usr/local/bin"
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
disabled={disabled || isSaving}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
size="sm"
|
||||
disabled={disabled || isSaving}
|
||||
>
|
||||
<Save size={14} />
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsAddingNew(false);
|
||||
setNewKey("");
|
||||
setNewValue("");
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setIsAddingNew(true)}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Environment Variable
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{envVars.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No environment variables configured
|
||||
</p>
|
||||
) : (
|
||||
envVars.map((kv) => (
|
||||
<div
|
||||
key={kv.key}
|
||||
className="flex items-center space-x-2 p-2 border rounded-md"
|
||||
>
|
||||
{editingKey === kv.key ? (
|
||||
<>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Input
|
||||
value={editingKeyValue}
|
||||
onChange={(e) => setEditingKeyValue(e.target.value)}
|
||||
placeholder="Key"
|
||||
className="h-8"
|
||||
disabled={disabled || isSaving}
|
||||
/>
|
||||
<Input
|
||||
value={editingValue}
|
||||
onChange={(e) => setEditingValue(e.target.value)}
|
||||
placeholder="Value"
|
||||
className="h-8"
|
||||
disabled={disabled || isSaving}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
onClick={handleSaveEdit}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled || isSaving}
|
||||
>
|
||||
<Save size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCancelEdit}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">{kv.key}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{kv.value}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
onClick={() => handleEdit(kv)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
disabled={disabled}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(kv.key)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||
disabled={disabled || isSaving}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolsMcpSettings() {
|
||||
const {
|
||||
servers,
|
||||
toolsByServer,
|
||||
consentsMap,
|
||||
createServer,
|
||||
toggleEnabled: toggleServerEnabled,
|
||||
deleteServer,
|
||||
setToolConsent: updateToolConsent,
|
||||
updateServer,
|
||||
isUpdatingServer,
|
||||
} = useMcp();
|
||||
const [consents, setConsents] = useState<Record<string, any>>({});
|
||||
const [name, setName] = useState("");
|
||||
const [transport, setTransport] = useState<Transport>("stdio");
|
||||
const [command, setCommand] = useState("");
|
||||
const [args, setArgs] = useState<string>("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
setConsents(consentsMap);
|
||||
}, [consentsMap]);
|
||||
|
||||
const onCreate = async () => {
|
||||
const parsedArgs = (() => {
|
||||
const trimmed = args.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.startsWith("[")) {
|
||||
try {
|
||||
const arr = JSON.parse(trimmed);
|
||||
return Array.isArray(arr) && arr.every((x) => typeof x === "string")
|
||||
? (arr as string[])
|
||||
: null;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
return trimmed.split(" ").filter(Boolean);
|
||||
})();
|
||||
await createServer({
|
||||
name,
|
||||
transport,
|
||||
command: command || null,
|
||||
args: parsedArgs,
|
||||
url: url || null,
|
||||
enabled,
|
||||
});
|
||||
setName("");
|
||||
setCommand("");
|
||||
setArgs("");
|
||||
setUrl("");
|
||||
setEnabled(true);
|
||||
};
|
||||
|
||||
// Removed activation toggling – tools are used dynamically with consent checks
|
||||
|
||||
const onSetToolConsent = async (
|
||||
serverId: number,
|
||||
toolName: string,
|
||||
consent: "ask" | "always" | "denied",
|
||||
) => {
|
||||
await updateToolConsent(serverId, toolName, consent);
|
||||
setConsents((prev) => ({ ...prev, [`${serverId}:${toolName}`]: consent }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My MCP Server"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Transport</Label>
|
||||
<select
|
||||
value={transport}
|
||||
onChange={(e) => setTransport(e.target.value as Transport)}
|
||||
className="w-full h-9 rounded-md border bg-transparent px-3 text-sm"
|
||||
>
|
||||
<option value="stdio">stdio</option>
|
||||
<option value="http">http</option>
|
||||
</select>
|
||||
</div>
|
||||
{transport === "stdio" && (
|
||||
<>
|
||||
<div>
|
||||
<Label>Command</Label>
|
||||
<Input
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
placeholder="node"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Args</Label>
|
||||
<Input
|
||||
value={args}
|
||||
onChange={(e) => setArgs(e.target.value)}
|
||||
placeholder="path/to/mcp-server.js --flag"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{transport === "http" && (
|
||||
<div className="col-span-2">
|
||||
<Label>URL</Label>
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="http://localhost:3000"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
<Label>Enabled</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={onCreate} disabled={!name.trim()}>
|
||||
Add Server
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{servers.map((s) => (
|
||||
<div key={s.id} className="border rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{s.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{s.transport}
|
||||
{s.url ? ` · ${s.url}` : ""}
|
||||
{s.command ? ` · ${s.command}` : ""}
|
||||
{Array.isArray(s.args) && s.args.length
|
||||
? ` · ${s.args.join(" ")}`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={!!s.enabled}
|
||||
onCheckedChange={() => toggleServerEnabled(s.id, !!s.enabled)}
|
||||
/>
|
||||
<Button variant="outline" onClick={() => deleteServer(s.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{s.transport === "stdio" && (
|
||||
<div className="mt-3">
|
||||
<div className="text-sm font-medium mb-2">
|
||||
Environment Variables
|
||||
</div>
|
||||
<EnvVarsEditor
|
||||
serverId={s.id}
|
||||
envJson={s.envJson}
|
||||
disabled={!s.enabled}
|
||||
isSaving={!!isUpdatingServer}
|
||||
onSave={async (pairs) => {
|
||||
await updateServer({
|
||||
id: s.id,
|
||||
envJson: arrayToEnvObject(pairs),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 space-y-2">
|
||||
{(toolsByServer[s.id] || []).map((t) => (
|
||||
<div key={t.name} className="border rounded p-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="font-mono text-sm truncate">{t.name}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={consents[`${s.id}:${t.name}`] || "ask"}
|
||||
onValueChange={(v) =>
|
||||
onSetToolConsent(s.id, t.name, v as any)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ask">Ask</SelectItem>
|
||||
<SelectItem value="always">Always allow</SelectItem>
|
||||
<SelectItem value="denied">Deny</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{t.description && (
|
||||
<div className="mt-1 text-xs max-w-[500px] text-muted-foreground truncate">
|
||||
{t.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{(toolsByServer[s.id] || []).length === 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
No tools discovered.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{servers.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No servers configured yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user