feat(migration): add McpServerMigrator for v2 data migration (#13303)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
SuYao
2026-03-19 11:03:57 +08:00
committed by GitHub
parent 2b267cb29d
commit 4abf143fc7
15 changed files with 1743 additions and 27 deletions

View File

@@ -0,0 +1,36 @@
CREATE TABLE `mcp_server` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`type` text,
`description` text,
`base_url` text,
`command` text,
`registry_url` text,
`args` text,
`env` text,
`headers` text,
`provider` text,
`provider_url` text,
`logo_url` text,
`tags` text,
`long_running` integer,
`timeout` integer,
`dxt_version` text,
`dxt_path` text,
`reference` text,
`search_key` text,
`config_sample` text,
`disabled_tools` text,
`disabled_auto_approve_tools` text,
`should_config` integer,
`is_active` integer DEFAULT false NOT NULL,
`install_source` text,
`is_trusted` integer,
`trusted_at` integer,
`installed_at` integer,
`created_at` integer,
`updated_at` integer
);
--> statement-breakpoint
CREATE INDEX `mcp_server_name_idx` ON `mcp_server` (`name`);--> statement-breakpoint
CREATE INDEX `mcp_server_is_active_idx` ON `mcp_server` (`is_active`);

View File

@@ -0,0 +1,851 @@
{
"version": "6",
"dialect": "sqlite",
"id": "f283d5d9-7eaa-40ff-8f9f-fbad46e536fd",
"prevId": "a433b120-0ab8-4f3f-9d1d-766b48c216c8",
"tables": {
"app_state": {
"name": "app_state",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"group": {
"name": "group",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"entity_type": {
"name": "entity_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"group_entity_sort_idx": {
"name": "group_entity_sort_idx",
"columns": ["entity_type", "sort_order"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"mcp_server": {
"name": "mcp_server",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"base_url": {
"name": "base_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"command": {
"name": "command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"registry_url": {
"name": "registry_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"args": {
"name": "args",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"env": {
"name": "env",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"headers": {
"name": "headers",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"provider_url": {
"name": "provider_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"logo_url": {
"name": "logo_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"long_running": {
"name": "long_running",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"timeout": {
"name": "timeout",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dxt_version": {
"name": "dxt_version",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dxt_path": {
"name": "dxt_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reference": {
"name": "reference",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"search_key": {
"name": "search_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"config_sample": {
"name": "config_sample",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"disabled_tools": {
"name": "disabled_tools",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"disabled_auto_approve_tools": {
"name": "disabled_auto_approve_tools",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"should_config": {
"name": "should_config",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"install_source": {
"name": "install_source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_trusted": {
"name": "is_trusted",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"trusted_at": {
"name": "trusted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"installed_at": {
"name": "installed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"mcp_server_name_idx": {
"name": "mcp_server_name_idx",
"columns": ["name"],
"isUnique": false
},
"mcp_server_is_active_idx": {
"name": "mcp_server_is_active_idx",
"columns": ["is_active"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"message": {
"name": "message",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"topic_id": {
"name": "topic_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"data": {
"name": "data",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"searchable_text": {
"name": "searchable_text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"siblings_group_id": {
"name": "siblings_group_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"assistant_id": {
"name": "assistant_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"assistant_meta": {
"name": "assistant_meta",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model_id": {
"name": "model_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model_meta": {
"name": "model_meta",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"trace_id": {
"name": "trace_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"stats": {
"name": "stats",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"message_parent_id_idx": {
"name": "message_parent_id_idx",
"columns": ["parent_id"],
"isUnique": false
},
"message_topic_created_idx": {
"name": "message_topic_created_idx",
"columns": ["topic_id", "created_at"],
"isUnique": false
},
"message_trace_id_idx": {
"name": "message_trace_id_idx",
"columns": ["trace_id"],
"isUnique": false
}
},
"foreignKeys": {
"message_topic_id_topic_id_fk": {
"name": "message_topic_id_topic_id_fk",
"tableFrom": "message",
"tableTo": "topic",
"columnsFrom": ["topic_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"message_parent_id_message_id_fk": {
"name": "message_parent_id_message_id_fk",
"tableFrom": "message",
"tableTo": "message",
"columnsFrom": ["parent_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"message_role_check": {
"name": "message_role_check",
"value": "\"message\".\"role\" IN ('user', 'assistant', 'system')"
},
"message_status_check": {
"name": "message_status_check",
"value": "\"message\".\"status\" IN ('pending', 'success', 'error', 'paused')"
}
}
},
"preference": {
"name": "preference",
"columns": {
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'default'"
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"preference_scope_key_pk": {
"columns": ["scope", "key"],
"name": "preference_scope_key_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"entity_tag": {
"name": "entity_tag",
"columns": {
"entity_type": {
"name": "entity_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_id": {
"name": "entity_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tag_id": {
"name": "tag_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"entity_tag_tag_id_idx": {
"name": "entity_tag_tag_id_idx",
"columns": ["tag_id"],
"isUnique": false
}
},
"foreignKeys": {
"entity_tag_tag_id_tag_id_fk": {
"name": "entity_tag_tag_id_tag_id_fk",
"tableFrom": "entity_tag",
"tableTo": "tag",
"columnsFrom": ["tag_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"entity_tag_entity_type_entity_id_tag_id_pk": {
"columns": ["entity_type", "entity_id", "tag_id"],
"name": "entity_tag_entity_type_entity_id_tag_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tag": {
"name": "tag",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"tag_name_unique": {
"name": "tag_name_unique",
"columns": ["name"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"topic": {
"name": "topic",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_name_manually_edited": {
"name": "is_name_manually_edited",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"assistant_id": {
"name": "assistant_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"assistant_meta": {
"name": "assistant_meta",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"prompt": {
"name": "prompt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active_node_id": {
"name": "active_node_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"group_id": {
"name": "group_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"is_pinned": {
"name": "is_pinned",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"pinned_order": {
"name": "pinned_order",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"topic_group_updated_idx": {
"name": "topic_group_updated_idx",
"columns": ["group_id", "updated_at"],
"isUnique": false
},
"topic_group_sort_idx": {
"name": "topic_group_sort_idx",
"columns": ["group_id", "sort_order"],
"isUnique": false
},
"topic_updated_at_idx": {
"name": "topic_updated_at_idx",
"columns": ["updated_at"],
"isUnique": false
},
"topic_is_pinned_idx": {
"name": "topic_is_pinned_idx",
"columns": ["is_pinned", "pinned_order"],
"isUnique": false
},
"topic_assistant_id_idx": {
"name": "topic_assistant_id_idx",
"columns": ["assistant_id"],
"isUnique": false
}
},
"foreignKeys": {
"topic_group_id_group_id_fk": {
"name": "topic_group_id_group_id_fk",
"tableFrom": "topic",
"tableTo": "group",
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -14,6 +14,13 @@
"tag": "0001_futuristic_human_fly",
"version": "6",
"when": 1767455592181
},
{
"idx": 2,
"version": "6",
"when": 1772954746790,
"tag": "0002_tired_glorian",
"breakpoints": true
}
],
"version": "7"

View File

@@ -225,10 +225,14 @@ export const DefaultSharedCache: SharedCacheSchema = {
*/
export type RendererPersistCacheSchema = {
'ui.tab.state': CacheValueTypes.TabsState
'feature.mcp.is_uv_installed': boolean
'feature.mcp.is_bun_installed': boolean
}
export const DefaultRendererPersistCache: RendererPersistCacheSchema = {
'ui.tab.state': { tabs: [], activeTabId: '' }
'ui.tab.state': { tabs: [], activeTabId: '' },
'feature.mcp.is_uv_installed': false,
'feature.mcp.is_bun_installed': false
}
// ============================================================================

View File

@@ -0,0 +1,64 @@
import type { MCPConfigSample } from '@types'
import { sql } from 'drizzle-orm'
import { check, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { createUpdateTimestamps, uuidPrimaryKey } from './_columnHelpers'
/**
* MCP Server table - stores user-configured MCP server definitions
*
* Migrated from Redux state.mcp.servers.
* Runtime flags (isUvInstalled, isBunInstalled) are NOT migrated - they are
* re-detected at runtime and stored via usePersistCache.
*/
export const mcpServerTable = sqliteTable(
'mcp_server',
{
id: uuidPrimaryKey(),
name: text().notNull(),
type: text(),
description: text(),
baseUrl: text(),
command: text(),
registryUrl: text(),
args: text({ mode: 'json' }).$type<string[]>(),
env: text({ mode: 'json' }).$type<Record<string, string>>(),
headers: text({ mode: 'json' }).$type<Record<string, string>>(),
provider: text(),
providerUrl: text(),
logoUrl: text(),
tags: text({ mode: 'json' }).$type<string[]>(),
longRunning: integer({ mode: 'boolean' }),
timeout: integer(),
dxtVersion: text(),
dxtPath: text(),
reference: text(),
searchKey: text(),
configSample: text({ mode: 'json' }).$type<MCPConfigSample>(),
disabledTools: text({ mode: 'json' }).$type<string[]>(),
disabledAutoApproveTools: text({ mode: 'json' }).$type<string[]>(),
shouldConfig: integer({ mode: 'boolean' }),
isActive: integer({ mode: 'boolean' }).notNull().default(false),
installSource: text(),
isTrusted: integer({ mode: 'boolean' }),
trustedAt: integer(),
installedAt: integer(),
...createUpdateTimestamps
},
(t) => [
index('mcp_server_name_idx').on(t.name),
index('mcp_server_is_active_idx').on(t.isActive),
check(
'mcp_server_type_check',
sql`${t.type} IS NULL OR ${t.type} IN ('stdio', 'sse', 'streamableHttp', 'inMemory')`
),
check(
'mcp_server_install_source_check',
sql`${t.installSource} IS NULL OR ${t.installSource} IN ('builtin', 'manual', 'protocol', 'unknown')`
)
]
)
export type McpServerInsert = typeof mcpServerTable.$inferInsert
export type McpServerSelect = typeof mcpServerTable.$inferSelect

View File

@@ -5,6 +5,7 @@
import { dbService } from '@data/db/DbService'
import { appStateTable } from '@data/db/schemas/appState'
import { mcpServerTable } from '@data/db/schemas/mcpServer'
import { messageTable } from '@data/db/schemas/message'
import { preferenceTable } from '@data/db/schemas/preference'
import { topicTable } from '@data/db/schemas/topic'
@@ -210,6 +211,7 @@ export class MigrationEngine {
const tables = [
{ table: messageTable, name: 'message' }, // Must clear before topic (FK reference)
{ table: topicTable, name: 'topic' },
{ table: mcpServerTable, name: 'mcp_server' },
{ table: preferenceTable, name: 'preference' }
// TODO: Add these when tables are created
// { table: assistantTable, name: 'assistant' },
@@ -230,6 +232,7 @@ export class MigrationEngine {
// Messages reference topics, so delete messages first
await db.delete(messageTable)
await db.delete(topicTable)
await db.delete(mcpServerTable)
await db.delete(preferenceTable)
// TODO: Add these when tables are created (in correct order)
// await db.delete(fileTable)

View File

@@ -0,0 +1,179 @@
/**
* MCP Server migrator - migrates MCP servers from Redux to SQLite
*
* Data sources:
* - Redux mcp slice (state.mcp.servers) -> mcp_server table
*
* Skipped fields (runtime/cache, re-detected when MCP settings are accessed):
* - isUvInstalled, isBunInstalled -> usePersistCache
*
* Not migrated (regenerable cache, re-fetched from provider API):
* - Dexie mcp:provider:*:servers (handled in separate PR)
*/
import { type McpServerInsert, mcpServerTable } from '@data/db/schemas/mcpServer'
import { loggerService } from '@logger'
import type { ExecuteResult, PrepareResult, ValidateResult } from '@shared/data/migration/v2/types'
import { sql } from 'drizzle-orm'
import type { MigrationContext } from '../core/MigrationContext'
import { BaseMigrator } from './BaseMigrator'
import { transformMcpServer } from './mappings/McpServerMappings'
const logger = loggerService.withContext('McpServerMigrator')
export class McpServerMigrator extends BaseMigrator {
readonly id = 'mcp_server'
readonly name = 'MCP Server'
readonly description = 'Migrate MCP server configurations from Redux to SQLite'
readonly order = 1.5
private preparedRows: McpServerInsert[] = []
private skippedCount = 0
async prepare(ctx: MigrationContext): Promise<PrepareResult> {
this.preparedRows = []
this.skippedCount = 0
try {
const warnings: string[] = []
const servers = ctx.sources.reduxState.get<unknown[]>('mcp', 'servers') ?? []
if (!Array.isArray(servers)) {
logger.warn('mcp.servers is not an array, skipping')
warnings.push('mcp.servers is not an array')
} else {
const seenIds = new Set<string>()
for (const server of servers) {
const s = server as Record<string, unknown>
if (!s.id || typeof s.id !== 'string') {
this.skippedCount++
warnings.push(`Skipped server without valid id: ${s.name ?? 'unknown'}`)
continue
}
if (seenIds.has(s.id)) {
this.skippedCount++
warnings.push(`Skipped duplicate server id: ${s.id}`)
continue
}
seenIds.add(s.id)
try {
this.preparedRows.push(transformMcpServer(s))
} catch (err) {
this.skippedCount++
warnings.push(`Failed to transform server ${s.id}: ${(err as Error).message}`)
logger.warn(`Skipping server ${s.id}`, err as Error)
}
}
if (this.skippedCount > 0 && this.preparedRows.length === 0 && servers.length > 0) {
return {
success: false,
itemCount: 0,
warnings
}
}
}
logger.info('Preparation completed', {
serverCount: this.preparedRows.length,
skipped: this.skippedCount
})
return {
success: true,
itemCount: this.preparedRows.length,
warnings: warnings.length > 0 ? warnings : undefined
}
} catch (error) {
logger.error('Preparation failed', error as Error)
return {
success: false,
itemCount: 0,
warnings: [error instanceof Error ? error.message : String(error)]
}
}
}
async execute(ctx: MigrationContext): Promise<ExecuteResult> {
if (this.preparedRows.length === 0) {
return { success: true, processedCount: 0 }
}
try {
let processed = 0
const BATCH_SIZE = 100
await ctx.db.transaction(async (tx) => {
for (let i = 0; i < this.preparedRows.length; i += BATCH_SIZE) {
const batch = this.preparedRows.slice(i, i + BATCH_SIZE)
await tx.insert(mcpServerTable).values(batch)
processed += batch.length
}
})
this.reportProgress(100, `Migrated ${processed} items`, {
key: 'migration.progress.migrated_mcp_servers',
params: { processed, total: this.preparedRows.length }
})
logger.info('Execute completed', { processedCount: processed })
return { success: true, processedCount: processed }
} catch (error) {
logger.error('Execute failed', error as Error)
return {
success: false,
processedCount: 0,
error: error instanceof Error ? error.message : String(error)
}
}
}
async validate(ctx: MigrationContext): Promise<ValidateResult> {
try {
const serverResult = await ctx.db.select({ count: sql<number>`count(*)` }).from(mcpServerTable).get()
const serverCount = serverResult?.count ?? 0
const errors: { key: string; message: string }[] = []
if (serverCount !== this.preparedRows.length) {
errors.push({
key: 'count_mismatch',
message: `Expected ${this.preparedRows.length} servers but found ${serverCount}`
})
}
const sample = await ctx.db.select().from(mcpServerTable).limit(3).all()
for (const server of sample) {
if (!server.id || !server.name) {
errors.push({ key: server.id ?? 'unknown', message: 'Missing required field (id or name)' })
}
}
return {
success: errors.length === 0,
errors,
stats: {
sourceCount: this.preparedRows.length,
targetCount: serverCount,
skippedCount: this.skippedCount
}
}
} catch (error) {
logger.error('Validation failed', error as Error)
return {
success: false,
errors: [{ key: 'validation', message: error instanceof Error ? error.message : String(error) }],
stats: {
sourceCount: this.preparedRows.length,
targetCount: 0,
skippedCount: this.skippedCount
}
}
}
}
}

View File

@@ -0,0 +1,74 @@
# McpServerMigrator
Migrates MCP server configurations from Redux to SQLite.
## Data Sources
| Source | Path | Description |
|--------|------|-------------|
| Redux | `state.mcp.servers` | Array of MCPServer objects |
## Target Table
`mcp_server` (defined in `src/main/data/db/schemas/mcpServer.ts`)
## Skipped Fields (Runtime/Cache)
| Field | Reason | V2 Target |
|-------|--------|-----------|
| `isUvInstalled` | Re-detected when MCP settings are accessed | `usePersistCache('feature.mcp.is_uv_installed')` |
| `isBunInstalled` | Re-detected when MCP settings are accessed | `usePersistCache('feature.mcp.is_bun_installed')` |
## Not Migrated (Regenerable Cache)
| Source | Reason | V2 Target |
|--------|--------|-----------|
| Dexie `mcp:provider:*:servers` | Re-fetched from provider API | Handled in separate PR |
## Field Mappings
All MCPServer fields are mapped 1:1 at the Drizzle ORM level (camelCase property names). The underlying SQLite columns use snake_case (e.g., `baseUrl``base_url`), handled automatically by Drizzle:
| Source Field | Target Column | Transform |
|---|---|---|
| `id` | `id` | Direct (PK) |
| `name` | `name` | Direct (NOT NULL) |
| `type` | `type` | Nullable passthrough |
| `description` | `description` | Nullable passthrough |
| `baseUrl` / `url` | `baseUrl` | Falls back from `url` if `baseUrl` is absent (legacy SSE servers) |
| `command` | `command` | Nullable passthrough |
| `registryUrl` | `registryUrl` | Nullable passthrough |
| `args` | `args` | JSON array |
| `env` | `env` | JSON object |
| `headers` | `headers` | JSON object |
| `provider` | `provider` | Nullable passthrough |
| `providerUrl` | `providerUrl` | Nullable passthrough |
| `logoUrl` | `logoUrl` | Nullable passthrough |
| `tags` | `tags` | JSON array |
| `longRunning` | `longRunning` | Nullable boolean |
| `timeout` | `timeout` | Nullable integer |
| `dxtVersion` | `dxtVersion` | Nullable passthrough |
| `dxtPath` | `dxtPath` | Nullable passthrough |
| `reference` | `reference` | Nullable passthrough |
| `searchKey` | `searchKey` | Nullable passthrough |
| `configSample` | `configSample` | JSON object |
| `disabledTools` | `disabledTools` | JSON array |
| `disabledAutoApproveTools` | `disabledAutoApproveTools` | JSON array |
| `shouldConfig` | `shouldConfig` | Nullable boolean |
| `isActive` | `isActive` | Boolean (NOT NULL, default false) |
| `installSource` | `installSource` | Nullable passthrough |
| `isTrusted` | `isTrusted` | Nullable boolean |
| `trustedAt` | `trustedAt` | Nullable integer (timestamp) |
| `installedAt` | `installedAt` | Nullable integer (timestamp) |
## Edge Cases
- **Missing `id`**: Server is skipped with warning
- **Empty `id`**: Server is skipped with warning
- **Duplicate `id`**: Second occurrence is skipped, first is kept
- **Missing `isActive`**: Defaults to `false`
- **`undefined`/`null` optional fields**: Stored as `null` in SQLite
## Execution Order
`order = 1.5` (after PreferencesMigrator=1, before AssistantMigrator=2)

View File

@@ -0,0 +1,277 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ReduxStateReader } from '../../utils/ReduxStateReader'
import { McpServerMigrator } from '../McpServerMigrator'
function createMockContext(reduxData: Record<string, unknown> = {}) {
const reduxState = new ReduxStateReader(reduxData)
return {
sources: {
electronStore: { get: vi.fn() },
reduxState,
dexieExport: { readTable: vi.fn(), createStreamReader: vi.fn(), tableExists: vi.fn() },
dexieSettings: { keys: vi.fn().mockReturnValue([]), get: vi.fn() }
},
db: {
transaction: vi.fn(async (fn: (tx: any) => Promise<void>) => {
const tx = {
insert: vi.fn().mockReturnValue({
values: vi.fn().mockResolvedValue(undefined)
})
}
await fn(tx)
return tx
}),
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
get: vi.fn().mockResolvedValue({ count: 0 })
})
})
},
sharedData: new Map(),
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn()
}
}
}
const SAMPLE_SERVERS = [
{
id: 'srv-1',
name: '@cherry/fetch',
type: 'inMemory',
isActive: true,
provider: 'CherryAI',
installSource: 'builtin',
isTrusted: true
},
{
id: 'srv-2',
name: 'custom-server',
type: 'stdio',
command: 'npx',
args: ['-y', 'my-mcp-server'],
env: { API_KEY: 'test' },
isActive: false,
installSource: 'manual'
},
{
id: 'srv-3',
name: 'sse-server',
type: 'sse',
baseUrl: 'http://localhost:8080',
isActive: true,
installSource: 'protocol'
}
]
describe('McpServerMigrator', () => {
let migrator: McpServerMigrator
beforeEach(() => {
migrator = new McpServerMigrator()
migrator.setProgressCallback(vi.fn())
})
it('should have correct metadata', () => {
expect(migrator.id).toBe('mcp_server')
expect(migrator.name).toBe('MCP Server')
expect(migrator.order).toBe(1.5)
})
describe('prepare', () => {
it('should count source servers', async () => {
const ctx = createMockContext({ mcp: { servers: SAMPLE_SERVERS } })
const result = await migrator.prepare(ctx as any)
expect(result).toStrictEqual({ success: true, itemCount: 3, warnings: undefined })
})
it('should handle empty servers array', async () => {
const ctx = createMockContext({ mcp: { servers: [] } })
const result = await migrator.prepare(ctx as any)
expect(result).toStrictEqual({ success: true, itemCount: 0, warnings: undefined })
})
it('should handle missing mcp category', async () => {
const ctx = createMockContext({})
const result = await migrator.prepare(ctx as any)
expect(result).toStrictEqual({ success: true, itemCount: 0, warnings: undefined })
})
it('should handle missing servers key', async () => {
const ctx = createMockContext({ mcp: {} })
const result = await migrator.prepare(ctx as any)
expect(result).toStrictEqual({ success: true, itemCount: 0, warnings: undefined })
})
it('should handle non-array servers value', async () => {
const ctx = createMockContext({ mcp: { servers: 'not-an-array' } })
const result = await migrator.prepare(ctx as any)
expect(result).toStrictEqual({
success: true,
itemCount: 0,
warnings: ['mcp.servers is not an array']
})
})
it('should fail when all servers are skipped', async () => {
const servers = [
{ name: 'no-id-1', isActive: true },
{ name: 'no-id-2', isActive: false }
]
const ctx = createMockContext({ mcp: { servers } })
const result = await migrator.prepare(ctx as any)
expect(result.success).toBe(false)
expect(result.itemCount).toBe(0)
})
it('should filter out servers without id', async () => {
const servers = [
{ id: 'srv-1', name: 'valid', isActive: true },
{ name: 'no-id', isActive: false },
{ id: '', name: 'empty-id', isActive: false }
]
const ctx = createMockContext({ mcp: { servers } })
const result = await migrator.prepare(ctx as any)
expect(result).toStrictEqual({
success: true,
itemCount: 1,
warnings: ['Skipped server without valid id: no-id', 'Skipped server without valid id: empty-id']
})
})
it('should deduplicate servers by id', async () => {
const servers = [
{ id: 'dup-1', name: 'first', isActive: true },
{ id: 'dup-1', name: 'duplicate', isActive: false },
{ id: 'srv-2', name: 'unique', isActive: true }
]
const ctx = createMockContext({ mcp: { servers } })
const result = await migrator.prepare(ctx as any)
expect(result).toStrictEqual({
success: true,
itemCount: 2,
warnings: ['Skipped duplicate server id: dup-1']
})
})
})
describe('execute', () => {
it('should insert servers into database', async () => {
const ctx = createMockContext({ mcp: { servers: SAMPLE_SERVERS } })
await migrator.prepare(ctx as any)
const result = await migrator.execute(ctx as any)
expect(result).toStrictEqual({ success: true, processedCount: 3 })
expect(ctx.db.transaction).toHaveBeenCalled()
})
it('should handle empty servers gracefully', async () => {
const ctx = createMockContext({ mcp: { servers: [] } })
await migrator.prepare(ctx as any)
const result = await migrator.execute(ctx as any)
expect(result).toStrictEqual({ success: true, processedCount: 0 })
})
it('should return failure when transaction throws', async () => {
const ctx = createMockContext({ mcp: { servers: SAMPLE_SERVERS } })
ctx.db.transaction = vi.fn().mockRejectedValue(new Error('SQLITE_CONSTRAINT'))
await migrator.prepare(ctx as any)
const result = await migrator.execute(ctx as any)
expect(result.success).toBe(false)
expect(result.error).toContain('SQLITE_CONSTRAINT')
expect(result.processedCount).toBe(0)
})
})
describe('validate', () => {
function mockValidateDb(ctx: ReturnType<typeof createMockContext>, count: number, sample: any[] = []) {
ctx.db.select = vi.fn().mockImplementation((arg) => {
if (arg) {
// count query: select({ count: ... }).from().get()
return {
from: vi.fn().mockReturnValue({
get: vi.fn().mockResolvedValue({ count })
})
}
}
// sample query: select().from().limit().all()
return {
from: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue({
all: vi.fn().mockResolvedValue(sample)
})
})
}
})
}
it('should pass when counts match and sample is valid', async () => {
const ctx = createMockContext({ mcp: { servers: SAMPLE_SERVERS } })
const sampleRows = SAMPLE_SERVERS.map((s) => ({ id: s.id, name: s.name }))
mockValidateDb(ctx, 3, sampleRows)
await migrator.prepare(ctx as any)
const result = await migrator.validate(ctx as any)
expect(result).toStrictEqual({
success: true,
errors: [],
stats: { sourceCount: 3, targetCount: 3, skippedCount: 0 }
})
})
it('should fail when sample has missing required fields', async () => {
const ctx = createMockContext({ mcp: { servers: SAMPLE_SERVERS } })
mockValidateDb(ctx, 3, [
{ id: '', name: 'test' },
{ id: 'srv-2', name: '' }
])
await migrator.prepare(ctx as any)
const result = await migrator.validate(ctx as any)
expect(result.success).toBe(false)
expect(result.errors).toHaveLength(2)
})
it('should pass with zero items', async () => {
const ctx = createMockContext({})
mockValidateDb(ctx, 0, [])
await migrator.prepare(ctx as any)
const result = await migrator.validate(ctx as any)
expect(result).toStrictEqual({
success: true,
errors: [],
stats: { sourceCount: 0, targetCount: 0, skippedCount: 0 }
})
})
it('should fail on count mismatch', async () => {
const ctx = createMockContext({ mcp: { servers: SAMPLE_SERVERS } })
mockValidateDb(ctx, 2, [
{ id: 'srv-1', name: 'test1' },
{ id: 'srv-2', name: 'test2' }
])
await migrator.prepare(ctx as any)
const result = await migrator.validate(ctx as any)
expect(result.success).toBe(false)
expect(result.errors).toContainEqual(expect.objectContaining({ key: 'count_mismatch' }))
})
it('should return failure when db throws', async () => {
const ctx = createMockContext({ mcp: { servers: SAMPLE_SERVERS } })
ctx.db.select = vi.fn().mockImplementation(() => {
throw new Error('DB_CORRUPT')
})
await migrator.prepare(ctx as any)
const result = await migrator.validate(ctx as any)
expect(result.success).toBe(false)
expect(result.errors[0].message).toContain('DB_CORRUPT')
})
})
})

View File

@@ -8,14 +8,21 @@ export { BaseMigrator } from './BaseMigrator'
import { AssistantMigrator } from './AssistantMigrator'
import { ChatMigrator } from './ChatMigrator'
import { KnowledgeMigrator } from './KnowledgeMigrator'
import { McpServerMigrator } from './McpServerMigrator'
import { PreferencesMigrator } from './PreferencesMigrator'
// Export migrator classes
export { AssistantMigrator, ChatMigrator, KnowledgeMigrator, PreferencesMigrator }
export { AssistantMigrator, ChatMigrator, KnowledgeMigrator, McpServerMigrator, PreferencesMigrator }
/**
* Get all registered migrators in execution order
*/
export function getAllMigrators() {
return [new PreferencesMigrator(), new AssistantMigrator(), new KnowledgeMigrator(), new ChatMigrator()]
return [
new PreferencesMigrator(),
new McpServerMigrator(),
new AssistantMigrator(),
new KnowledgeMigrator(),
new ChatMigrator()
]
}

View File

@@ -0,0 +1,49 @@
/**
* MCP Server migration mappings and transform functions
*
* Transforms legacy Redux MCPServer objects to SQLite mcp_server table rows.
*/
import type { McpServerInsert } from '@data/db/schemas/mcpServer'
function toNullable<T>(value: unknown): T | null {
return (value ?? null) as T | null
}
function toRequired<T>(value: unknown, fallback: T): T {
return (value ?? fallback) as T
}
export function transformMcpServer(source: Record<string, unknown>): McpServerInsert {
return {
id: toRequired<string>(source.id, ''),
name: toRequired<string>(source.name, ''),
type: toNullable(source.type),
description: toNullable(source.description),
baseUrl: toNullable(source.baseUrl ?? source.url),
command: toNullable(source.command),
registryUrl: toNullable(source.registryUrl),
args: toNullable(source.args),
env: toNullable(source.env),
headers: toNullable(source.headers),
provider: toNullable(source.provider),
providerUrl: toNullable(source.providerUrl),
logoUrl: toNullable(source.logoUrl),
tags: toNullable(source.tags),
longRunning: toNullable(source.longRunning),
timeout: toNullable(source.timeout),
dxtVersion: toNullable(source.dxtVersion),
dxtPath: toNullable(source.dxtPath),
reference: toNullable(source.reference),
searchKey: toNullable(source.searchKey),
configSample: toNullable(source.configSample),
disabledTools: toNullable(source.disabledTools),
disabledAutoApproveTools: toNullable(source.disabledAutoApproveTools),
shouldConfig: toNullable(source.shouldConfig),
isActive: toRequired(source.isActive, false),
installSource: toNullable(source.installSource),
isTrusted: toNullable(source.isTrusted),
trustedAt: toNullable(source.trustedAt),
installedAt: toNullable(source.installedAt)
}
}

View File

@@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest'
import { transformMcpServer } from '../McpServerMappings'
const NULL_FIELDS = {
type: null,
description: null,
baseUrl: null,
command: null,
registryUrl: null,
args: null,
env: null,
headers: null,
provider: null,
providerUrl: null,
logoUrl: null,
tags: null,
longRunning: null,
timeout: null,
dxtVersion: null,
dxtPath: null,
reference: null,
searchKey: null,
configSample: null,
disabledTools: null,
disabledAutoApproveTools: null,
shouldConfig: null,
installSource: null,
isTrusted: null,
trustedAt: null,
installedAt: null
}
describe('McpServerMappings', () => {
describe('transformMcpServer', () => {
it('should transform a full MCPServer record', () => {
const source = {
id: 'srv-1',
name: '@cherry/fetch',
type: 'inMemory',
description: 'Fetch tool',
baseUrl: 'http://localhost:3000',
command: 'npx',
registryUrl: 'https://registry.example.com',
args: ['-y', 'some-package'],
env: { API_KEY: 'key123' },
headers: { Authorization: 'Bearer token' },
provider: 'CherryAI',
providerUrl: 'https://cherry.ai',
logoUrl: 'https://cherry.ai/logo.png',
tags: ['search', 'web'],
longRunning: true,
timeout: 120,
dxtVersion: '1.0.0',
dxtPath: '/path/to/dxt',
reference: 'https://docs.example.com',
searchKey: 'fetch-tool',
configSample: { command: 'npx', args: ['-y', 'some-package'], env: { API_KEY: 'key123' } },
disabledTools: ['tool1'],
disabledAutoApproveTools: ['tool2'],
shouldConfig: true,
isActive: true,
installSource: 'builtin',
isTrusted: true,
trustedAt: 1700000000000,
installedAt: 1699000000000
}
expect(transformMcpServer(source)).toStrictEqual(source)
})
it('should handle minimal MCPServer (only required fields)', () => {
expect(transformMcpServer({ id: 'srv-2', name: 'my-server', isActive: false })).toStrictEqual({
...NULL_FIELDS,
id: 'srv-2',
name: 'my-server',
isActive: false
})
})
it('should handle null and undefined optional fields', () => {
const source = {
id: 'srv-3',
name: 'test',
isActive: true,
type: undefined,
description: null,
args: undefined,
env: null
}
expect(transformMcpServer(source as any)).toStrictEqual({
...NULL_FIELDS,
id: 'srv-3',
name: 'test',
isActive: true
})
})
it('should default isActive to false when missing', () => {
expect(transformMcpServer({ id: 'srv-4', name: 'no-active-field' } as any)).toStrictEqual({
...NULL_FIELDS,
id: 'srv-4',
name: 'no-active-field',
isActive: false
})
})
it('should preserve empty arrays', () => {
expect(
transformMcpServer({
id: 'srv-5',
name: 'empty-arrays',
isActive: false,
args: [],
tags: [],
disabledTools: []
})
).toStrictEqual({
...NULL_FIELDS,
id: 'srv-5',
name: 'empty-arrays',
isActive: false,
args: [],
tags: [],
disabledTools: []
})
})
it('should fall back from url to baseUrl for SSE servers', () => {
const result = transformMcpServer({
id: 'sse-1',
name: 'sse-server',
isActive: true,
url: 'http://localhost:8080/sse'
})
expect(result.baseUrl).toBe('http://localhost:8080/sse')
})
it('should prefer baseUrl over url when both present', () => {
const result = transformMcpServer({
id: 'sse-2',
name: 'sse-server',
isActive: true,
baseUrl: 'http://primary:8080',
url: 'http://fallback:8080'
})
expect(result.baseUrl).toBe('http://primary:8080')
})
it('should preserve empty objects', () => {
expect(
transformMcpServer({ id: 'srv-6', name: 'empty-objects', isActive: false, env: {}, headers: {} })
).toStrictEqual({
...NULL_FIELDS,
id: 'srv-6',
name: 'empty-objects',
isActive: false,
env: {},
headers: {}
})
})
})
})

View File

@@ -5,14 +5,14 @@ import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import ModelSelector from '@renderer/components/ModelSelector'
import { isMac, isWin } from '@renderer/config/constant'
import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models'
import { usePersistCache } from '@renderer/data/hooks/useCache'
import { useCodeTools } from '@renderer/hooks/useCodeTools'
import { useProviders } from '@renderer/hooks/useProvider'
import { useTimer } from '@renderer/hooks/useTimer'
import { getAssistantSettings, getProviderByModel } from '@renderer/services/AssistantService'
import { loggerService } from '@renderer/services/LoggerService'
import { getModelUniqId } from '@renderer/services/ModelService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setIsBunInstalled } from '@renderer/store/mcp'
import { useAppSelector } from '@renderer/store'
import type { EndpointType, Model } from '@renderer/types'
import type { TerminalConfig } from '@shared/config/constant'
import { codeTools, terminalApps } from '@shared/config/constant'
@@ -38,8 +38,7 @@ const logger = loggerService.withContext('CodeToolsPage')
const CodeToolsPage: FC = () => {
const { t } = useTranslation()
const { providers } = useProviders()
const dispatch = useAppDispatch()
const isBunInstalled = useAppSelector((state) => state.mcp.isBunInstalled)
const [isBunInstalled, setIsBunInstalled] = usePersistCache('feature.mcp.is_bun_installed')
const {
selectedCliTool,
selectedModel,
@@ -181,12 +180,12 @@ const CodeToolsPage: FC = () => {
const checkBunInstallation = useCallback(async () => {
try {
const bunExists = await window.api.isBinaryExist('bun')
dispatch(setIsBunInstalled(bunExists))
setIsBunInstalled(bunExists)
} catch (error) {
logger.error('Failed to check bun installation status:', error as Error)
dispatch(setIsBunInstalled(false))
// IPC failure — leave previous value unchanged
}
}, [dispatch])
}, [setIsBunInstalled])
// 获取可用终端
const loadAvailableTerminals = useCallback(async () => {
@@ -213,7 +212,7 @@ const CodeToolsPage: FC = () => {
try {
setIsInstallingBun(true)
await window.api.installBunBinary()
dispatch(setIsBunInstalled(true))
setIsBunInstalled(true)
window.toast.success(t('settings.mcp.installSuccess'))
} catch (error: any) {
logger.error('Failed to install bun:', error as Error)

View File

@@ -1,8 +1,7 @@
import { CheckCircleOutlined, QuestionCircleOutlined, WarningOutlined } from '@ant-design/icons'
import { Center, ColFlex } from '@cherrystudio/ui'
import { Button } from '@cherrystudio/ui'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setIsBunInstalled, setIsUvInstalled } from '@renderer/store/mcp'
import { usePersistCache } from '@renderer/data/hooks/useCache'
import { useNavigate } from '@tanstack/react-router'
import { Alert } from 'antd'
import type { FC } from 'react'
@@ -17,9 +16,8 @@ interface Props {
}
const InstallNpxUv: FC<Props> = ({ mini = false }) => {
const dispatch = useAppDispatch()
const isUvInstalled = useAppSelector((state) => state.mcp.isUvInstalled)
const isBunInstalled = useAppSelector((state) => state.mcp.isBunInstalled)
const [isUvInstalled, setIsUvInstalled] = usePersistCache('feature.mcp.is_uv_installed')
const [isBunInstalled, setIsBunInstalled] = usePersistCache('feature.mcp.is_bun_installed')
const [isInstallingUv, setIsInstallingUv] = useState(false)
const [isInstallingBun, setIsInstallingBun] = useState(false)
@@ -38,23 +36,27 @@ const InstallNpxUv: FC<Props> = ({ mini = false }) => {
}, [])
const checkBinaries = useCallback(async () => {
const uvExists = await window.api.isBinaryExist('uv')
const bunExists = await window.api.isBinaryExist('bun')
const { uvPath, bunPath, dir } = await window.api.mcp.getInstallInfo()
try {
const uvExists = await window.api.isBinaryExist('uv')
const bunExists = await window.api.isBinaryExist('bun')
const { uvPath, bunPath, dir } = await window.api.mcp.getInstallInfo()
dispatch(setIsUvInstalled(uvExists))
dispatch(setIsBunInstalled(bunExists))
setUvPath(uvPath)
setBunPath(bunPath)
setBinariesDir(dir)
}, [dispatch])
setIsUvInstalled(uvExists)
setIsBunInstalled(bunExists)
setUvPath(uvPath)
setBunPath(bunPath)
setBinariesDir(dir)
} catch {
// IPC failure — leave previous values unchanged
}
}, [setIsUvInstalled, setIsBunInstalled])
const installUV = async () => {
try {
setIsInstallingUv(true)
await window.api.installUVBinary()
setIsInstallingUv(false)
dispatch(setIsUvInstalled(true))
setIsUvInstalled(true)
} catch (error: any) {
window.toast.error(`${t('settings.mcp.installError')}: ${error.message}`)
setIsInstallingUv(false)
@@ -68,7 +70,7 @@ const InstallNpxUv: FC<Props> = ({ mini = false }) => {
setIsInstallingBun(true)
await window.api.installBunBinary()
setIsInstallingBun(false)
dispatch(setIsBunInstalled(true))
setIsBunInstalled(true)
} catch (error: any) {
window.toast.error(`${t('settings.mcp.installError')}: ${error.message}`)
setIsInstallingBun(false)

View File

@@ -11,6 +11,7 @@ const SLICES_TO_EXPORT = [
'assistants', // Assistant configurations
'knowledge', // Knowledge base metadata
'llm', // LLM provider and model configurations
'mcp', // MCP server configurations
'note', // Note-related settings
'selectionStore', // Selection assistant settings
'websearch' // Web search configurations