Add MCP support (#1028)

This commit is contained in:
Will Chen
2025-09-19 15:43:39 -07:00
committed by GitHub
parent 7b160b7d0b
commit 6d3c397d40
39 changed files with 3865 additions and 650 deletions

View File

@@ -0,0 +1,23 @@
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`);

View File

@@ -0,0 +1,731 @@
{
"version": "6",
"dialect": "sqlite",
"id": "8c77d7f5-9f88-4186-8aff-8385e060e59f",
"prevId": "6ac2fe61-675b-4e3f-baf7-0f7d5f76bb2c",
"tables": {
"apps": {
"name": "apps",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"github_org": {
"name": "github_org",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_repo": {
"name": "github_repo",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_branch": {
"name": "github_branch",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"supabase_project_id": {
"name": "supabase_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_project_id": {
"name": "neon_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_development_branch_id": {
"name": "neon_development_branch_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_preview_branch_id": {
"name": "neon_preview_branch_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_project_id": {
"name": "vercel_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_project_name": {
"name": "vercel_project_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_team_id": {
"name": "vercel_team_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_deployment_url": {
"name": "vercel_deployment_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"install_command": {
"name": "install_command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"start_command": {
"name": "start_command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"chat_context": {
"name": "chat_context",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"chats": {
"name": "chats",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"app_id": {
"name": "app_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"initial_commit_hash": {
"name": "initial_commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"chats_app_id_apps_id_fk": {
"name": "chats_app_id_apps_id_fk",
"tableFrom": "chats",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"language_model_providers": {
"name": "language_model_providers",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_base_url": {
"name": "api_base_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"env_var_name": {
"name": "env_var_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"language_models": {
"name": "language_models",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_name": {
"name": "api_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"builtin_provider_id": {
"name": "builtin_provider_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"custom_provider_id": {
"name": "custom_provider_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_output_tokens": {
"name": "max_output_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"context_window": {
"name": "context_window",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"language_models_custom_provider_id_language_model_providers_id_fk": {
"name": "language_models_custom_provider_id_language_model_providers_id_fk",
"tableFrom": "language_models",
"tableTo": "language_model_providers",
"columnsFrom": [
"custom_provider_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"mcp_servers": {
"name": "mcp_servers",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"transport": {
"name": "transport",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"command": {
"name": "command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"args": {
"name": "args",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"env_json": {
"name": "env_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "0"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"mcp_tool_consents": {
"name": "mcp_tool_consents",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"server_id": {
"name": "server_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tool_name": {
"name": "tool_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"consent": {
"name": "consent",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'ask'"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"uniq_mcp_consent": {
"name": "uniq_mcp_consent",
"columns": [
"server_id",
"tool_name"
],
"isUnique": true
}
},
"foreignKeys": {
"mcp_tool_consents_server_id_mcp_servers_id_fk": {
"name": "mcp_tool_consents_server_id_mcp_servers_id_fk",
"tableFrom": "mcp_tool_consents",
"tableTo": "mcp_servers",
"columnsFrom": [
"server_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"messages": {
"name": "messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"chat_id": {
"name": "chat_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"approval_state": {
"name": "approval_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"commit_hash": {
"name": "commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"messages_chat_id_chats_id_fk": {
"name": "messages_chat_id_chats_id_fk",
"tableFrom": "messages",
"tableTo": "chats",
"columnsFrom": [
"chat_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"prompts": {
"name": "prompts",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"versions": {
"name": "versions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"app_id": {
"name": "app_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"commit_hash": {
"name": "commit_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"neon_db_timestamp": {
"name": "neon_db_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"versions_app_commit_unique": {
"name": "versions_app_commit_unique",
"columns": [
"app_id",
"commit_hash"
],
"isUnique": true
}
},
"foreignKeys": {
"versions_app_id_apps_id_fk": {
"name": "versions_app_id_apps_id_fk",
"tableFrom": "versions",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -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
} }
] ]
} }

View File

@@ -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 {
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 }) {

49
e2e-tests/mcp.spec.ts Normal file
View File

@@ -0,0 +1,49 @@
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");
});

View File

@@ -0,0 +1,7 @@
- paragraph: "[call_tool=calculator_add]"
- img
- text: Tool Call
- img
- text: testing-mcp-server calculator_add
- img
- text: less than a minute ago

View File

@@ -0,0 +1,80 @@
===
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]

1386
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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 @@
"@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 @@
"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"
}
} }
} }

View File

@@ -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;

View File

@@ -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>

View File

@@ -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() {
</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>
); );

View 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>
);
}

View 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>
);
}

View File

@@ -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" },
]; ];

View File

@@ -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): {
"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[] {
"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(
</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

View 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>
);
};

View 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>
);
};

View 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>
);
}

View File

@@ -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)],
);

173
src/hooks/useMcp.ts Normal file
View File

@@ -0,0 +1,173 @@
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;
}

View File

@@ -1,5 +1,5 @@
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 {
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";
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";
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 {
.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({
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({
} }
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}
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
] 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
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
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
}, },
} 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
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
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
${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;
}

View File

@@ -0,0 +1,163 @@
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);
},
);
}

View File

@@ -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 {
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 {
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 {
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 {

View File

@@ -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() {
registerPortalHandlers(); registerPortalHandlers();
registerPromptHandlers(); registerPromptHandlers();
registerHelpBotHandlers(); registerHelpBotHandlers();
registerMcpHandlers();
} }

View File

@@ -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;
}

View File

@@ -137,6 +137,10 @@ export function createDyadEngine(
if ("dyadRequestId" in parsedBody) { if ("dyadRequestId" in parsedBody) {
delete parsedBody.dyadRequestId; delete parsedBody.dyadRequestId;
} }
const dyadDisableFiles = parsedBody.dyadDisableFiles;
if ("dyadDisableFiles" in parsedBody) {
delete parsedBody.dyadDisableFiles;
}
// Track and modify requestId with attempt number // Track and modify requestId with attempt number
let modifiedRequestId = requestId; let modifiedRequestId = requestId;
@@ -147,7 +151,7 @@ export function createDyadEngine(
} }
// Add files to the request if they exist // Add files to the request if they exist
if (files?.length) { if (files?.length && !dyadDisableFiles) {
parsedBody.dyad_options = { parsedBody.dyad_options = {
files, files,
enable_lazy_edits: options.dyadOptions.enableLazyEdits, enable_lazy_edits: options.dyadOptions.enableLazyEdits,

View File

@@ -0,0 +1,108 @@
import { db } from "../../db";
import { mcpToolConsents } from "../../db/schema";
import { and, eq } from "drizzle-orm";
import { IpcMainInvokeEvent } from "electron";
export type Consent = "ask" | "always" | "denied";
const pendingConsentResolvers = new Map<
string,
(d: "accept-once" | "accept-always" | "decline") => void
>();
export function waitForConsent(
requestId: string,
): Promise<"accept-once" | "accept-always" | "decline"> {
return new Promise((resolve) => {
pendingConsentResolvers.set(requestId, resolve);
});
}
export function resolveConsent(
requestId: string,
decision: "accept-once" | "accept-always" | "decline",
) {
const resolver = pendingConsentResolvers.get(requestId);
if (resolver) {
pendingConsentResolvers.delete(requestId);
resolver(decision);
}
}
export async function getStoredConsent(
serverId: number,
toolName: string,
): Promise<Consent> {
const rows = await db
.select()
.from(mcpToolConsents)
.where(
and(
eq(mcpToolConsents.serverId, serverId),
eq(mcpToolConsents.toolName, toolName),
),
);
if (rows.length === 0) return "ask";
return (rows[0].consent as Consent) ?? "ask";
}
export async function setStoredConsent(
serverId: number,
toolName: string,
consent: Consent,
): Promise<void> {
const rows = await db
.select()
.from(mcpToolConsents)
.where(
and(
eq(mcpToolConsents.serverId, serverId),
eq(mcpToolConsents.toolName, toolName),
),
);
if (rows.length > 0) {
await db
.update(mcpToolConsents)
.set({ consent })
.where(
and(
eq(mcpToolConsents.serverId, serverId),
eq(mcpToolConsents.toolName, toolName),
),
);
} else {
await db.insert(mcpToolConsents).values({ serverId, toolName, consent });
}
}
export async function requireMcpToolConsent(
event: IpcMainInvokeEvent,
params: {
serverId: number;
serverName: string;
toolName: string;
toolDescription?: string | null;
inputPreview?: string | null;
},
): Promise<boolean> {
const current = await getStoredConsent(params.serverId, params.toolName);
if (current === "always") return true;
if (current === "denied") return false;
// Ask renderer for a decision via event bridge
const requestId = `${params.serverId}:${params.toolName}:${Date.now()}`;
(event.sender as any).send("mcp:tool-consent-request", {
requestId,
...params,
});
const response = await waitForConsent(requestId);
if (response === "accept-always") {
await setStoredConsent(params.serverId, params.toolName, "always");
return true;
}
if (response === "decline") {
return false;
}
return response === "accept-once";
}

View File

@@ -0,0 +1,59 @@
import { db } from "../../db";
import { mcpServers } from "../../db/schema";
import { experimental_createMCPClient, experimental_MCPClient } from "ai";
import { eq } from "drizzle-orm";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
class McpManager {
private static _instance: McpManager;
static get instance(): McpManager {
if (!this._instance) this._instance = new McpManager();
return this._instance;
}
private clients = new Map<number, experimental_MCPClient>();
async getClient(serverId: number): Promise<experimental_MCPClient> {
const existing = this.clients.get(serverId);
if (existing) return existing;
const server = await db
.select()
.from(mcpServers)
.where(eq(mcpServers.id, serverId));
const s = server.find((x) => x.id === serverId);
if (!s) throw new Error(`MCP server not found: ${serverId}`);
let transport: StdioClientTransport | StreamableHTTPClientTransport;
if (s.transport === "stdio") {
const args = s.args ?? [];
const env = s.envJson ?? undefined;
if (!s.command) throw new Error("MCP server command is required");
transport = new StdioClientTransport({
command: s.command,
args,
env,
});
} else if (s.transport === "http") {
if (!s.url) throw new Error("HTTP MCP requires url");
transport = new StreamableHTTPClientTransport(new URL(s.url as string));
} else {
throw new Error(`Unsupported MCP transport: ${s.transport}`);
}
const client = await experimental_createMCPClient({
transport,
});
this.clients.set(serverId, client);
return client;
}
dispose(serverId: number) {
const c = this.clients.get(serverId);
if (c) {
c.close();
this.clients.delete(serverId);
}
}
}
export const mcpManager = McpManager.instance;

View File

@@ -128,7 +128,7 @@ export type RuntimeMode = z.infer<typeof RuntimeModeSchema>;
export const RuntimeMode2Schema = z.enum(["host", "docker"]); export const RuntimeMode2Schema = z.enum(["host", "docker"]);
export type RuntimeMode2 = z.infer<typeof RuntimeMode2Schema>; export type RuntimeMode2 = z.infer<typeof RuntimeMode2Schema>;
export const ChatModeSchema = z.enum(["build", "ask"]); export const ChatModeSchema = z.enum(["build", "ask", "agent"]);
export type ChatMode = z.infer<typeof ChatModeSchema>; export type ChatMode = z.infer<typeof ChatModeSchema>;
export const GitHubSecretsSchema = z.object({ export const GitHubSecretsSchema = z.object({

View File

@@ -3,6 +3,7 @@ import { PostHog } from "posthog-js";
import React from "react"; import React from "react";
import { CustomErrorToast } from "../components/CustomErrorToast"; import { CustomErrorToast } from "../components/CustomErrorToast";
import { InputRequestToast } from "../components/InputRequestToast"; import { InputRequestToast } from "../components/InputRequestToast";
import { McpConsentToast } from "../components/McpConsentToast";
/** /**
* Toast utility functions for consistent notifications across the app * Toast utility functions for consistent notifications across the app
@@ -111,6 +112,29 @@ export const showInputRequest = (
return toastId; return toastId;
}; };
export function showMcpConsentToast(args: {
serverName: string;
toolName: string;
toolDescription?: string | null;
inputPreview?: string | null;
onDecision: (d: "accept-once" | "accept-always" | "decline") => void;
}) {
const toastId = toast.custom(
(t) => (
<McpConsentToast
toastId={t}
serverName={args.serverName}
toolName={args.toolName}
toolDescription={args.toolDescription}
inputPreview={args.inputPreview}
onDecision={args.onDecision}
/>
),
{ duration: Infinity },
);
return toastId;
}
export const showExtraFilesToast = ({ export const showExtraFilesToast = ({
files, files,
error, error,

View File

@@ -24,6 +24,7 @@ import { AutoUpdateSwitch } from "@/components/AutoUpdateSwitch";
import { ReleaseChannelSelector } from "@/components/ReleaseChannelSelector"; import { ReleaseChannelSelector } from "@/components/ReleaseChannelSelector";
import { NeonIntegration } from "@/components/NeonIntegration"; import { NeonIntegration } from "@/components/NeonIntegration";
import { RuntimeModeSelector } from "@/components/RuntimeModeSelector"; import { RuntimeModeSelector } from "@/components/RuntimeModeSelector";
import { ToolsMcpSettings } from "@/components/settings/ToolsMcpSettings";
export default function SettingsPage() { export default function SettingsPage() {
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
@@ -119,6 +120,17 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
{/* Tools (MCP) */}
<div
id="tools-mcp"
className="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6"
>
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
Tools (MCP)
</h2>
<ToolsMcpSettings />
</div>
{/* Experiments Section */} {/* Experiments Section */}
<div <div
id="experiments" id="experiments"

View File

@@ -109,6 +109,16 @@ const validInvokeChannels = [
"restart-dyad", "restart-dyad",
"get-templates", "get-templates",
"portal:migrate-create", "portal:migrate-create",
// MCP
"mcp:list-servers",
"mcp:create-server",
"mcp:update-server",
"mcp:delete-server",
"mcp:list-tools",
"mcp:get-tool-consents",
"mcp:set-tool-consent",
// MCP consent response from renderer to main
"mcp:tool-consent-response",
// Help bot // Help bot
"help:chat:start", "help:chat:start",
"help:chat:cancel", "help:chat:cancel",
@@ -138,6 +148,8 @@ const validReceiveChannels = [
"help:chat:response:chunk", "help:chat:response:chunk",
"help:chat:response:end", "help:chat:response:end",
"help:chat:response:error", "help:chat:response:error",
// MCP consent request from main to renderer
"mcp:tool-consent-request",
] as const; ] as const;
type ValidInvokeChannel = (typeof validInvokeChannels)[number]; type ValidInvokeChannel = (typeof validInvokeChannels)[number];

View File

@@ -450,19 +450,80 @@ IF YOU USE ANY OF THESE TAGS, YOU WILL BE FIRED.
Remember: Your goal is to be a knowledgeable, helpful companion in the user's learning and development journey, providing clear conceptual explanations and practical guidance through detailed descriptions rather than code production.`; Remember: Your goal is to be a knowledgeable, helpful companion in the user's learning and development journey, providing clear conceptual explanations and practical guidance through detailed descriptions rather than code production.`;
const AGENT_MODE_SYSTEM_PROMPT = `
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."**
`;
export const constructSystemPrompt = ({ export const constructSystemPrompt = ({
aiRules, aiRules,
chatMode = "build", chatMode = "build",
}: { }: {
aiRules: string | undefined; aiRules: string | undefined;
chatMode?: "build" | "ask"; chatMode?: "build" | "ask" | "agent";
}) => { }) => {
const systemPrompt = const systemPrompt = getSystemPromptForChatMode(chatMode);
chatMode === "ask" ? ASK_MODE_SYSTEM_PROMPT : BUILD_SYSTEM_PROMPT;
return systemPrompt.replace("[[AI_RULES]]", aiRules ?? DEFAULT_AI_RULES); return systemPrompt.replace("[[AI_RULES]]", aiRules ?? DEFAULT_AI_RULES);
}; };
export const getSystemPromptForChatMode = (
chatMode: "build" | "ask" | "agent",
) => {
if (chatMode === "agent") {
return AGENT_MODE_SYSTEM_PROMPT;
}
if (chatMode === "ask") {
return ASK_MODE_SYSTEM_PROMPT;
}
return BUILD_SYSTEM_PROMPT;
};
export const readAiRules = async (dyadAppPath: string) => { export const readAiRules = async (dyadAppPath: string) => {
const aiRulesPath = path.join(dyadAppPath, "AI_RULES.md"); const aiRulesPath = path.join(dyadAppPath, "AI_RULES.md");
try { try {

View File

@@ -11,7 +11,8 @@ import {
QueryClientProvider, QueryClientProvider,
MutationCache, MutationCache,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { showError } from "./lib/toast"; import { showError, showMcpConsentToast } from "./lib/toast";
import { IpcClient } from "./ipc/ipc_client";
// @ts-ignore // @ts-ignore
console.log("Running in mode:", import.meta.env.MODE); console.log("Running in mode:", import.meta.env.MODE);
@@ -109,6 +110,20 @@ function App() {
}; };
}, []); }, []);
useEffect(() => {
const ipc = IpcClient.getInstance();
const unsubscribe = ipc.onMcpToolConsentRequest((payload) => {
showMcpConsentToast({
serverName: payload.serverName,
toolName: payload.toolName,
toolDescription: payload.toolDescription,
inputPreview: payload.inputPreview,
onDecision: (d) => ipc.respondToMcpConsentRequest(payload.requestId, d),
});
});
return () => unsubscribe();
}, []);
return <RouterProvider router={router} />; return <RouterProvider router={router} />;
} }

50
testing/README.md Normal file
View File

@@ -0,0 +1,50 @@
### Fake stdio MCP server
This directory contains a minimal stdio MCP server for local testing.
- **Tools**:
- **calculator_add**: adds two numbers. Inputs: `a` (number), `b` (number).
- **print_envs**: returns all environment variables visible to the server as pretty JSON.
### Requirements
- **Node 20+** (same as the repo engines)
- Uses the repo dependency `@modelcontextprotocol/sdk` and `zod`
### Launch
- **Via Node**:
```bash
node testing/fake-stdio-mcp-server.mjs
```
- **Via script** (adds a stable entrypoint path):
```bash
testing/run-fake-stdio-mcp-server.sh
```
### Passing environment variables
Environment variables provided when launching (either from your shell or by the app) will be visible to the `print_envs` tool.
```bash
export FOO=bar
export SECRET_TOKEN=example
testing/run-fake-stdio-mcp-server.sh
```
### Integrating with Dyad (stdio MCP)
When adding a stdio MCP server in the app, use:
- **Command**: `testing/run-fake-stdio-mcp-server.sh` (absolute path recommended)
- **Transport**: `stdio`
- **Args**: leave empty (not required)
- **Env**: optional key/values (e.g., `FOO=bar`)
Once connected, you should see the two tools listed:
- `calculator_add`
- `print_envs`

View File

@@ -180,9 +180,46 @@ export default Index;
messageContent = `[[STRING_IS_FINISHED]]";</dyad-write>\nFinished writing file.`; messageContent = `[[STRING_IS_FINISHED]]";</dyad-write>\nFinished writing file.`;
messageContent += "\n\n" + generateDump(req); messageContent += "\n\n" + generateDump(req);
} }
const isToolCall = !!(
lastMessage &&
lastMessage.content &&
lastMessage.content.includes("[call_tool=calculator_add]")
);
let message = {
role: "assistant",
content: messageContent,
} as any;
// Non-streaming response // Non-streaming response
if (!stream) { if (!stream) {
if (isToolCall) {
const toolCallId = `call_${Date.now()}`;
return res.json({
id: `chatcmpl-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: "fake-model",
choices: [
{
index: 0,
message: {
role: "assistant",
tool_calls: [
{
id: toolCallId,
type: "function",
function: {
name: "calculator_add",
arguments: JSON.stringify({ a: 1, b: 2 }),
},
},
],
},
finish_reason: "tool_calls",
},
],
});
}
return res.json({ return res.json({
id: `chatcmpl-${Date.now()}`, id: `chatcmpl-${Date.now()}`,
object: "chat.completion", object: "chat.completion",
@@ -191,10 +228,7 @@ export default Index;
choices: [ choices: [
{ {
index: 0, index: 0,
message: { message,
role: "assistant",
content: messageContent,
},
finish_reason: "stop", finish_reason: "stop",
}, },
], ],
@@ -206,9 +240,73 @@ export default Index;
res.setHeader("Cache-Control", "no-cache"); res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive"); res.setHeader("Connection", "keep-alive");
// Tool call streaming (OpenAI-style)
if (isToolCall) {
const now = Date.now();
const mkChunk = (delta: any, finish: null | string = null) => {
const chunk = {
id: `chatcmpl-${now}`,
object: "chat.completion.chunk",
created: Math.floor(now / 1000),
model: "fake-model",
choices: [
{
index: 0,
delta,
finish_reason: finish,
},
],
};
return `data: ${JSON.stringify(chunk)}\n\n`;
};
// 1) Send role
res.write(mkChunk({ role: "assistant" }));
// 2) Send tool_calls init with id + name + empty args
const toolCallId = `call_${now}`;
res.write(
mkChunk({
tool_calls: [
{
index: 0,
id: toolCallId,
type: "function",
function: {
name: "testing-mcp-server__calculator_add",
arguments: "",
},
},
],
}),
);
// 3) Stream arguments gradually
const args = JSON.stringify({ a: 1, b: 2 });
let i = 0;
const argBatchSize = 6;
const argInterval = setInterval(() => {
if (i < args.length) {
const part = args.slice(i, i + argBatchSize);
i += argBatchSize;
res.write(
mkChunk({
tool_calls: [{ index: 0, function: { arguments: part } }],
}),
);
} else {
// 4) Finalize with finish_reason tool_calls and [DONE]
res.write(mkChunk({}, "tool_calls"));
res.write("data: [DONE]\n\n");
clearInterval(argInterval);
res.end();
}
}, 10);
return;
}
// Split the message into characters to simulate streaming // Split the message into characters to simulate streaming
const message = messageContent; const messageChars = messageContent.split("");
const messageChars = message.split("");
// Stream each character with a delay // Stream each character with a delay
let index = 0; let index = 0;

View File

@@ -0,0 +1,44 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "fake-stdio-mcp",
version: "0.1.0",
});
server.registerTool(
"calculator_add",
{
title: "Calculator Add",
description: "Add two numbers and return the sum",
inputSchema: { a: z.number(), b: z.number() },
},
async ({ a, b }) => {
const sum = a + b;
return {
content: [{ type: "text", text: String(sum) }],
};
},
);
server.registerTool(
"print_envs",
{
title: "Print Envs",
description: "Print the environment variables received by the server",
inputSchema: {},
},
async () => {
const envObject = Object.fromEntries(
Object.entries(process.env).map(([key, value]) => [key, value ?? ""]),
);
const pretty = JSON.stringify(envObject, null, 2);
return {
content: [{ type: "text", text: pretty }],
};
},
);
const transport = new StdioServerTransport();
await server.connect(transport);

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
# Launch the fake stdio MCP server with Node.
# Usage: testing/run-fake-stdio-mcp-server.sh
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NODE_BIN="node"
exec "$NODE_BIN" "$SCRIPT_DIR/fake-stdio-mcp-server.mjs"