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