Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/issues/plugin-mcp-lifecycle-isolation/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Plugin MCP Lifecycle Isolation Plan

## Architecture

- Treat plugin MCP servers as managed plugin resources when `ownerPluginId` is present or
`source/sourceId` identifies a plugin.
- Keep plugin-owned server configs in the MCP config store for compatibility, but branch lifecycle
behavior in `McpPresenter`.
- Store transient per-server runtime errors in `ServerManager` and expose them through
`IMCPPresenter.getServerLastError`.

## Implementation

- Update MCP initialization and global enable/disable to skip plugin-owned servers when applying the
global switch.
- Start plugin MCP servers from `PluginPresenter` without checking the global MCP setting.
- Filter tools, prompts, and resources so global MCP disabled hides non-plugin results but keeps
plugin-owned results.
- Suppress global MCP error notifications for plugin-owned start/listTools failures.
- Surface plugin MCP errors in plugin list and CUA settings status.

## Test Strategy

- Add main-process tests for global switch isolation and plugin start behavior.
- Add error-notification tests for plugin-owned connection and tool-list failures.
- Add renderer tests for disabled-global MCP state with plugin tools still visible.
- Add CUA settings regression coverage for MCP error state.

## Risks

- Moderate. MCP tools are shared across runtime and UI surfaces, so filtering must happen in the
presenter/store boundaries without changing stored config.
26 changes: 26 additions & 0 deletions docs/issues/plugin-mcp-lifecycle-isolation/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Plugin MCP Lifecycle Isolation

## User Story

As a user with the CUA plugin enabled, I want the plugin MCP runtime to follow plugin state rather
than the global MCP switch, so Computer Use remains available when I disable normal MCP tools and
does not spam global connection failure toasts.

## Acceptance Criteria

- The global MCP switch starts and stops only non-plugin MCP servers.
- Plugin-owned MCP servers are identified by `ownerPluginId` or `source: plugin`.
- Enabled plugin MCP servers start when the plugin is active even if global MCP is disabled.
- Plugin MCP connection and tool-list failures do not show global MCP error toasts.
- Plugin MCP failures are visible from plugin status surfaces.
- DeepChat agent tools can still include plugin MCP tools while global MCP is disabled.

## Non-goals

- Redesigning plugin installation or runtime detection.
- Changing ACP agent MCP selection behavior.
- Migrating existing MCP settings.

## Open Questions

None.
9 changes: 9 additions & 0 deletions docs/issues/plugin-mcp-lifecycle-isolation/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Plugin MCP Lifecycle Isolation Tasks

- [x] Document plugin MCP lifecycle requirements.
- [x] Make MCP presenter lifecycle owner-aware.
- [x] Suppress plugin MCP global notifications and track last error.
- [x] Keep plugin MCP visible in renderer state when global MCP is disabled.
- [x] Surface plugin MCP errors in plugin settings UI.
- [x] Add focused regression coverage.
- [x] Run format, i18n, lint, and focused tests.
7 changes: 7 additions & 0 deletions plugins/cua/settings/assets/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,19 @@ async function refreshStatus() {
const cuaMcp = status.mcpServers?.find((server) => server.serverId === 'cua-driver')
if (!cuaMcp) {
setText(mcpStateNode, 'Unavailable')
setMessage('')
} else if (cuaMcp.lastError) {
setText(mcpStateNode, 'Error')
setMessage(cuaMcp.lastError)
} else if (cuaMcp.running) {
setText(mcpStateNode, 'Running')
setMessage('')
} else if (cuaMcp.enabled) {
setText(mcpStateNode, 'Stopped')
setMessage('')
} else {
setText(mcpStateNode, 'Disabled')
setMessage('')
}
}

Expand Down
73 changes: 53 additions & 20 deletions src/main/presenter/mcpPresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ export class McpPresenter implements IMCPPresenter {
return Boolean(this.configPresenter.getPrivacyModeEnabled())
}

private isPluginOwnedServerConfig(config?: Partial<MCPServerConfig> | null): boolean {
return Boolean(config?.ownerPluginId || config?.source === 'plugin')
}

private async isPluginOwnedServerName(serverName: string): Promise<boolean> {
const servers = await this.configPresenter.getMcpServers()
return this.isPluginOwnedServerConfig(servers[serverName])
}

async initialize() {
if (this.isInitialized) {
return
Expand All @@ -70,9 +79,10 @@ export class McpPresenter implements IMCPPresenter {
}

// Load configuration
const [servers, enabledServers] = await Promise.all([
const [servers, enabledServers, mcpEnabled] = await Promise.all([
this.configPresenter.getMcpServers(),
this.configPresenter.getEnabledMcpServers()
this.configPresenter.getEnabledMcpServers(),
this.configPresenter.getMcpEnabled()
])

// Initialize npm registry (prefer cache if available)
Expand All @@ -90,7 +100,7 @@ export class McpPresenter implements IMCPPresenter {

// Check and start deepchat-inmemory/custom-prompts-server
const customPromptsServerName = 'deepchat-inmemory/custom-prompts-server'
if (servers[customPromptsServerName]) {
if (mcpEnabled && servers[customPromptsServerName]) {
console.log(`[MCP] Attempting to start custom prompts server: ${customPromptsServerName}`)

try {
Expand All @@ -109,7 +119,8 @@ export class McpPresenter implements IMCPPresenter {

if (enabledServers.length > 0) {
for (const serverName of enabledServers) {
if (servers[serverName]) {
const serverConfig = servers[serverName]
if (serverConfig && (mcpEnabled || this.isPluginOwnedServerConfig(serverConfig))) {
console.log(`[MCP] Attempting to start enabled server: ${serverName}`)

try {
Expand Down Expand Up @@ -241,7 +252,11 @@ export class McpPresenter implements IMCPPresenter {

// Get all MCP servers
async getMcpClients(): Promise<McpClient[]> {
const clients = await this.toolManager.getRunningClients()
const enabled = await this.configPresenter.getMcpEnabled()
const servers = await this.configPresenter.getMcpServers()
const clients = (await this.toolManager.getRunningClients()).filter(
(client) => enabled || this.isPluginOwnedServerConfig(servers[client.serverName])
)
const clientsList: McpClient[] = []
for (const client of clients) {
const results: MCPToolDefinition[] = []
Expand Down Expand Up @@ -330,7 +345,12 @@ export class McpPresenter implements IMCPPresenter {
async setMcpServerEnabled(serverName: string, enabled: boolean): Promise<void> {
await this.configPresenter.setMcpServerEnabled(serverName, enabled)

if (!(await this.configPresenter.getMcpEnabled())) {
const servers = await this.configPresenter.getMcpServers()
const serverConfig = servers[serverName]
if (
!this.isPluginOwnedServerConfig(serverConfig) &&
!(await this.configPresenter.getMcpEnabled())
) {
return
}

Expand Down Expand Up @@ -408,12 +428,19 @@ export class McpPresenter implements IMCPPresenter {
// Notify renderer process that server has stopped
eventBus.send(MCP_EVENTS.SERVER_STOPPED, SendTarget.ALL_WINDOWS, serverName)
}

getServerLastError(serverName: string): string | undefined {
return this.serverManager.getServerLastError(serverName)
}

async getAllToolDefinitions(enabledMcpTools?: string[]): Promise<MCPToolDefinition[]> {
const enabled = await this.configPresenter.getMcpEnabled()
const tools = await this.toolManager.getAllToolDefinitions(enabledMcpTools)
if (enabled) {
return await this.toolManager.getAllToolDefinitions(enabledMcpTools)
return tools
}
return []
const servers = await this.configPresenter.getMcpServers()
return tools.filter((tool) => this.isPluginOwnedServerConfig(servers[tool.server.name]))
}

/**
Expand All @@ -422,11 +449,10 @@ export class McpPresenter implements IMCPPresenter {
*/
async getAllPrompts(): Promise<Array<PromptListEntry>> {
const enabled = await this.configPresenter.getMcpEnabled()
if (!enabled) {
return []
}

const clients = await this.toolManager.getRunningClients()
const servers = await this.configPresenter.getMcpServers()
const clients = (await this.toolManager.getRunningClients()).filter(
(client) => enabled || this.isPluginOwnedServerConfig(servers[client.serverName])
)
const promptsList: Array<Prompt & { client: { name: string; icon: string } }> = []

for (const client of clients) {
Expand Down Expand Up @@ -468,11 +494,10 @@ export class McpPresenter implements IMCPPresenter {
Array<ResourceListEntry & { client: { name: string; icon: string } }>
> {
const enabled = await this.configPresenter.getMcpEnabled()
if (!enabled) {
return []
}

const clients = await this.toolManager.getRunningClients()
const servers = await this.configPresenter.getMcpServers()
const clients = (await this.toolManager.getRunningClients()).filter(
(client) => enabled || this.isPluginOwnedServerConfig(servers[client.serverName])
)
const resourcesList: Array<ResourceListEntry & { client: { name: string; icon: string } }> = []

for (const client of clients) {
Expand Down Expand Up @@ -657,8 +682,12 @@ export class McpPresenter implements IMCPPresenter {
await this.configPresenter?.setMcpEnabled(enabled)

if (enabled) {
const servers = await this.configPresenter.getMcpServers()
const enabledServers = await this.configPresenter.getEnabledMcpServers()
for (const serverName of enabledServers) {
if (this.isPluginOwnedServerConfig(servers[serverName])) {
continue
}
try {
await this.startServer(serverName)
} catch (error) {
Expand All @@ -669,7 +698,11 @@ export class McpPresenter implements IMCPPresenter {
}

const runningClients = await this.serverManager.getRunningClients()
const servers = await this.configPresenter.getMcpServers()
for (const client of runningClients) {
if (this.isPluginOwnedServerConfig(servers[client.serverName])) {
continue
}
try {
await this.stopServer(client.serverName)
} catch (error) {
Expand Down Expand Up @@ -712,7 +745,7 @@ export class McpPresenter implements IMCPPresenter {

// For MCP server prompts, check if MCP is enabled
const enabled = await this.configPresenter.getMcpEnabled()
if (!enabled) {
if (!enabled && !(await this.isPluginOwnedServerName(prompt.client.name))) {
throw new Error('MCP functionality is disabled')
}

Expand All @@ -727,7 +760,7 @@ export class McpPresenter implements IMCPPresenter {
*/
async readResource(resource: ResourceListEntry): Promise<Resource> {
const enabled = await this.configPresenter.getMcpEnabled()
if (!enabled) {
if (!enabled && !(await this.isPluginOwnedServerName(resource.client.name))) {
throw new Error('MCP functionality is disabled')
}

Expand Down
29 changes: 26 additions & 3 deletions src/main/presenter/mcpPresenter/serverManager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IConfigPresenter } from '@shared/presenter'
import { IConfigPresenter, MCPServerConfig } from '@shared/presenter'
import { McpClient } from './mcpClient'
import axios from 'axios'
import { proxyConfig } from '@/presenter/proxyConfig'
Expand All @@ -15,6 +15,7 @@ const NPM_REGISTRY_LIST = [

export class ServerManager {
private clients: Map<string, McpClient> = new Map()
private serverLastErrors: Map<string, string> = new Map()
private configPresenter: IConfigPresenter
private npmRegistry: string | null = null
private uvRegistry: string | null = null
Expand All @@ -28,6 +29,23 @@ export class ServerManager {
return Boolean(this.configPresenter.getPrivacyModeEnabled())
}

private isPluginOwnedServerConfig(config?: Partial<MCPServerConfig> | null): boolean {
return Boolean(config?.ownerPluginId || config?.source === 'plugin')
}

getServerLastError(serverName: string): string | undefined {
return this.serverLastErrors.get(serverName)
}

setServerLastError(serverName: string, error: unknown): void {
const message = error instanceof Error ? error.message : String(error || 'Unknown error')
this.serverLastErrors.set(serverName, message)
}

clearServerLastError(serverName: string): void {
this.serverLastErrors.delete(serverName)
}

loadRegistryFromCache(): void {
const effectiveRegistry = this.configPresenter.getEffectiveNpmRegistry?.()
if (effectiveRegistry) {
Expand Down Expand Up @@ -229,14 +247,18 @@ export class ServerManager {

// Connect to server, this will start the service
await client.connect()
this.clearServerLastError(name)
} catch (error) {
console.error(`Failed to start MCP server ${name}:`, error)

// Remove client reference
this.clients.delete(name)
this.setServerLastError(name, error)

// Send global error notification
this.sendMcpConnectionError(name, error)
if (!this.isPluginOwnedServerConfig(serverConfig)) {
// Send global error notification only for normal MCP servers.
this.sendMcpConnectionError(name, error)
}

throw error
} finally {
Expand Down Expand Up @@ -282,6 +304,7 @@ export class ServerManager {

// Remove from client list
this.clients.delete(name)
this.clearServerLastError(name)

console.info(`MCP server ${name} has been stopped`)
eventBus.send(MCP_EVENTS.CLIENT_LIST_UPDATED, SendTarget.ALL_WINDOWS)
Expand Down
Loading