Unverified 提交 6d3c397d authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Add MCP support (#1028)

上级 7b160b7d
CREATE TABLE `mcp_servers` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`transport` text NOT NULL,
`command` text,
`args` text,
`env_json` text,
`url` text,
`enabled` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `mcp_tool_consents` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`server_id` integer NOT NULL,
`tool_name` text NOT NULL,
`consent` text DEFAULT 'ask' NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`server_id`) REFERENCES `mcp_servers`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `uniq_mcp_consent` ON `mcp_tool_consents` (`server_id`,`tool_name`);
\ No newline at end of file
差异被折叠。
...@@ -85,6 +85,13 @@ ...@@ -85,6 +85,13 @@
"when": 1755545060076, "when": 1755545060076,
"tag": "0011_light_zeigeist", "tag": "0011_light_zeigeist",
"breakpoints": true "breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1758320228637,
"tag": "0012_bouncy_fenris",
"breakpoints": true
} }
] ]
} }
\ No newline at end of file
...@@ -330,7 +330,7 @@ export class PageObject { ...@@ -330,7 +330,7 @@ export class PageObject {
await this.page.getByRole("button", { name: "Import" }).click(); await this.page.getByRole("button", { name: "Import" }).click();
} }
async selectChatMode(mode: "build" | "ask") { async selectChatMode(mode: "build" | "ask" | "agent") {
await this.page.getByTestId("chat-mode-selector").click(); await this.page.getByTestId("chat-mode-selector").click();
await this.page.getByRole("option", { name: mode }).click(); await this.page.getByRole("option", { name: mode }).click();
} }
...@@ -685,11 +685,16 @@ export class PageObject { ...@@ -685,11 +685,16 @@ export class PageObject {
await this.page.getByRole("button", { name: "Back" }).click(); await this.page.getByRole("button", { name: "Back" }).click();
} }
async sendPrompt(prompt: string) { async sendPrompt(
prompt: string,
{ skipWaitForCompletion = false }: { skipWaitForCompletion?: boolean } = {},
) {
await this.getChatInput().click(); await this.getChatInput().click();
await this.getChatInput().fill(prompt); await this.getChatInput().fill(prompt);
await this.page.getByRole("button", { name: "Send message" }).click(); await this.page.getByRole("button", { name: "Send message" }).click();
await this.waitForChatCompletion(); if (!skipWaitForCompletion) {
await this.waitForChatCompletion();
}
} }
async selectModel({ provider, model }: { provider: string; model: string }) { async selectModel({ provider, model }: { provider: string; model: string }) {
......
import path from "path";
import { test } from "./helpers/test_helper";
import { expect } from "@playwright/test";
test("mcp - call calculator", async ({ po }) => {
await po.setUp();
await po.goToSettingsTab();
await po.page.getByRole("button", { name: "Tools (MCP)" }).click();
await po.page
.getByRole("textbox", { name: "My MCP Server" })
.fill("testing-mcp-server");
await po.page.getByRole("textbox", { name: "node" }).fill("node");
const testMcpServerPath = path.join(
__dirname,
"..",
"testing",
"fake-stdio-mcp-server.mjs",
);
console.log("testMcpServerPath", testMcpServerPath);
await po.page
.getByRole("textbox", { name: "path/to/mcp-server.js --flag" })
.fill(testMcpServerPath);
await po.page.getByRole("button", { name: "Add Server" }).click();
await po.page
.getByRole("button", { name: "Add Environment Variable" })
.click();
await po.page.getByRole("textbox", { name: "Key" }).fill("testKey1");
await po.page.getByRole("textbox", { name: "Value" }).fill("testValue1");
await po.page.getByRole("button", { name: "Save" }).click();
await po.goToAppsTab();
await po.selectChatMode("agent");
await po.sendPrompt("[call_tool=calculator_add]", {
skipWaitForCompletion: true,
});
// Wait for consent dialog to appear
const alwaysAllowButton = po.page.getByRole("button", {
name: "Always allow",
});
await expect(alwaysAllowButton).toBeVisible();
// Make sure the tool call doesn't execute until consent is given
await po.snapshotMessages();
await alwaysAllowButton.click();
await po.sendPrompt("[dump]");
await po.snapshotServerDump("all-messages");
});
- paragraph: "[call_tool=calculator_add]"
- img
- text: Tool Call
- img
- text: testing-mcp-server calculator_add
- img
- text: less than a minute ago
===
role: system
message:
You are an AI App Builder Agent. Your role is to analyze app development requests and gather all necessary information before the actual coding phase begins.
## Core Mission
Determine what tools, APIs, data, or external resources are needed to build the requested application. Prepare everything needed for successful app development without writing any code yourself.
## Tool Usage Decision Framework
### Use Tools When The App Needs:
- **External APIs or services** (payment processing, authentication, maps, social media, etc.)
- **Real-time data** (weather, stock prices, news, current events)
- **Third-party integrations** (Firebase, Supabase, cloud services)
- **Current framework/library documentation** or best practices
### Use Tools To Research:
- Available APIs and their documentation
- Authentication methods and implementation approaches
- Database options and setup requirements
- UI/UX frameworks and component libraries
- Deployment platforms and requirements
- Performance optimization strategies
- Security best practices for the app type
### When Tools Are NOT Needed
If the app request is straightforward and can be built with standard web technologies without external dependencies, respond with:
**"Ok, looks like I don't need any tools, I can start building."**
This applies to simple apps like:
- Basic calculators or converters
- Simple games (tic-tac-toe, memory games)
- Static information displays
- Basic form interfaces
- Simple data visualization with static data
## Critical Constraints
- ABSOLUTELY NO CODE GENERATION
- **Never write HTML, CSS, JavaScript, TypeScript, or any programming code**
- **Do not create component examples or code snippets**
- **Do not provide implementation details or syntax**
- Your job ends with information gathering and requirement analysis
- All actual development happens in the next phase
## Output Structure
When tools are used, provide a brief human-readable summary of the information gathered from the tools.
When tools are not used, simply state: **"Ok, looks like I don't need any tools, I can start building."**
===
role: user
message: [call_tool=calculator_add]
===
role: assistant
message: <dyad-mcp-tool-call server="testing-mcp-server" tool="calculator_add">
{"a":1,"b":2}
</dyad-mcp-tool-call>
<dyad-mcp-tool-result server="testing-mcp-server" tool="calculator_add">
{"content":[{"type":"text","text":"3"}],"isError":false}
</dyad-mcp-tool-result>
<dyad-write path="file1.txt">
A file (2)
</dyad-write>
More
EOM
<dyad-write path="file1.txt">
A file (2)
</dyad-write>
More
EOM
===
role: user
message: [dump]
\ No newline at end of file
差异被折叠。
...@@ -61,6 +61,7 @@ ...@@ -61,6 +61,7 @@
"@playwright/test": "^1.52.0", "@playwright/test": "^1.52.0",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/fs-extra": "^11.0.4",
"@types/glob": "^8.1.0", "@types/glob": "^8.1.0",
"@types/kill-port": "^2.0.3", "@types/kill-port": "^2.0.3",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
...@@ -97,6 +98,7 @@ ...@@ -97,6 +98,7 @@
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@dyad-sh/supabase-management-js": "v1.0.0", "@dyad-sh/supabase-management-js": "v1.0.0",
"@lexical/react": "^0.33.1", "@lexical/react": "^0.33.1",
"@modelcontextprotocol/sdk": "^1.17.5",
"@monaco-editor/react": "^4.7.0-rc.0", "@monaco-editor/react": "^4.7.0-rc.0",
"@neondatabase/api-client": "^2.1.0", "@neondatabase/api-client": "^2.1.0",
"@neondatabase/serverless": "^1.0.1", "@neondatabase/serverless": "^1.0.1",
...@@ -170,5 +172,10 @@ ...@@ -170,5 +172,10 @@
"lint-staged": { "lint-staged": {
"**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue,astro,svelte}": "oxlint", "**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue,astro,svelte}": "oxlint",
"*.{js,css,md,ts,tsx,jsx,json}": "prettier --write" "*.{js,css,md,ts,tsx,jsx,json}": "prettier --write"
},
"overrides": {
"@vercel/sdk": {
"@modelcontextprotocol/sdk": "$@modelcontextprotocol/sdk"
}
} }
} }
...@@ -42,10 +42,12 @@ const config: PlaywrightTestConfig = { ...@@ -42,10 +42,12 @@ const config: PlaywrightTestConfig = {
// video: "retain-on-failure", // video: "retain-on-failure",
}, },
webServer: { webServer: [
command: `cd testing/fake-llm-server && npm run build && npm start`, {
url: "http://localhost:3500/health", command: `cd testing/fake-llm-server && npm run build && npm start`,
}, url: "http://localhost:3500/health",
},
],
}; };
export default config; export default config;
...@@ -2,15 +2,25 @@ import { ContextFilesPicker } from "./ContextFilesPicker"; ...@@ -2,15 +2,25 @@ import { ContextFilesPicker } from "./ContextFilesPicker";
import { ModelPicker } from "./ModelPicker"; import { ModelPicker } from "./ModelPicker";
import { ProModeSelector } from "./ProModeSelector"; import { ProModeSelector } from "./ProModeSelector";
import { ChatModeSelector } from "./ChatModeSelector"; import { ChatModeSelector } from "./ChatModeSelector";
import { McpToolsPicker } from "@/components/McpToolsPicker";
import { useSettings } from "@/hooks/useSettings";
export function ChatInputControls({ export function ChatInputControls({
showContextFilesPicker = false, showContextFilesPicker = false,
}: { }: {
showContextFilesPicker?: boolean; showContextFilesPicker?: boolean;
}) { }) {
const { settings } = useSettings();
return ( return (
<div className="flex"> <div className="flex">
<ChatModeSelector /> <ChatModeSelector />
{settings?.selectedChatMode === "agent" && (
<>
<div className="w-1.5"></div>
<McpToolsPicker />
</>
)}
<div className="w-1.5"></div> <div className="w-1.5"></div>
<ModelPicker /> <ModelPicker />
<div className="w-1.5"></div> <div className="w-1.5"></div>
......
...@@ -29,6 +29,8 @@ export function ChatModeSelector() { ...@@ -29,6 +29,8 @@ export function ChatModeSelector() {
return "Build"; return "Build";
case "ask": case "ask":
return "Ask"; return "Ask";
case "agent":
return "Agent";
default: default:
return "Build"; return "Build";
} }
...@@ -70,6 +72,14 @@ export function ChatModeSelector() { ...@@ -70,6 +72,14 @@ export function ChatModeSelector() {
</span> </span>
</div> </div>
</SelectItem> </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> </SelectContent>
</Select> </Select>
); );
......
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>
);
}
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 = [ ...@@ -10,6 +10,7 @@ const SETTINGS_SECTIONS = [
{ id: "provider-settings", label: "Model Providers" }, { id: "provider-settings", label: "Model Providers" },
{ id: "telemetry", label: "Telemetry" }, { id: "telemetry", label: "Telemetry" },
{ id: "integrations", label: "Integrations" }, { id: "integrations", label: "Integrations" },
{ id: "tools-mcp", label: "Tools (MCP)" },
{ id: "experiments", label: "Experiments" }, { id: "experiments", label: "Experiments" },
{ id: "danger-zone", label: "Danger Zone" }, { id: "danger-zone", label: "Danger Zone" },
]; ];
......
...@@ -17,6 +17,8 @@ import { CustomTagState } from "./stateTypes"; ...@@ -17,6 +17,8 @@ import { CustomTagState } from "./stateTypes";
import { DyadOutput } from "./DyadOutput"; import { DyadOutput } from "./DyadOutput";
import { DyadProblemSummary } from "./DyadProblemSummary"; import { DyadProblemSummary } from "./DyadProblemSummary";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import { DyadMcpToolCall } from "./DyadMcpToolCall";
import { DyadMcpToolResult } from "./DyadMcpToolResult";
interface DyadMarkdownParserProps { interface DyadMarkdownParserProps {
content: string; content: string;
...@@ -124,6 +126,8 @@ function preprocessUnclosedTags(content: string): { ...@@ -124,6 +126,8 @@ function preprocessUnclosedTags(content: string): {
"dyad-codebase-context", "dyad-codebase-context",
"think", "think",
"dyad-command", "dyad-command",
"dyad-mcp-tool-call",
"dyad-mcp-tool-result",
]; ];
let processedContent = content; let processedContent = content;
...@@ -191,6 +195,8 @@ function parseCustomTags(content: string): ContentPiece[] { ...@@ -191,6 +195,8 @@ function parseCustomTags(content: string): ContentPiece[] {
"dyad-codebase-context", "dyad-codebase-context",
"think", "think",
"dyad-command", "dyad-command",
"dyad-mcp-tool-call",
"dyad-mcp-tool-result",
]; ];
const tagPattern = new RegExp( const tagPattern = new RegExp(
...@@ -399,6 +405,34 @@ function renderCustomTag( ...@@ -399,6 +405,34 @@ function renderCustomTag(
</DyadCodebaseContext> </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": case "dyad-output":
return ( return (
<DyadOutput <DyadOutput
......
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>
);
};
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>
);
};
差异被折叠。
...@@ -172,3 +172,43 @@ export const versionsRelations = relations(versions, ({ one }) => ({ ...@@ -172,3 +172,43 @@ export const versionsRelations = relations(versions, ({ one }) => ({
references: [apps.id], references: [apps.id],
}), }),
})); }));
// --- MCP (Model Context Protocol) tables ---
export const mcpServers = sqliteTable("mcp_servers", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
transport: text("transport").notNull(),
command: text("command"),
// Store typed JSON for args and environment variables
args: text("args", { mode: "json" }).$type<string[] | null>(),
envJson: text("env_json", { mode: "json" }).$type<Record<
string,
string
> | null>(),
url: text("url"),
enabled: integer("enabled", { mode: "boolean" })
.notNull()
.default(sql`0`),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
});
export const mcpToolConsents = sqliteTable(
"mcp_tool_consents",
{
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => mcpServers.id, { onDelete: "cascade" }),
toolName: text("tool_name").notNull(),
consent: text("consent").notNull().default("ask"), // ask | always | denied
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
},
(table) => [unique("uniq_mcp_consent").on(table.serverId, table.toolName)],
);
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { IpcClient } from "@/ipc/ipc_client";
import type {
McpServer,
McpServerUpdate,
McpTool,
McpToolConsent,
CreateMcpServer,
} from "@/ipc/ipc_types";
export type Transport = "stdio" | "http";
export function useMcp() {
const queryClient = useQueryClient();
const serversQuery = useQuery<McpServer[], Error>({
queryKey: ["mcp", "servers"],
queryFn: async () => {
const ipc = IpcClient.getInstance();
const list = await ipc.listMcpServers();
return (list || []) as McpServer[];
},
meta: { showErrorToast: true },
});
const serverIds = useMemo(
() => (serversQuery.data || []).map((s) => s.id).sort((a, b) => a - b),
[serversQuery.data],
);
const toolsByServerQuery = useQuery<Record<number, McpTool[]>, Error>({
queryKey: ["mcp", "tools-by-server", serverIds],
enabled: serverIds.length > 0,
queryFn: async () => {
const ipc = IpcClient.getInstance();
const entries = await Promise.all(
serverIds.map(async (id) => [id, await ipc.listMcpTools(id)] as const),
);
return Object.fromEntries(entries) as Record<number, McpTool[]>;
},
meta: { showErrorToast: true },
});
const consentsQuery = useQuery<McpToolConsent[], Error>({
queryKey: ["mcp", "consents"],
queryFn: async () => {
const ipc = IpcClient.getInstance();
const list = await ipc.getMcpToolConsents();
return (list || []) as McpToolConsent[];
},
meta: { showErrorToast: true },
});
const consentsMap = useMemo(() => {
const map: Record<string, McpToolConsent["consent"]> = {};
for (const c of consentsQuery.data || []) {
map[`${c.serverId}:${c.toolName}`] = c.consent;
}
return map;
}, [consentsQuery.data]);
const createServerMutation = useMutation({
mutationFn: async (params: CreateMcpServer) => {
const ipc = IpcClient.getInstance();
return ipc.createMcpServer(params);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["mcp", "servers"] });
await queryClient.invalidateQueries({
queryKey: ["mcp", "tools-by-server"],
});
},
meta: { showErrorToast: true },
});
const updateServerMutation = useMutation({
mutationFn: async (params: McpServerUpdate) => {
const ipc = IpcClient.getInstance();
return ipc.updateMcpServer(params);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["mcp", "servers"] });
await queryClient.invalidateQueries({
queryKey: ["mcp", "tools-by-server"],
});
},
meta: { showErrorToast: true },
});
const deleteServerMutation = useMutation({
mutationFn: async (id: number) => {
const ipc = IpcClient.getInstance();
return ipc.deleteMcpServer(id);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["mcp", "servers"] });
await queryClient.invalidateQueries({
queryKey: ["mcp", "tools-by-server"],
});
},
meta: { showErrorToast: true },
});
const setConsentMutation = useMutation({
mutationFn: async (params: {
serverId: number;
toolName: string;
consent: McpToolConsent["consent"];
}) => {
const ipc = IpcClient.getInstance();
return ipc.setMcpToolConsent(params);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["mcp", "consents"] });
},
meta: { showErrorToast: true },
});
const createServer = async (params: CreateMcpServer) =>
createServerMutation.mutateAsync(params);
const toggleEnabled = async (id: number, currentEnabled: boolean) =>
updateServerMutation.mutateAsync({ id, enabled: !currentEnabled });
const updateServer = async (params: McpServerUpdate) =>
updateServerMutation.mutateAsync(params);
const deleteServer = async (id: number) =>
deleteServerMutation.mutateAsync(id);
const setToolConsent = async (
serverId: number,
toolName: string,
consent: McpToolConsent["consent"],
) => setConsentMutation.mutateAsync({ serverId, toolName, consent });
const refetchAll = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["mcp", "servers"] }),
queryClient.invalidateQueries({ queryKey: ["mcp", "tools-by-server"] }),
queryClient.invalidateQueries({ queryKey: ["mcp", "consents"] }),
]);
};
return {
servers: serversQuery.data || [],
toolsByServer: toolsByServerQuery.data || {},
consentsList: consentsQuery.data || [],
consentsMap,
isLoading:
serversQuery.isLoading ||
toolsByServerQuery.isLoading ||
consentsQuery.isLoading,
error:
serversQuery.error || toolsByServerQuery.error || consentsQuery.error,
refetchAll,
// Mutations
createServer,
toggleEnabled,
updateServer,
deleteServer,
setToolConsent,
// Status flags
isCreating: createServerMutation.isPending,
isToggling: updateServerMutation.isPending,
isUpdatingServer: updateServerMutation.isPending,
isDeleting: deleteServerMutation.isPending,
isSettingConsent: setConsentMutation.isPending,
} as const;
}
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { ipcMain } from "electron"; import { ipcMain, IpcMainInvokeEvent } from "electron";
import { import {
ModelMessage, ModelMessage,
TextPart, TextPart,
...@@ -7,7 +7,9 @@ import { ...@@ -7,7 +7,9 @@ import {
streamText, streamText,
ToolSet, ToolSet,
TextStreamPart, TextStreamPart,
stepCountIs,
} from "ai"; } from "ai";
import { db } from "../../db"; import { db } from "../../db";
import { chats, messages } from "../../db/schema"; import { chats, messages } from "../../db/schema";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
...@@ -42,6 +44,8 @@ import { getMaxTokens, getTemperature } from "../utils/token_utils"; ...@@ -42,6 +44,8 @@ import { getMaxTokens, getTemperature } from "../utils/token_utils";
import { MAX_CHAT_TURNS_IN_CONTEXT } from "@/constants/settings_constants"; import { MAX_CHAT_TURNS_IN_CONTEXT } from "@/constants/settings_constants";
import { validateChatContext } from "../utils/context_paths_utils"; import { validateChatContext } from "../utils/context_paths_utils";
import { GoogleGenerativeAIProviderOptions } from "@ai-sdk/google"; import { GoogleGenerativeAIProviderOptions } from "@ai-sdk/google";
import { mcpServers } from "../../db/schema";
import { requireMcpToolConsent } from "../utils/mcp_consent";
import { getExtraProviderOptions } from "../utils/thinking_utils"; import { getExtraProviderOptions } from "../utils/thinking_utils";
...@@ -64,6 +68,7 @@ import { parseAppMentions } from "@/shared/parse_mention_apps"; ...@@ -64,6 +68,7 @@ import { parseAppMentions } from "@/shared/parse_mention_apps";
import { prompts as promptsTable } from "../../db/schema"; import { prompts as promptsTable } from "../../db/schema";
import { inArray } from "drizzle-orm"; import { inArray } from "drizzle-orm";
import { replacePromptReference } from "../utils/replacePromptReference"; import { replacePromptReference } from "../utils/replacePromptReference";
import { mcpManager } from "../utils/mcp_manager";
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>; type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
...@@ -103,6 +108,23 @@ function escapeXml(unsafe: string): string { ...@@ -103,6 +108,23 @@ function escapeXml(unsafe: string): string {
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
// Safely parse an MCP tool key that combines server and tool names.
// We split on the LAST occurrence of "__" to avoid ambiguity if either
// side contains "__" as part of its sanitized name.
function parseMcpToolKey(toolKey: string): {
serverName: string;
toolName: string;
} {
const separator = "__";
const lastIndex = toolKey.lastIndexOf(separator);
if (lastIndex === -1) {
return { serverName: "", toolName: toolKey };
}
const serverName = toolKey.slice(0, lastIndex);
const toolName = toolKey.slice(lastIndex + separator.length);
return { serverName, toolName };
}
// Ensure the temp directory exists // Ensure the temp directory exists
if (!fs.existsSync(TEMP_DIR)) { if (!fs.existsSync(TEMP_DIR)) {
fs.mkdirSync(TEMP_DIR, { recursive: true }); fs.mkdirSync(TEMP_DIR, { recursive: true });
...@@ -129,11 +151,16 @@ async function processStreamChunks({ ...@@ -129,11 +151,16 @@ async function processStreamChunks({
for await (const part of fullStream) { for await (const part of fullStream) {
let chunk = ""; let chunk = "";
if (
inThinkingBlock &&
!["reasoning-delta", "reasoning-end", "reasoning-start"].includes(
part.type,
)
) {
chunk = "</think>";
inThinkingBlock = false;
}
if (part.type === "text-delta") { if (part.type === "text-delta") {
if (inThinkingBlock) {
chunk = "</think>";
inThinkingBlock = false;
}
chunk += part.text; chunk += part.text;
} else if (part.type === "reasoning-delta") { } else if (part.type === "reasoning-delta") {
if (!inThinkingBlock) { if (!inThinkingBlock) {
...@@ -142,6 +169,14 @@ async function processStreamChunks({ ...@@ -142,6 +169,14 @@ async function processStreamChunks({
} }
chunk += escapeDyadTags(part.text); chunk += escapeDyadTags(part.text);
} else if (part.type === "tool-call") {
const { serverName, toolName } = parseMcpToolKey(part.toolName);
const content = escapeDyadTags(JSON.stringify(part.input));
chunk = `<dyad-mcp-tool-call server="${serverName}" tool="${toolName}">\n${content}\n</dyad-mcp-tool-call>\n`;
} else if (part.type === "tool-result") {
const { serverName, toolName } = parseMcpToolKey(part.toolName);
const content = escapeDyadTags(part.output);
chunk = `<dyad-mcp-tool-result server="${serverName}" tool="${toolName}">\n${content}\n</dyad-mcp-tool-result>\n`;
} }
if (!chunk) { if (!chunk) {
...@@ -496,7 +531,10 @@ ${componentSnippet} ...@@ -496,7 +531,10 @@ ${componentSnippet}
let systemPrompt = constructSystemPrompt({ let systemPrompt = constructSystemPrompt({
aiRules: await readAiRules(getDyadAppPath(updatedChat.app.path)), aiRules: await readAiRules(getDyadAppPath(updatedChat.app.path)),
chatMode: settings.selectedChatMode, chatMode:
settings.selectedChatMode === "agent"
? "build"
: settings.selectedChatMode,
}); });
// Add information about mentioned apps if any // Add information about mentioned apps if any
...@@ -603,19 +641,21 @@ This conversation includes one or more image attachments. When the user uploads ...@@ -603,19 +641,21 @@ This conversation includes one or more image attachments. When the user uploads
] as const) ] as const)
: []; : [];
const limitedHistoryChatMessages = limitedMessageHistory.map((msg) => ({
role: msg.role as "user" | "assistant" | "system",
// Why remove thinking tags?
// Thinking tags are generally not critical for the context
// and eats up extra tokens.
content:
settings.selectedChatMode === "ask"
? removeDyadTags(removeNonEssentialTags(msg.content))
: removeNonEssentialTags(msg.content),
}));
let chatMessages: ModelMessage[] = [ let chatMessages: ModelMessage[] = [
...codebasePrefix, ...codebasePrefix,
...otherCodebasePrefix, ...otherCodebasePrefix,
...limitedMessageHistory.map((msg) => ({ ...limitedHistoryChatMessages,
role: msg.role as "user" | "assistant" | "system",
// Why remove thinking tags?
// Thinking tags are generally not critical for the context
// and eats up extra tokens.
content:
settings.selectedChatMode === "ask"
? removeDyadTags(removeNonEssentialTags(msg.content))
: removeNonEssentialTags(msg.content),
})),
]; ];
// Check if the last message should include attachments // Check if the last message should include attachments
...@@ -654,9 +694,15 @@ This conversation includes one or more image attachments. When the user uploads ...@@ -654,9 +694,15 @@ This conversation includes one or more image attachments. When the user uploads
const simpleStreamText = async ({ const simpleStreamText = async ({
chatMessages, chatMessages,
modelClient, modelClient,
tools,
systemPromptOverride = systemPrompt,
dyadDisableFiles = false,
}: { }: {
chatMessages: ModelMessage[]; chatMessages: ModelMessage[];
modelClient: ModelClient; modelClient: ModelClient;
tools?: ToolSet;
systemPromptOverride?: string;
dyadDisableFiles?: boolean;
}) => { }) => {
const dyadRequestId = uuidv4(); const dyadRequestId = uuidv4();
if (isEngineEnabled) { if (isEngineEnabled) {
...@@ -671,6 +717,7 @@ This conversation includes one or more image attachments. When the user uploads ...@@ -671,6 +717,7 @@ This conversation includes one or more image attachments. When the user uploads
const providerOptions: Record<string, any> = { const providerOptions: Record<string, any> = {
"dyad-engine": { "dyad-engine": {
dyadRequestId, dyadRequestId,
dyadDisableFiles,
}, },
"dyad-gateway": getExtraProviderOptions( "dyad-gateway": getExtraProviderOptions(
modelClient.builtinProviderId, modelClient.builtinProviderId,
...@@ -708,6 +755,7 @@ This conversation includes one or more image attachments. When the user uploads ...@@ -708,6 +755,7 @@ This conversation includes one or more image attachments. When the user uploads
}, },
} satisfies GoogleGenerativeAIProviderOptions; } satisfies GoogleGenerativeAIProviderOptions;
} }
return streamText({ return streamText({
headers: isAnthropic headers: isAnthropic
? { ? {
...@@ -718,8 +766,10 @@ This conversation includes one or more image attachments. When the user uploads ...@@ -718,8 +766,10 @@ This conversation includes one or more image attachments. When the user uploads
temperature: await getTemperature(settings.selectedModel), temperature: await getTemperature(settings.selectedModel),
maxRetries: 2, maxRetries: 2,
model: modelClient.model, model: modelClient.model,
stopWhen: stepCountIs(3),
providerOptions, providerOptions,
system: systemPrompt, system: systemPromptOverride,
tools,
messages: chatMessages.filter((m) => m.content), messages: chatMessages.filter((m) => m.content),
onError: (error: any) => { onError: (error: any) => {
logger.error("Error streaming text:", error); logger.error("Error streaming text:", error);
...@@ -780,6 +830,38 @@ This conversation includes one or more image attachments. When the user uploads ...@@ -780,6 +830,38 @@ This conversation includes one or more image attachments. When the user uploads
return fullResponse; return fullResponse;
}; };
if (settings.selectedChatMode === "agent") {
const tools = await getMcpTools(event);
const { fullStream } = await simpleStreamText({
chatMessages: limitedHistoryChatMessages,
modelClient,
tools,
systemPromptOverride: constructSystemPrompt({
aiRules: await readAiRules(getDyadAppPath(updatedChat.app.path)),
chatMode: "agent",
}),
dyadDisableFiles: true,
});
const result = await processStreamChunks({
fullStream,
fullResponse,
abortController,
chatId: req.chatId,
processResponseChunkUpdate,
});
fullResponse = result.fullResponse;
chatMessages.push({
role: "assistant",
content: fullResponse,
});
chatMessages.push({
role: "user",
content: "OK.",
});
}
// When calling streamText, the messages need to be properly formatted for mixed content // When calling streamText, the messages need to be properly formatted for mixed content
const { fullStream } = await simpleStreamText({ const { fullStream } = await simpleStreamText({
chatMessages, chatMessages,
...@@ -1316,3 +1398,48 @@ These are the other apps that I've mentioned in my prompt. These other apps' cod ...@@ -1316,3 +1398,48 @@ These are the other apps that I've mentioned in my prompt. These other apps' cod
${otherAppsCodebaseInfo} ${otherAppsCodebaseInfo}
`; `;
} }
async function getMcpTools(event: IpcMainInvokeEvent): Promise<ToolSet> {
const mcpToolSet: ToolSet = {};
try {
const servers = await db
.select()
.from(mcpServers)
.where(eq(mcpServers.enabled, true as any));
for (const s of servers) {
const client = await mcpManager.getClient(s.id);
const toolSet = await client.tools();
for (const [name, tool] of Object.entries(toolSet)) {
const key = `${String(s.name || "").replace(/[^a-zA-Z0-9_-]/g, "-")}__${String(name).replace(/[^a-zA-Z0-9_-]/g, "-")}`;
const original = tool;
mcpToolSet[key] = {
description: original?.description,
inputSchema: original?.inputSchema,
execute: async (args: any, execCtx: any) => {
const inputPreview =
typeof args === "string"
? args
: Array.isArray(args)
? args.join(" ")
: JSON.stringify(args).slice(0, 500);
const ok = await requireMcpToolConsent(event, {
serverId: s.id,
serverName: s.name,
toolName: name,
toolDescription: original?.description,
inputPreview,
});
if (!ok) throw new Error(`User declined running tool ${key}`);
const res = await original.execute?.(args, execCtx);
return typeof res === "string" ? res : JSON.stringify(res);
},
};
}
}
} catch (e) {
logger.warn("Failed building MCP toolset", e);
}
return mcpToolSet;
}
import { IpcMainInvokeEvent } from "electron";
import log from "electron-log";
import { db } from "../../db";
import { mcpServers, mcpToolConsents } from "../../db/schema";
import { eq, and } from "drizzle-orm";
import { createLoggedHandler } from "./safe_handle";
import { resolveConsent } from "../utils/mcp_consent";
import { getStoredConsent } from "../utils/mcp_consent";
import { mcpManager } from "../utils/mcp_manager";
import { CreateMcpServer, McpServerUpdate, McpTool } from "../ipc_types";
const logger = log.scope("mcp_handlers");
const handle = createLoggedHandler(logger);
type ConsentDecision = "accept-once" | "accept-always" | "decline";
export function registerMcpHandlers() {
// CRUD for MCP servers
handle("mcp:list-servers", async () => {
return await db.select().from(mcpServers);
});
handle(
"mcp:create-server",
async (_event: IpcMainInvokeEvent, params: CreateMcpServer) => {
const { name, transport, command, args, envJson, url, enabled } = params;
const result = await db
.insert(mcpServers)
.values({
name,
transport,
command: command || null,
args: args || null,
envJson: envJson || null,
url: url || null,
enabled: !!enabled,
})
.returning();
return result[0];
},
);
handle(
"mcp:update-server",
async (_event: IpcMainInvokeEvent, params: McpServerUpdate) => {
const update: any = {};
if (params.name !== undefined) update.name = params.name;
if (params.transport !== undefined) update.transport = params.transport;
if (params.command !== undefined) update.command = params.command;
if (params.args !== undefined) update.args = params.args || null;
if (params.cwd !== undefined) update.cwd = params.cwd;
if (params.envJson !== undefined) update.envJson = params.envJson || null;
if (params.url !== undefined) update.url = params.url;
if (params.enabled !== undefined) update.enabled = !!params.enabled;
const result = await db
.update(mcpServers)
.set(update)
.where(eq(mcpServers.id, params.id))
.returning();
// If server config changed, dispose cached client to be recreated on next use
try {
mcpManager.dispose(params.id);
} catch {}
return result[0];
},
);
handle(
"mcp:delete-server",
async (_event: IpcMainInvokeEvent, id: number) => {
try {
mcpManager.dispose(id);
} catch {}
await db.delete(mcpServers).where(eq(mcpServers.id, id));
return { success: true };
},
);
// Tools listing (dynamic)
handle(
"mcp:list-tools",
async (
_event: IpcMainInvokeEvent,
serverId: number,
): Promise<McpTool[]> => {
try {
const client = await mcpManager.getClient(serverId);
const remoteTools = await client.tools();
const tools = await Promise.all(
Object.entries(remoteTools).map(async ([name, tool]) => ({
name,
description: tool.description ?? null,
consent: await getStoredConsent(serverId, name),
})),
);
return tools;
} catch (e) {
logger.error("Failed to list tools", e);
return [];
}
},
);
// Consents
handle("mcp:get-tool-consents", async () => {
return await db.select().from(mcpToolConsents);
});
handle(
"mcp:set-tool-consent",
async (
_event: IpcMainInvokeEvent,
params: {
serverId: number;
toolName: string;
consent: "ask" | "always" | "denied";
},
) => {
const existing = await db
.select()
.from(mcpToolConsents)
.where(
and(
eq(mcpToolConsents.serverId, params.serverId),
eq(mcpToolConsents.toolName, params.toolName),
),
);
if (existing.length > 0) {
const result = await db
.update(mcpToolConsents)
.set({ consent: params.consent })
.where(
and(
eq(mcpToolConsents.serverId, params.serverId),
eq(mcpToolConsents.toolName, params.toolName),
),
)
.returning();
return result[0];
} else {
const result = await db
.insert(mcpToolConsents)
.values({
serverId: params.serverId,
toolName: params.toolName,
consent: params.consent,
})
.returning();
return result[0];
}
},
);
// Tool consent request/response handshake
// Receive consent response from renderer
handle(
"mcp:tool-consent-response",
async (_event, data: { requestId: string; decision: ConsentDecision }) => {
resolveConsent(data.requestId, data.decision);
},
);
}
...@@ -63,6 +63,8 @@ import type { ...@@ -63,6 +63,8 @@ import type {
PromptDto, PromptDto,
CreatePromptParamsDto, CreatePromptParamsDto,
UpdatePromptParamsDto, UpdatePromptParamsDto,
McpServerUpdate,
CreateMcpServer,
} from "./ipc_types"; } from "./ipc_types";
import type { Template } from "../shared/templates"; import type { Template } from "../shared/templates";
import type { import type {
...@@ -119,11 +121,13 @@ export class IpcClient { ...@@ -119,11 +121,13 @@ export class IpcClient {
onError: (error: string) => void; onError: (error: string) => void;
} }
>; >;
private mcpConsentHandlers: Map<string, (payload: any) => void>;
private constructor() { private constructor() {
this.ipcRenderer = (window as any).electron.ipcRenderer as IpcRenderer; this.ipcRenderer = (window as any).electron.ipcRenderer as IpcRenderer;
this.chatStreams = new Map(); this.chatStreams = new Map();
this.appStreams = new Map(); this.appStreams = new Map();
this.helpStreams = new Map(); this.helpStreams = new Map();
this.mcpConsentHandlers = new Map();
// Set up listeners for stream events // Set up listeners for stream events
this.ipcRenderer.on("chat:response:chunk", (data) => { this.ipcRenderer.on("chat:response:chunk", (data) => {
if ( if (
...@@ -238,6 +242,12 @@ export class IpcClient { ...@@ -238,6 +242,12 @@ export class IpcClient {
this.helpStreams.delete(sessionId); this.helpStreams.delete(sessionId);
} }
}); });
// MCP tool consent request from main
this.ipcRenderer.on("mcp:tool-consent-request", (payload) => {
const handler = this.mcpConsentHandlers.get("consent");
if (handler) handler(payload);
});
} }
public static getInstance(): IpcClient { public static getInstance(): IpcClient {
...@@ -814,6 +824,67 @@ export class IpcClient { ...@@ -814,6 +824,67 @@ export class IpcClient {
return result.version as string; return result.version as string;
} }
// --- MCP Client Methods ---
public async listMcpServers() {
return this.ipcRenderer.invoke("mcp:list-servers");
}
public async createMcpServer(params: CreateMcpServer) {
return this.ipcRenderer.invoke("mcp:create-server", params);
}
public async updateMcpServer(params: McpServerUpdate) {
return this.ipcRenderer.invoke("mcp:update-server", params);
}
public async deleteMcpServer(id: number) {
return this.ipcRenderer.invoke("mcp:delete-server", id);
}
public async listMcpTools(serverId: number) {
return this.ipcRenderer.invoke("mcp:list-tools", serverId);
}
// Removed: upsertMcpTools and setMcpToolActive – tools are fetched dynamically at runtime
public async getMcpToolConsents() {
return this.ipcRenderer.invoke("mcp:get-tool-consents");
}
public async setMcpToolConsent(params: {
serverId: number;
toolName: string;
consent: "ask" | "always" | "denied";
}) {
return this.ipcRenderer.invoke("mcp:set-tool-consent", params);
}
public onMcpToolConsentRequest(
handler: (payload: {
requestId: string;
serverId: number;
serverName: string;
toolName: string;
toolDescription?: string | null;
inputPreview?: string | null;
}) => void,
) {
this.mcpConsentHandlers.set("consent", handler as any);
return () => {
this.mcpConsentHandlers.delete("consent");
};
}
public respondToMcpConsentRequest(
requestId: string,
decision: "accept-once" | "accept-always" | "decline",
) {
this.ipcRenderer.invoke("mcp:tool-consent-response", {
requestId,
decision,
});
}
// Get proposal details // Get proposal details
public async getProposal(chatId: number): Promise<ProposalResult | null> { public async getProposal(chatId: number): Promise<ProposalResult | null> {
try { try {
......
...@@ -30,6 +30,7 @@ import { registerTemplateHandlers } from "./handlers/template_handlers"; ...@@ -30,6 +30,7 @@ import { registerTemplateHandlers } from "./handlers/template_handlers";
import { registerPortalHandlers } from "./handlers/portal_handlers"; import { registerPortalHandlers } from "./handlers/portal_handlers";
import { registerPromptHandlers } from "./handlers/prompt_handlers"; import { registerPromptHandlers } from "./handlers/prompt_handlers";
import { registerHelpBotHandlers } from "./handlers/help_bot_handlers"; import { registerHelpBotHandlers } from "./handlers/help_bot_handlers";
import { registerMcpHandlers } from "./handlers/mcp_handlers";
export function registerIpcHandlers() { export function registerIpcHandlers() {
// Register all IPC handlers by category // Register all IPC handlers by category
...@@ -65,4 +66,5 @@ export function registerIpcHandlers() { ...@@ -65,4 +66,5 @@ export function registerIpcHandlers() {
registerPortalHandlers(); registerPortalHandlers();
registerPromptHandlers(); registerPromptHandlers();
registerHelpBotHandlers(); registerHelpBotHandlers();
registerMcpHandlers();
} }
...@@ -450,3 +450,37 @@ export interface HelpChatResponseError { ...@@ -450,3 +450,37 @@ export interface HelpChatResponseError {
sessionId: string; sessionId: string;
error: string; error: string;
} }
// --- MCP Types ---
export interface McpServer {
id: number;
name: string;
transport: string;
command?: string | null;
args?: string[] | null;
cwd?: string | null;
envJson?: Record<string, string> | null;
url?: string | null;
enabled: boolean;
createdAt: number;
updatedAt: number;
}
export interface CreateMcpServer
extends Omit<McpServer, "id" | "createdAt" | "updatedAt"> {}
export type McpServerUpdate = Partial<McpServer> & Pick<McpServer, "id">;
export type McpToolConsentType = "ask" | "always" | "denied";
export interface McpTool {
name: string;
description?: string | null;
consent: McpToolConsentType;
}
export interface McpToolConsent {
id: number;
serverId: number;
toolName: string;
consent: McpToolConsentType;
updatedAt: number;
}
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论