From 5e781dd0e49d514eb29669e50b277d3f6dcadca0 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 11:06:48 -0600 Subject: [PATCH 01/25] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20MCP=20server?= =?UTF-8?q?=20configuration=20and=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lock | 5 + package.json | 1 + src/browser/components/ChatInput/index.tsx | 73 +++++++++ .../components/Settings/SettingsModal.tsx | 9 +- .../sections/ProjectSettingsSection.tsx | 103 +++++++++++++ src/browser/utils/slashCommands/registry.ts | 34 +++++ src/browser/utils/slashCommands/types.ts | 3 + src/cli/cli.test.ts | 2 + src/cli/server.test.ts | 2 + src/cli/server.ts | 2 + src/common/orpc/schemas.ts | 3 + src/common/orpc/schemas/api.ts | 15 ++ src/common/orpc/schemas/mcp.ts | 14 ++ src/common/types/mcp.ts | 5 + src/common/utils/tools/tools.ts | 7 +- src/desktop/main.ts | 2 + src/node/orpc/context.ts | 4 + src/node/orpc/router.ts | 18 +++ src/node/services/aiService.ts | 29 +++- src/node/services/mcpConfigService.ts | 96 ++++++++++++ src/node/services/mcpServerManager.ts | 129 ++++++++++++++++ src/node/services/mcpStdioTransport.ts | 144 ++++++++++++++++++ src/node/services/serviceContainer.ts | 8 + src/node/services/workspaceService.ts | 11 ++ tests/ipc/setup.ts | 2 + 25 files changed, 715 insertions(+), 6 deletions(-) create mode 100644 src/browser/components/Settings/sections/ProjectSettingsSection.tsx create mode 100644 src/common/orpc/schemas/mcp.ts create mode 100644 src/common/types/mcp.ts create mode 100644 src/node/services/mcpConfigService.ts create mode 100644 src/node/services/mcpServerManager.ts create mode 100644 src/node/services/mcpStdioTransport.ts diff --git a/bun.lock b/bun.lock index 1117a5d002..c319e107f8 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@ai-sdk/amazon-bedrock": "^3.0.61", "@ai-sdk/anthropic": "^2.0.47", "@ai-sdk/google": "^2.0.43", + "@ai-sdk/mcp": "^0.0.11", "@ai-sdk/openai": "^2.0.72", "@ai-sdk/xai": "^2.0.36", "@aws-sdk/credential-providers": "^3.940.0", @@ -175,6 +176,8 @@ "@ai-sdk/google": ["@ai-sdk/google@2.0.44", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-c5dck36FjqiVoeeMJQLTEmUheoURcGTU/nBT6iJu8/nZiKFT/y8pD85KMDRB7RerRYaaQOtslR2d6/5PditiRw=="], + "@ai-sdk/mcp": ["@ai-sdk/mcp@0.0.11", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18", "pkce-challenge": "^5.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-n0oZsxhPdMaXhAn6LrpMpxABufmeSatfhR3epbrCjJcLEHPOucKwQciwE8CTgIGgwBxHNy1FFLZmcFI77JmJfg=="], + "@ai-sdk/openai": ["@ai-sdk/openai@2.0.76", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ryUkhTDVxe3D1GSAGc94vPZsJlSY8ZuBDLkpf4L81Dm7Ik5AgLfhQrZa8+0hD4kp0dxdVaIoxhpa3QOt1CmncA=="], "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.28", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yKubDxLYtXyGUzkr9lNStf/lE/I+Okc8tmotvyABhsQHHieLKk6oV5fJeRJxhr67Ejhg+FRnwUOxAmjRoFM4dA=="], @@ -2989,6 +2992,8 @@ "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="], "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], diff --git a/package.json b/package.json index 143256e1d7..1a1f6fcb0e 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@ai-sdk/amazon-bedrock": "^3.0.61", "@ai-sdk/anthropic": "^2.0.47", "@ai-sdk/google": "^2.0.43", + "@ai-sdk/mcp": "^0.0.11", "@ai-sdk/openai": "^2.0.72", "@ai-sdk/xai": "^2.0.36", "@aws-sdk/credential-providers": "^3.940.0", diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 66adf09fd5..eb6b20fe7a 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -14,6 +14,8 @@ import { ChatInputToast } from "../ChatInputToast"; import { createCommandToast, createErrorToast } from "../ChatInputToasts"; import { parseCommand } from "@/browser/utils/slashCommands/parser"; import { usePersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { useSettings } from "@/browser/contexts/SettingsContext"; +import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { useMode } from "@/browser/contexts/ModeContext"; import { ThinkingSliderComponent } from "../ThinkingSlider"; import { ModelSettings } from "../ModelSettings"; @@ -167,6 +169,8 @@ export const ChatInput: React.FC = (props) => { [setInput] ); const preEditDraftRef = useRef({ text: "", images: [] }); + const { open } = useSettings(); + const { selectedWorkspace } = useWorkspaceContext(); const [mode, setMode] = useMode(); const { recentModels, addModel, defaultModel, setDefaultModel } = useModelLRU(); const commandListId = useId(); @@ -730,6 +734,75 @@ export const ChatInput: React.FC = (props) => { } // Handle /vim command + if (parsed.type === "mcp-open") { + setInput(""); + open("project"); + return; + } + + if (parsed.type === "mcp-add" || parsed.type === "mcp-remove") { + if (!api) { + setToast({ + id: Date.now().toString(), + type: "error", + message: "Not connected to server", + }); + return; + } + if (!selectedWorkspace?.projectPath) { + setToast({ + id: Date.now().toString(), + type: "error", + message: "Select a workspace to manage MCP servers", + }); + return; + } + + setIsSending(true); + setInput(""); + try { + const projectPath = selectedWorkspace.projectPath; + const result = + parsed.type === "mcp-add" + ? await api.projects.mcp.add({ + projectPath, + name: parsed.name, + command: parsed.command, + }) + : await api.projects.mcp.remove({ projectPath, name: parsed.name }); + + if (!result.success) { + setToast({ + id: Date.now().toString(), + type: "error", + message: result.error ?? "Failed to update MCP servers", + }); + setInput(messageText); + } else { + setToast({ + id: Date.now().toString(), + type: "success", + message: + parsed.type === "mcp-add" + ? `Added MCP server ${parsed.name}` + : `Removed MCP server ${parsed.name}`, + }); + } + } catch (error) { + console.error("Failed to update MCP servers", error); + setToast({ + id: Date.now().toString(), + type: "error", + message: error instanceof Error ? error.message : "Failed to update MCP servers", + }); + setInput(messageText); + } finally { + setIsSending(false); + } + + return; + } + if (parsed.type === "vim-toggle") { setInput(""); // Clear input immediately setVimEnabled((prev) => !prev); diff --git a/src/browser/components/Settings/SettingsModal.tsx b/src/browser/components/Settings/SettingsModal.tsx index 41202f6370..bf22334381 100644 --- a/src/browser/components/Settings/SettingsModal.tsx +++ b/src/browser/components/Settings/SettingsModal.tsx @@ -1,11 +1,12 @@ import React from "react"; -import { Settings, Key, Cpu, X } from "lucide-react"; +import { Settings, Key, Cpu, X, Briefcase } from "lucide-react"; import { useSettings } from "@/browser/contexts/SettingsContext"; import { Dialog, DialogContent, DialogTitle, VisuallyHidden } from "@/browser/components/ui/dialog"; import { GeneralSection } from "./sections/GeneralSection"; import { ProvidersSection } from "./sections/ProvidersSection"; import { ModelsSection } from "./sections/ModelsSection"; import { Button } from "@/browser/components/ui/button"; +import { ProjectSettingsSection } from "./sections/ProjectSettingsSection"; import type { SettingsSection } from "./types"; const SECTIONS: SettingsSection[] = [ @@ -21,6 +22,12 @@ const SECTIONS: SettingsSection[] = [ icon: , component: ProvidersSection, }, + { + id: "project", + label: "Project", + icon: , + component: ProjectSettingsSection, + }, { id: "models", label: "Models", diff --git a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx new file mode 100644 index 0000000000..0e0eddef9e --- /dev/null +++ b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx @@ -0,0 +1,103 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useAPI } from "@/browser/contexts/API"; +import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; +import { Trash2 } from "lucide-react"; + +export const ProjectSettingsSection: React.FC = () => { + const { api } = useAPI(); + const { selectedWorkspace } = useWorkspaceContext(); + const projectPath = selectedWorkspace?.projectPath; + + const [servers, setServers] = useState>({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + if (!api || !projectPath) return; + setLoading(true); + try { + const result = await api.projects.mcp.list({ projectPath }); + setServers(result ?? {}); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load MCP servers"); + } finally { + setLoading(false); + } + }, [api, projectPath]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const handleRemove = useCallback( + async (name: string) => { + if (!api || !projectPath) return; + setLoading(true); + try { + const result = await api.projects.mcp.remove({ projectPath, name }); + if (!result.success) { + setError(result.error ?? "Failed to remove MCP server"); + } else { + await refresh(); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to remove MCP server"); + } finally { + setLoading(false); + } + }, + [api, projectPath, refresh] + ); + + if (!projectPath) { + return ( +

+ Select a workspace to manage project settings. +

+ ); + } + + return ( +
+
+

MCP Servers

+

+ Servers are stored in .mux/mcp.jsonc in this project. Use{" "} + /mcp add to add new entries. +

+
+ + {error &&

{error}

} + + {loading &&

Loading…

} + + {!loading && Object.keys(servers).length === 0 && ( +

No MCP servers configured.

+ )} + +
    + {Object.entries(servers).map(([name, command]) => ( +
  • +
    +
    {name}
    +
    {command}
    +
    + +
  • + ))} +
+
+ ); +}; diff --git a/src/browser/utils/slashCommands/registry.ts b/src/browser/utils/slashCommands/registry.ts index 975d332505..e364acb521 100644 --- a/src/browser/utils/slashCommands/registry.ts +++ b/src/browser/utils/slashCommands/registry.ts @@ -585,6 +585,39 @@ const newCommandDefinition: SlashCommandDefinition = { }, }; +const mcpCommandDefinition: SlashCommandDefinition = { + key: "mcp", + description: "Manage MCP servers for this project", + handler: ({ cleanRemainingTokens, rawInput }) => { + if (cleanRemainingTokens.length === 0) { + return { type: "mcp-open" }; + } + + const sub = cleanRemainingTokens[0]; + if (sub === "add") { + const name = cleanRemainingTokens[1]; + const commandText = rawInput + .trim() + .replace(/^add\s+[^\s]+\s*/i, "") + .trim(); + if (!name || !commandText) { + return { type: "unknown-command", command: "mcp", subcommand: "add" }; + } + return { type: "mcp-add", name, command: commandText }; + } + + if (sub === "remove") { + const name = cleanRemainingTokens[1]; + if (!name) { + return { type: "unknown-command", command: "mcp", subcommand: "remove" }; + } + return { type: "mcp-remove", name }; + } + + return { type: "unknown-command", command: "mcp", subcommand: sub }; + }, +}; + export const SLASH_COMMAND_DEFINITIONS: readonly SlashCommandDefinition[] = [ clearCommandDefinition, truncateCommandDefinition, @@ -595,6 +628,7 @@ export const SLASH_COMMAND_DEFINITIONS: readonly SlashCommandDefinition[] = [ forkCommandDefinition, newCommandDefinition, vimCommandDefinition, + mcpCommandDefinition, ]; export const SLASH_COMMAND_DEFINITION_MAP = new Map( diff --git a/src/browser/utils/slashCommands/types.ts b/src/browser/utils/slashCommands/types.ts index f4a8627564..d3b2245100 100644 --- a/src/browser/utils/slashCommands/types.ts +++ b/src/browser/utils/slashCommands/types.ts @@ -29,6 +29,9 @@ export type ParsedCommand = startMessage?: string; } | { type: "vim-toggle" } + | { type: "mcp-add"; name: string; command: string } + | { type: "mcp-remove"; name: string } + | { type: "mcp-open" } | { type: "unknown-command"; command: string; subcommand?: string } | null; diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index 273238a111..426efe04a0 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -67,6 +67,8 @@ async function createTestServer(authToken?: string): Promise { updateService: services.updateService, tokenizerService: services.tokenizerService, serverService: services.serverService, + mcpConfigService: services.mcpConfigService, + mcpServerManager: services.mcpServerManager, menuEventService: services.menuEventService, voiceService: services.voiceService, telemetryService: services.telemetryService, diff --git a/src/cli/server.test.ts b/src/cli/server.test.ts index d0f2db27f9..dfc9576cb7 100644 --- a/src/cli/server.test.ts +++ b/src/cli/server.test.ts @@ -70,6 +70,8 @@ async function createTestServer(): Promise { updateService: services.updateService, tokenizerService: services.tokenizerService, serverService: services.serverService, + mcpConfigService: services.mcpConfigService, + mcpServerManager: services.mcpServerManager, menuEventService: services.menuEventService, voiceService: services.voiceService, telemetryService: services.telemetryService, diff --git a/src/cli/server.ts b/src/cli/server.ts index 66f5f7c818..6e6849bf1a 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -79,6 +79,8 @@ const mockWindow: BrowserWindow = { tokenizerService: serviceContainer.tokenizerService, serverService: serviceContainer.serverService, menuEventService: serviceContainer.menuEventService, + mcpConfigService: serviceContainer.mcpConfigService, + mcpServerManager: serviceContainer.mcpServerManager, voiceService: serviceContainer.voiceService, telemetryService: serviceContainer.telemetryService, }; diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index 8dea1b3582..379367cefe 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -38,6 +38,9 @@ export { SecretSchema } from "./schemas/secrets"; // Provider options schemas export { MuxProviderOptionsSchema } from "./schemas/providerOptions"; +// MCP schemas +export { MCPServerMapSchema, MCPAddParamsSchema, MCPRemoveParamsSchema } from "./schemas/mcp"; + // Terminal schemas export { TerminalCreateParamsSchema, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index aa7d0011e2..59cba82630 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -15,6 +15,7 @@ import { } from "./terminal"; import { BashToolResultSchema, FileTreeNodeSchema } from "./tools"; import { FrontendWorkspaceMetadataSchema, WorkspaceActivitySnapshotSchema } from "./workspace"; +import { MCPAddParamsSchema, MCPRemoveParamsSchema, MCPServerMapSchema } from "./mcp"; // Re-export telemetry schemas export { telemetry, TelemetryEventSchema } from "./telemetry"; @@ -116,6 +117,20 @@ export const projects = { input: z.object({ projectPath: z.string() }), output: BranchListResultSchema, }, + mcp: { + list: { + input: z.object({ projectPath: z.string() }), + output: MCPServerMapSchema, + }, + add: { + input: MCPAddParamsSchema, + output: ResultSchema(z.void(), z.string()), + }, + remove: { + input: MCPRemoveParamsSchema, + output: ResultSchema(z.void(), z.string()), + }, + }, secrets: { get: { input: z.object({ projectPath: z.string() }), diff --git a/src/common/orpc/schemas/mcp.ts b/src/common/orpc/schemas/mcp.ts new file mode 100644 index 0000000000..7fe7d42b0f --- /dev/null +++ b/src/common/orpc/schemas/mcp.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const MCPServerMapSchema = z.record(z.string(), z.string()); + +export const MCPAddParamsSchema = z.object({ + projectPath: z.string(), + name: z.string(), + command: z.string(), +}); + +export const MCPRemoveParamsSchema = z.object({ + projectPath: z.string(), + name: z.string(), +}); diff --git a/src/common/types/mcp.ts b/src/common/types/mcp.ts new file mode 100644 index 0000000000..15e7c8e0af --- /dev/null +++ b/src/common/types/mcp.ts @@ -0,0 +1,5 @@ +export interface MCPConfig { + servers: Record; +} + +export type MCPServerMap = Record; diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index 3f11ed29c1..d688ebbd2d 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -85,7 +85,8 @@ export async function getToolsForModel( config: ToolConfiguration, workspaceId: string, initStateManager: InitStateManager, - toolInstructions?: Record + toolInstructions?: Record, + mcpTools?: Record ): Promise> { const [provider, modelId] = modelString.split(":"); @@ -129,13 +130,14 @@ export async function getToolsForModel( // Try to add provider-specific web search tools if available // Lazy-load providers to avoid loading all AI SDKs at startup - let allTools = baseTools; + let allTools = { ...baseTools, ...(mcpTools ?? {}) }; try { switch (provider) { case "anthropic": { const { anthropic } = await import("@ai-sdk/anthropic"); allTools = { ...baseTools, + ...(mcpTools ?? {}), // Provider-specific tool types are compatible with Tool at runtime web_search: anthropic.tools.webSearch_20250305({ maxUses: 1000 }) as Tool, }; @@ -148,6 +150,7 @@ export async function getToolsForModel( const { openai } = await import("@ai-sdk/openai"); allTools = { ...baseTools, + ...(mcpTools ?? {}), // Provider-specific tool types are compatible with Tool at runtime web_search: openai.tools.webSearch({ searchContextSize: "high", diff --git a/src/desktop/main.ts b/src/desktop/main.ts index 29fc55206a..d56953d31d 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -332,6 +332,8 @@ async function loadServices(): Promise { updateService: services.updateService, tokenizerService: services.tokenizerService, serverService: services.serverService, + mcpConfigService: services.mcpConfigService, + mcpServerManager: services.mcpServerManager, menuEventService: services.menuEventService, voiceService: services.voiceService, telemetryService: services.telemetryService, diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index c48402a79f..887c497d12 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -11,6 +11,8 @@ import type { TokenizerService } from "@/node/services/tokenizerService"; import type { ServerService } from "@/node/services/serverService"; import type { MenuEventService } from "@/node/services/menuEventService"; import type { VoiceService } from "@/node/services/voiceService"; +import type { MCPConfigService } from "@/node/services/mcpConfigService"; +import type { MCPServerManager } from "@/node/services/mcpServerManager"; import type { TelemetryService } from "@/node/services/telemetryService"; export interface ORPCContext { @@ -26,6 +28,8 @@ export interface ORPCContext { serverService: ServerService; menuEventService: MenuEventService; voiceService: VoiceService; + mcpConfigService: MCPConfigService; + mcpServerManager: MCPServerManager; telemetryService: TelemetryService; headers?: IncomingHttpHeaders; } diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 46b25ad99c..b757dd129d 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -168,6 +168,24 @@ export const router = (authToken?: string) => { return context.projectService.updateSecrets(input.projectPath, input.secrets); }), }, + mcp: { + list: t + .input(schemas.projects.mcp.list.input) + .output(schemas.projects.mcp.list.output) + .handler(({ context, input }) => context.mcpConfigService.listServers(input.projectPath)), + add: t + .input(schemas.projects.mcp.add.input) + .output(schemas.projects.mcp.add.output) + .handler(({ context, input }) => + context.mcpConfigService.addServer(input.projectPath, input.name, input.command) + ), + remove: t + .input(schemas.projects.mcp.remove.input) + .output(schemas.projects.mcp.remove.output) + .handler(({ context, input }) => + context.mcpConfigService.removeServer(input.projectPath, input.name) + ), + }, }, nameGeneration: { generate: t diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index d3294654b0..37c265e24c 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -3,7 +3,7 @@ import * as os from "os"; import { EventEmitter } from "events"; import type { XaiProviderOptions } from "@ai-sdk/xai"; import { fromNodeProviderChain } from "@aws-sdk/credential-providers"; -import { convertToModelMessages, type LanguageModel } from "ai"; +import { convertToModelMessages, type LanguageModel, type Tool } from "ai"; import { applyToolOutputRedaction } from "@/browser/utils/messages/applyToolOutputRedaction"; import { sanitizeToolInputs } from "@/browser/utils/messages/sanitizeToolInput"; import type { Result } from "@/common/types/result"; @@ -36,6 +36,7 @@ import type { HistoryService } from "./historyService"; import type { PartialService } from "./partialService"; import { buildSystemMessage, readToolInstructions } from "./systemMessage"; import { getTokenizerForModel } from "@/node/utils/main/tokenizer"; +import type { MCPServerManager } from "@/node/services/mcpServerManager"; import { buildProviderOptions } from "@/common/utils/ai/providerOptions"; import type { ThinkingLevel } from "@/common/types/thinking"; import type { @@ -240,6 +241,7 @@ export class AIService extends EventEmitter { private readonly historyService: HistoryService; private readonly partialService: PartialService; private readonly config: Config; + private mcpServerManager?: MCPServerManager; private readonly initStateManager: InitStateManager; private readonly mockModeEnabled: boolean; private readonly mockScenarioPlayer?: MockScenarioPlayer; @@ -274,6 +276,10 @@ export class AIService extends EventEmitter { } } + setMCPServerManager(manager: MCPServerManager): void { + this.mcpServerManager = manager; + } + /** * Forward all stream events from StreamManager to AIService consumers */ @@ -879,7 +885,9 @@ export class AIService extends EventEmitter { secrets: {}, }, "", // Empty workspace ID for early stub config - this.initStateManager + this.initStateManager, + undefined, + undefined ); const earlyTools = applyToolPolicy(earlyAllTools, toolPolicy); const toolNamesForSentinel = Object.keys(earlyTools); @@ -993,6 +1001,20 @@ export class AIService extends EventEmitter { // Generate stream token and create temp directory for tools const streamToken = this.streamManager.generateStreamToken(); + let mcpTools: Record | undefined; + if (this.mcpServerManager) { + try { + mcpTools = await this.mcpServerManager.getToolsForWorkspace({ + workspaceId, + projectPath: metadata.projectPath, + runtime, + workspacePath, + }); + } catch (error) { + log.error("Failed to start MCP servers", { workspaceId, error }); + } + } + const runtimeTempDir = await this.streamManager.createTempDirForStream(streamToken, runtime); // Extract tool-specific instructions from AGENTS.md files @@ -1021,7 +1043,8 @@ export class AIService extends EventEmitter { }, workspaceId, this.initStateManager, - toolInstructions + toolInstructions, + mcpTools ); // Apply tool policy to filter tools (if policy provided) diff --git a/src/node/services/mcpConfigService.ts b/src/node/services/mcpConfigService.ts new file mode 100644 index 0000000000..12cdcff782 --- /dev/null +++ b/src/node/services/mcpConfigService.ts @@ -0,0 +1,96 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as jsonc from "jsonc-parser"; +import writeFileAtomic from "write-file-atomic"; +import type { MCPConfig, MCPServerMap } from "@/common/types/mcp"; +import { log } from "@/node/services/log"; +import { Ok, Err } from "@/common/types/result"; +import type { Result } from "@/common/types/result"; + +export class MCPConfigService { + private getConfigPath(projectPath: string): string { + return path.join(projectPath, ".mux", "mcp.jsonc"); + } + + private async pathExists(targetPath: string): Promise { + try { + await fs.promises.access(targetPath, fs.constants.F_OK); + return true; + } catch { + return false; + } + } + + private async ensureProjectDir(projectPath: string): Promise { + const muxDir = path.join(projectPath, ".mux"); + if (!(await this.pathExists(muxDir))) { + await fs.promises.mkdir(muxDir, { recursive: true }); + } + } + + async getConfig(projectPath: string): Promise { + const filePath = this.getConfigPath(projectPath); + try { + const exists = await this.pathExists(filePath); + if (!exists) { + return { servers: {} }; + } + const raw = await fs.promises.readFile(filePath, "utf-8"); + const parsed = jsonc.parse(raw) as MCPConfig | undefined; + if (!parsed || typeof parsed !== "object" || !parsed.servers) { + return { servers: {} }; + } + return { servers: parsed.servers ?? {} }; + } catch (error) { + log.error("Failed to read MCP config", { projectPath, error }); + return { servers: {} }; + } + } + + private async saveConfig(projectPath: string, config: MCPConfig): Promise { + await this.ensureProjectDir(projectPath); + const filePath = this.getConfigPath(projectPath); + await writeFileAtomic(filePath, JSON.stringify(config, null, 2), "utf-8"); + } + + async listServers(projectPath: string): Promise { + const cfg = await this.getConfig(projectPath); + return cfg.servers ?? {}; + } + + async addServer(projectPath: string, name: string, command: string): Promise> { + if (!name.trim()) { + return Err("Server name is required"); + } + if (!command.trim()) { + return Err("Command is required"); + } + + const cfg = await this.getConfig(projectPath); + cfg.servers = cfg.servers ?? {}; + cfg.servers[name] = command; + + try { + await this.saveConfig(projectPath, cfg); + return Ok(undefined); + } catch (error) { + log.error("Failed to save MCP server", { projectPath, name, error }); + return Err(error instanceof Error ? error.message : String(error)); + } + } + + async removeServer(projectPath: string, name: string): Promise> { + const cfg = await this.getConfig(projectPath); + if (!cfg.servers?.[name]) { + return Err(`Server ${name} not found`); + } + delete cfg.servers[name]; + try { + await this.saveConfig(projectPath, cfg); + return Ok(undefined); + } catch (error) { + log.error("Failed to remove MCP server", { projectPath, name, error }); + return Err(error instanceof Error ? error.message : String(error)); + } + } +} diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts new file mode 100644 index 0000000000..db58516551 --- /dev/null +++ b/src/node/services/mcpServerManager.ts @@ -0,0 +1,129 @@ +import { experimental_createMCPClient, type MCPTransport } from "@ai-sdk/mcp"; +import type { Tool } from "ai"; +import { log } from "@/node/services/log"; +import { MCPStdioTransport } from "@/node/services/mcpStdioTransport"; +import type { MCPServerMap } from "@/common/types/mcp"; +import type { Runtime } from "@/node/runtime/Runtime"; +import type { MCPConfigService } from "@/node/services/mcpConfigService"; + +interface MCPServerInstance { + name: string; + transport: MCPTransport; + tools: Record; + close: () => Promise; +} + +interface WorkspaceServers { + configSignature: string; + instances: Map; +} + +export class MCPServerManager { + private readonly workspaceServers = new Map(); + + constructor(private readonly configService: MCPConfigService) {} + + async getToolsForWorkspace(options: { + workspaceId: string; + projectPath: string; + runtime: Runtime; + workspacePath: string; + }): Promise> { + const { workspaceId, projectPath, runtime, workspacePath } = options; + const servers = await this.configService.listServers(projectPath); + const signature = JSON.stringify(servers ?? {}); + + const existing = this.workspaceServers.get(workspaceId); + if (existing?.configSignature === signature) { + return this.collectTools(existing.instances); + } + + // Config changed or not started yet -> restart + await this.stopServers(workspaceId); + const instances = await this.startServers(servers, runtime, workspacePath); + this.workspaceServers.set(workspaceId, { + configSignature: signature, + instances, + }); + return this.collectTools(instances); + } + + async stopServers(workspaceId: string): Promise { + const entry = this.workspaceServers.get(workspaceId); + if (!entry) return; + + for (const instance of entry.instances.values()) { + try { + await instance.close(); + } catch (error) { + log.warn("Failed to stop MCP server", { error, name: instance.name }); + } + } + + this.workspaceServers.delete(workspaceId); + } + + private collectTools(instances: Map): Record { + const aggregated: Record = {}; + for (const instance of instances.values()) { + Object.assign(aggregated, instance.tools); + } + return aggregated; + } + + private async startServers( + servers: MCPServerMap, + runtime: Runtime, + workspacePath: string + ): Promise> { + const result = new Map(); + const entries = Object.entries(servers ?? {}); + for (const [name, command] of entries) { + try { + const instance = await this.startSingleServer(name, command, runtime, workspacePath); + if (instance) { + result.set(name, instance); + } + } catch (error) { + log.error("Failed to start MCP server", { name, error }); + } + } + return result; + } + + private async startSingleServer( + name: string, + command: string, + runtime: Runtime, + workspacePath: string + ): Promise { + const execStream = await runtime.exec(command, { + cwd: workspacePath, + timeout: 60 * 60 * 24, // 24 hours + }); + + const transport = new MCPStdioTransport(execStream); + transport.onerror = (error) => { + log.error("MCP transport error", { name, error }); + }; + + await transport.start(); + const client = await experimental_createMCPClient({ transport }); + const tools = await client.tools(); + + const close = async () => { + try { + await client.close(); + } catch (error) { + log.debug("Error closing MCP client", { name, error }); + } + try { + await transport.close(); + } catch (error) { + log.debug("Error closing MCP transport", { name, error }); + } + }; + + return { name, transport, tools, close }; + } +} diff --git a/src/node/services/mcpStdioTransport.ts b/src/node/services/mcpStdioTransport.ts new file mode 100644 index 0000000000..3d60d9c153 --- /dev/null +++ b/src/node/services/mcpStdioTransport.ts @@ -0,0 +1,144 @@ +import { TextDecoder, TextEncoder } from "util"; +import type { MCPTransport, JSONRPCMessage } from "@ai-sdk/mcp"; +import type { ExecStream } from "@/node/runtime/Runtime"; +import { log } from "@/node/services/log"; + +function findHeaderEnd(buffer: Uint8Array): number { + for (let i = 0; i < buffer.length - 3; i++) { + if (buffer[i] === 13 && buffer[i + 1] === 10 && buffer[i + 2] === 13 && buffer[i + 3] === 10) { + return i; + } + } + return -1; +} + +function concatBuffers(a: Uint8Array, b: Uint8Array): Uint8Array { + const result = new Uint8Array(a.length + b.length); + result.set(a, 0); + result.set(b, a.length); + return result; +} + +/** + * Minimal stdio transport for MCP servers using JSON-RPC over Content-Length framed messages. + */ +export class MCPStdioTransport implements MCPTransport { + private readonly decoder = new TextDecoder(); + private readonly encoder = new TextEncoder(); + private readonly stdoutReader: ReadableStreamDefaultReader; + private readonly stdinWriter: WritableStreamDefaultWriter; + private buffer: Uint8Array = new Uint8Array(0); + private running = false; + private readonly exitPromise: Promise; + + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + + constructor(private readonly execStream: ExecStream) { + this.stdoutReader = execStream.stdout.getReader(); + this.stdinWriter = execStream.stdin.getWriter(); + this.exitPromise = execStream.exitCode; + // Observe process exit to trigger close event + void this.exitPromise.then(() => { + if (this.onclose) this.onclose(); + }); + } + + start(): Promise { + if (this.running) return Promise.resolve(); + this.running = true; + void this.readLoop(); + return Promise.resolve(); + } + + async send(message: JSONRPCMessage): Promise { + const payload = JSON.stringify(message); + const body = this.encoder.encode(payload); + const header = this.encoder.encode(`Content-Length: ${body.length}\r\n\r\n`); + const framed = concatBuffers(header, body); + await this.stdinWriter.write(framed); + } + + async close(): Promise { + try { + await this.stdinWriter.close(); + } catch (error) { + log.debug("Failed to close MCP stdin writer", { error }); + } + try { + await this.stdoutReader.cancel(); + } catch (error) { + log.debug("Failed to cancel MCP stdout reader", { error }); + } + } + + private async readLoop(): Promise { + try { + while (true) { + const { value, done } = await this.stdoutReader.read(); + if (done) break; + if (value) { + const chunk = value; + this.buffer = concatBuffers(this.buffer, chunk); + this.processBuffer(); + } + } + } catch (error) { + if (this.onerror) { + this.onerror(error as Error); + } else { + log.error("MCP stdio transport read error", { error }); + } + } finally { + if (this.onclose) this.onclose(); + } + } + + private processBuffer(): void { + while (true) { + const headerEnd = findHeaderEnd(this.buffer); + if (headerEnd === -1) return; // Need more data + + const headerBytes = this.buffer.slice(0, headerEnd); + const headerText = this.decoder.decode(headerBytes); + const contentLengthMatch = headerText + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.toLowerCase().startsWith("content-length")); + + if (!contentLengthMatch) { + throw new Error("Content-Length header missing in MCP response"); + } + + const [, lengthStr] = contentLengthMatch.split(":"); + const contentLength = parseInt(lengthStr?.trim() ?? "", 10); + if (!Number.isFinite(contentLength)) { + throw new Error("Invalid Content-Length header in MCP response"); + } + + const messageStart = headerEnd + 4; // \r\n\r\n + if (this.buffer.length < messageStart + contentLength) { + return; // Wait for more data + } + + const messageBytes = this.buffer.slice(messageStart, messageStart + contentLength); + const remaining = this.buffer.slice(messageStart + contentLength); + this.buffer = remaining; + + const messageText = this.decoder.decode(messageBytes); + try { + const message = JSON.parse(messageText) as JSONRPCMessage; + if (this.onmessage) { + this.onmessage(message); + } + } catch (error) { + if (this.onerror) { + this.onerror(error as Error); + } else { + log.error("Failed to parse MCP message", { error, messageText }); + } + } + } + } +} diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 03ea24cbeb..e7f25d07cf 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -19,6 +19,8 @@ import { MenuEventService } from "@/node/services/menuEventService"; import { VoiceService } from "@/node/services/voiceService"; import { TelemetryService } from "@/node/services/telemetryService"; import { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; +import { MCPConfigService } from "@/node/services/mcpConfigService"; +import { MCPServerManager } from "@/node/services/mcpServerManager"; /** * ServiceContainer - Central dependency container for all backend services. @@ -41,6 +43,8 @@ export class ServiceContainer { public readonly serverService: ServerService; public readonly menuEventService: MenuEventService; public readonly voiceService: VoiceService; + public readonly mcpConfigService: MCPConfigService; + public readonly mcpServerManager: MCPServerManager; public readonly telemetryService: TelemetryService; private readonly initStateManager: InitStateManager; private readonly extensionMetadata: ExtensionMetadataService; @@ -53,10 +57,12 @@ export class ServiceContainer { this.partialService = new PartialService(config, this.historyService); this.projectService = new ProjectService(config); this.initStateManager = new InitStateManager(config); + this.mcpConfigService = new MCPConfigService(); this.extensionMetadata = new ExtensionMetadataService( path.join(config.rootDir, "extensionMetadata.json") ); this.backgroundProcessManager = new BackgroundProcessManager(); + this.mcpServerManager = new MCPServerManager(this.mcpConfigService); this.aiService = new AIService( config, this.historyService, @@ -64,6 +70,7 @@ export class ServiceContainer { this.initStateManager, this.backgroundProcessManager ); + this.aiService.setMCPServerManager(this.mcpServerManager); this.workspaceService = new WorkspaceService( config, this.historyService, @@ -73,6 +80,7 @@ export class ServiceContainer { this.extensionMetadata, this.backgroundProcessManager ); + this.workspaceService.setMCPServerManager(this.mcpServerManager); this.providerService = new ProviderService(config); // Terminal services - PTYService is cross-platform this.ptyService = new PTYService(); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 5452775922..f9520dce42 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -12,6 +12,7 @@ import type { PartialService } from "@/node/services/partialService"; import type { AIService } from "@/node/services/aiService"; import type { InitStateManager } from "@/node/services/initStateManager"; import type { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService"; +import type { MCPServerManager } from "@/node/services/mcpServerManager"; import { createRuntime, IncompatibleRuntimeError } from "@/node/runtime/runtimeFactory"; import { validateWorkspaceName } from "@/common/utils/validation/workspaceValidation"; @@ -95,6 +96,7 @@ export class WorkspaceService extends EventEmitter { this.setupMetadataListeners(); } + private mcpServerManager?: MCPServerManager; // Optional terminal service for cleanup on workspace removal private terminalService?: TerminalService; @@ -102,6 +104,10 @@ export class WorkspaceService extends EventEmitter { * Set the terminal service for cleanup on workspace removal. * Called after construction due to circular dependency. */ + setMCPServerManager(manager: MCPServerManager): void { + this.mcpServerManager = manager; + } + setTerminalService(terminalService: TerminalService): void { this.terminalService = terminalService; } @@ -447,6 +453,11 @@ export class WorkspaceService extends EventEmitter { log.error(`Failed to remove session directory for ${workspaceId}:`, error); } + // Stop MCP servers for this workspace + if (this.mcpServerManager) { + await this.mcpServerManager.stopServers(workspaceId); + } + // Dispose session this.disposeSession(workspaceId); diff --git a/tests/ipc/setup.ts b/tests/ipc/setup.ts index ca98732518..469d72756f 100644 --- a/tests/ipc/setup.ts +++ b/tests/ipc/setup.ts @@ -79,6 +79,8 @@ export async function createTestEnvironment(): Promise { updateService: services.updateService, tokenizerService: services.tokenizerService, serverService: services.serverService, + mcpConfigService: services.mcpConfigService, + mcpServerManager: services.mcpServerManager, menuEventService: services.menuEventService, voiceService: services.voiceService, telemetryService: services.telemetryService, From 5ec5ffdfc8c3002c1aa0dd67421f4c39b6e77afd Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 11:16:54 -0600 Subject: [PATCH 02/25] add IPC test for MCP config lifecycle --- tests/ipc/mcpConfig.test.ts | 56 +++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 tests/ipc/mcpConfig.test.ts diff --git a/tests/ipc/mcpConfig.test.ts b/tests/ipc/mcpConfig.test.ts new file mode 100644 index 0000000000..39af9939f3 --- /dev/null +++ b/tests/ipc/mcpConfig.test.ts @@ -0,0 +1,56 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import { shouldRunIntegrationTests, cleanupTestEnvironment, createTestEnvironment } from "./setup"; +import { createTempGitRepo, cleanupTempGitRepo, resolveOrpcClient } from "./helpers"; + +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +describeIntegration("MCP project configuration", () => { + test.concurrent("add, list, and remove MCP servers", async () => { + const env = await createTestEnvironment(); + const repoPath = await createTempGitRepo(); + const client = resolveOrpcClient(env); + + try { + // Register project + const createResult = await client.projects.create({ projectPath: repoPath }); + expect(createResult.success).toBe(true); + + // Initially empty + const initial = await client.projects.mcp.list({ projectPath: repoPath }); + expect(initial).toEqual({}); + + // Add server + const addResult = await client.projects.mcp.add({ + projectPath: repoPath, + name: "chrome-devtools", + command: "npx chrome-devtools-mcp@latest", + }); + expect(addResult.success).toBe(true); + + // Should list the added server + const listed = await client.projects.mcp.list({ projectPath: repoPath }); + expect(listed).toEqual({ "chrome-devtools": "npx chrome-devtools-mcp@latest" }); + + // Config file should be written + const configPath = path.join(repoPath, ".mux", "mcp.jsonc"); + const file = await fs.readFile(configPath, "utf-8"); + expect(JSON.parse(file)).toEqual({ + servers: { "chrome-devtools": "npx chrome-devtools-mcp@latest" }, + }); + + // Remove server + const removeResult = await client.projects.mcp.remove({ + projectPath: repoPath, + name: "chrome-devtools", + }); + expect(removeResult.success).toBe(true); + + const finalList = await client.projects.mcp.list({ projectPath: repoPath }); + expect(finalList).toEqual({}); + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(repoPath); + } + }); +}); From 876502534a7b3d8ff31e570ac447780f004b7d2d Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 12:59:07 -0600 Subject: [PATCH 03/25] fix: prevent worker pool hang when tokenizer worker fails to load - Track worker error state to reject promises immediately if worker is dead - Clean up debug logging from aiService.ts - Improve MCP server logging for better diagnostics - MCP integration test now passes (was blocked by tokenizer, not MCP) --- src/node/services/mcpServerManager.ts | 17 +++- src/node/services/mcpStdioTransport.ts | 73 ++++------------- src/node/utils/main/workerPool.ts | 11 +++ tests/ipc/mcpConfig.test.ts | 107 ++++++++++++++++++++++++- 4 files changed, 147 insertions(+), 61 deletions(-) diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index db58516551..0cdfb56b3f 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -32,13 +32,21 @@ export class MCPServerManager { const { workspaceId, projectPath, runtime, workspacePath } = options; const servers = await this.configService.listServers(projectPath); const signature = JSON.stringify(servers ?? {}); + const serverCount = Object.keys(servers ?? {}).length; const existing = this.workspaceServers.get(workspaceId); if (existing?.configSignature === signature) { + log.debug("[MCP] Using cached servers", { workspaceId, serverCount }); return this.collectTools(existing.instances); } // Config changed or not started yet -> restart + if (serverCount > 0) { + log.info("[MCP] Starting servers", { + workspaceId, + servers: Object.keys(servers ?? {}), + }); + } await this.stopServers(workspaceId); const instances = await this.startServers(servers, runtime, workspacePath); this.workspaceServers.set(workspaceId, { @@ -97,6 +105,7 @@ export class MCPServerManager { runtime: Runtime, workspacePath: string ): Promise { + log.debug("[MCP] Spawning server", { name, command }); const execStream = await runtime.exec(command, { cwd: workspacePath, timeout: 60 * 60 * 24, // 24 hours @@ -104,23 +113,25 @@ export class MCPServerManager { const transport = new MCPStdioTransport(execStream); transport.onerror = (error) => { - log.error("MCP transport error", { name, error }); + log.error("[MCP] Transport error", { name, error }); }; await transport.start(); const client = await experimental_createMCPClient({ transport }); const tools = await client.tools(); + const toolNames = Object.keys(tools); + log.info("[MCP] Server ready", { name, tools: toolNames }); const close = async () => { try { await client.close(); } catch (error) { - log.debug("Error closing MCP client", { name, error }); + log.debug("[MCP] Error closing client", { name, error }); } try { await transport.close(); } catch (error) { - log.debug("Error closing MCP transport", { name, error }); + log.debug("[MCP] Error closing transport", { name, error }); } }; diff --git a/src/node/services/mcpStdioTransport.ts b/src/node/services/mcpStdioTransport.ts index 3d60d9c153..fe6189bf10 100644 --- a/src/node/services/mcpStdioTransport.ts +++ b/src/node/services/mcpStdioTransport.ts @@ -3,31 +3,17 @@ import type { MCPTransport, JSONRPCMessage } from "@ai-sdk/mcp"; import type { ExecStream } from "@/node/runtime/Runtime"; import { log } from "@/node/services/log"; -function findHeaderEnd(buffer: Uint8Array): number { - for (let i = 0; i < buffer.length - 3; i++) { - if (buffer[i] === 13 && buffer[i + 1] === 10 && buffer[i + 2] === 13 && buffer[i + 3] === 10) { - return i; - } - } - return -1; -} - -function concatBuffers(a: Uint8Array, b: Uint8Array): Uint8Array { - const result = new Uint8Array(a.length + b.length); - result.set(a, 0); - result.set(b, a.length); - return result; -} - /** - * Minimal stdio transport for MCP servers using JSON-RPC over Content-Length framed messages. + * Minimal stdio transport for MCP servers using newline-delimited JSON (NDJSON). + * Each message is a single line of JSON followed by \n. + * This matches the protocol used by @ai-sdk/mcp's StdioMCPTransport. */ export class MCPStdioTransport implements MCPTransport { private readonly decoder = new TextDecoder(); private readonly encoder = new TextEncoder(); private readonly stdoutReader: ReadableStreamDefaultReader; private readonly stdinWriter: WritableStreamDefaultWriter; - private buffer: Uint8Array = new Uint8Array(0); + private buffer = ""; private running = false; private readonly exitPromise: Promise; @@ -53,11 +39,10 @@ export class MCPStdioTransport implements MCPTransport { } async send(message: JSONRPCMessage): Promise { - const payload = JSON.stringify(message); - const body = this.encoder.encode(payload); - const header = this.encoder.encode(`Content-Length: ${body.length}\r\n\r\n`); - const framed = concatBuffers(header, body); - await this.stdinWriter.write(framed); + // NDJSON: serialize as JSON followed by newline + const line = JSON.stringify(message) + "\n"; + const bytes = this.encoder.encode(line); + await this.stdinWriter.write(bytes); } async close(): Promise { @@ -79,8 +64,7 @@ export class MCPStdioTransport implements MCPTransport { const { value, done } = await this.stdoutReader.read(); if (done) break; if (value) { - const chunk = value; - this.buffer = concatBuffers(this.buffer, chunk); + this.buffer += this.decoder.decode(value, { stream: true }); this.processBuffer(); } } @@ -96,39 +80,16 @@ export class MCPStdioTransport implements MCPTransport { } private processBuffer(): void { - while (true) { - const headerEnd = findHeaderEnd(this.buffer); - if (headerEnd === -1) return; // Need more data - - const headerBytes = this.buffer.slice(0, headerEnd); - const headerText = this.decoder.decode(headerBytes); - const contentLengthMatch = headerText - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => line.toLowerCase().startsWith("content-length")); - - if (!contentLengthMatch) { - throw new Error("Content-Length header missing in MCP response"); - } - - const [, lengthStr] = contentLengthMatch.split(":"); - const contentLength = parseInt(lengthStr?.trim() ?? "", 10); - if (!Number.isFinite(contentLength)) { - throw new Error("Invalid Content-Length header in MCP response"); - } - - const messageStart = headerEnd + 4; // \r\n\r\n - if (this.buffer.length < messageStart + contentLength) { - return; // Wait for more data - } + // Process complete lines (NDJSON format) + let newlineIndex: number; + while ((newlineIndex = this.buffer.indexOf("\n")) !== -1) { + const line = this.buffer.slice(0, newlineIndex); + this.buffer = this.buffer.slice(newlineIndex + 1); - const messageBytes = this.buffer.slice(messageStart, messageStart + contentLength); - const remaining = this.buffer.slice(messageStart + contentLength); - this.buffer = remaining; + if (line.trim().length === 0) continue; // Skip empty lines - const messageText = this.decoder.decode(messageBytes); try { - const message = JSON.parse(messageText) as JSONRPCMessage; + const message = JSON.parse(line) as JSONRPCMessage; if (this.onmessage) { this.onmessage(message); } @@ -136,7 +97,7 @@ export class MCPStdioTransport implements MCPTransport { if (this.onerror) { this.onerror(error as Error); } else { - log.error("Failed to parse MCP message", { error, messageText }); + log.error("Failed to parse MCP message", { error, line }); } } } diff --git a/src/node/utils/main/workerPool.ts b/src/node/utils/main/workerPool.ts index 8e8381b01f..c2167073f6 100644 --- a/src/node/utils/main/workerPool.ts +++ b/src/node/utils/main/workerPool.ts @@ -29,6 +29,9 @@ const pendingPromises = new Map< { resolve: (value: unknown) => void; reject: (error: Error) => void } >(); +// Track if worker is alive - reject immediately if dead +let workerError: Error | null = null; + // Resolve worker path // In production: both workerPool.js and tokenizer.worker.js are in dist/utils/main/ // During tests: workerPool.ts is in src/utils/main/ but worker is in dist/utils/main/ @@ -81,6 +84,7 @@ worker.on("message", (response: WorkerResponse) => { // Handle worker errors worker.on("error", (error) => { log.error("Worker error:", error); + workerError = error; // Reject all pending promises for (const pending of pendingPromises.values()) { pending.reject(error); @@ -93,6 +97,7 @@ worker.on("exit", (code) => { if (code !== 0) { log.error(`Worker stopped with exit code ${code}`); const error = new Error(`Worker stopped with exit code ${code}`); + workerError = error; for (const pending of pendingPromises.values()) { pending.reject(error); } @@ -110,6 +115,12 @@ worker.unref(); * @returns A promise that resolves with the task result */ export function run(taskName: string, data: unknown): Promise { + // If worker already died (e.g., failed to load), reject immediately + // This prevents hanging promises when the worker is not available + if (workerError) { + return Promise.reject(workerError); + } + const messageId = messageIdCounter++; const request: WorkerRequest = { messageId, taskName, data }; diff --git a/tests/ipc/mcpConfig.test.ts b/tests/ipc/mcpConfig.test.ts index 39af9939f3..2129a87de6 100644 --- a/tests/ipc/mcpConfig.test.ts +++ b/tests/ipc/mcpConfig.test.ts @@ -1,10 +1,29 @@ import * as fs from "fs/promises"; import * as path from "path"; -import { shouldRunIntegrationTests, cleanupTestEnvironment, createTestEnvironment } from "./setup"; -import { createTempGitRepo, cleanupTempGitRepo, resolveOrpcClient } from "./helpers"; +import { + shouldRunIntegrationTests, + cleanupTestEnvironment, + createTestEnvironment, + setupWorkspace, + validateApiKeys, +} from "./setup"; +import { + createTempGitRepo, + cleanupTempGitRepo, + resolveOrpcClient, + sendMessageWithModel, + createStreamCollector, + assertStreamSuccess, + extractTextFromEvents, + HAIKU_MODEL, +} from "./helpers"; const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; +if (shouldRunIntegrationTests()) { + validateApiKeys(["ANTHROPIC_API_KEY"]); +} + describeIntegration("MCP project configuration", () => { test.concurrent("add, list, and remove MCP servers", async () => { const env = await createTestEnvironment(); @@ -54,3 +73,87 @@ describeIntegration("MCP project configuration", () => { } }); }); + +describeIntegration("MCP server integration with model", () => { + + test.concurrent( + "MCP tools are available to the model", + async () => { + console.log("[MCP Test] Setting up workspace..."); + // Setup workspace with Anthropic provider + const { env, workspaceId, tempGitRepo, cleanup } = await setupWorkspace( + "anthropic", + "mcp-memory" + ); + const client = resolveOrpcClient(env); + console.log("[MCP Test] Workspace created:", { workspaceId, tempGitRepo }); + + try { + // Add the memory MCP server to the project + console.log("[MCP Test] Adding MCP server..."); + const addResult = await client.projects.mcp.add({ + projectPath: tempGitRepo, + name: "memory", + command: "npx -y @modelcontextprotocol/server-memory", + }); + expect(addResult.success).toBe(true); + console.log("[MCP Test] MCP server added"); + + // Create stream collector to capture events + console.log("[MCP Test] Creating stream collector..."); + const collector = createStreamCollector(env.orpc, workspaceId); + collector.start(); + await collector.waitForSubscription(); + console.log("[MCP Test] Stream collector ready"); + + // Send a message that should trigger the memory tool + // The memory server provides: create_entities, create_relations, read_graph, etc. + console.log("[MCP Test] Sending message..."); + const result = await sendMessageWithModel( + env, + workspaceId, + 'Use the create_entities tool from MCP to create an entity with name "TestEntity" and entityType "test" and observations ["integration test"]. Then confirm you did it.', + HAIKU_MODEL + ); + console.log("[MCP Test] Message sent, result:", result.success); + + expect(result.success).toBe(true); + + // Wait for stream to complete + console.log("[MCP Test] Waiting for stream-end..."); + await collector.waitForEvent("stream-end", 60000); + console.log("[MCP Test] Stream ended"); + assertStreamSuccess(collector); + + // Verify MCP tool was called + const events = collector.getEvents(); + const toolCallStarts = events.filter( + (e): e is Extract => e.type === "tool-call-start" + ); + console.log( + "[MCP Test] Tool calls:", + toolCallStarts.map((e) => e.toolName) + ); + + // Should have at least one tool call + expect(toolCallStarts.length).toBeGreaterThan(0); + + // Should have called the MCP memory tool (create_entities) + const mcpToolCall = toolCallStarts.find((e) => e.toolName === "create_entities"); + expect(mcpToolCall).toBeDefined(); + + // Verify response mentions the entity was created + const deltas = collector.getDeltas(); + const responseText = extractTextFromEvents(deltas).toLowerCase(); + expect(responseText).toMatch(/entity|created|testentity/i); + + collector.stop(); + } finally { + console.log("[MCP Test] Cleaning up..."); + await cleanup(); + console.log("[MCP Test] Done"); + } + }, + 90000 + ); // MCP server startup + tool call can take time +}); From c050505ffe0ca7783484516b40a85cf6c8185692 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 13:07:32 -0600 Subject: [PATCH 04/25] feat: add MCP server test button in Settings UI - Add projects.mcp.test ORPC endpoint - Add MCPServerManager.testServer() that spawns server, fetches tools, closes - Add Test button with spinner, success (shows tools), and error states - 30 second timeout for test operations --- .../sections/ProjectSettingsSection.tsx | 114 +++++++++++++++--- src/common/orpc/schemas.ts | 8 +- src/common/orpc/schemas/api.ts | 12 +- src/common/orpc/schemas/mcp.ts | 10 ++ src/node/orpc/router.ts | 6 + src/node/services/mcpServerManager.ts | 56 +++++++++ tests/ipc/mcpConfig.test.ts | 1 - 7 files changed, 184 insertions(+), 23 deletions(-) diff --git a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx index 0e0eddef9e..20abf008e3 100644 --- a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx @@ -1,7 +1,9 @@ import React, { useCallback, useEffect, useState } from "react"; import { useAPI } from "@/browser/contexts/API"; import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; -import { Trash2 } from "lucide-react"; +import { Trash2, Play, Loader2, CheckCircle, XCircle } from "lucide-react"; + +type TestResult = { success: true; tools: string[] } | { success: false; error: string }; export const ProjectSettingsSection: React.FC = () => { const { api } = useAPI(); @@ -11,6 +13,8 @@ export const ProjectSettingsSection: React.FC = () => { const [servers, setServers] = useState>({}); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [testingServer, setTestingServer] = useState(null); + const [testResults, setTestResults] = useState>(new Map()); const refresh = useCallback(async () => { if (!api || !projectPath) return; @@ -50,6 +54,33 @@ export const ProjectSettingsSection: React.FC = () => { [api, projectPath, refresh] ); + const handleTest = useCallback( + async (name: string) => { + if (!api || !projectPath) return; + setTestingServer(name); + // Clear previous result for this server + setTestResults((prev) => { + const next = new Map(prev); + next.delete(name); + return next; + }); + try { + const result = await api.projects.mcp.test({ projectPath, name }); + setTestResults((prev) => new Map(prev).set(name, result)); + } catch (err) { + setTestResults((prev) => + new Map(prev).set(name, { + success: false, + error: err instanceof Error ? err.message : "Test failed", + }) + ); + } finally { + setTestingServer(null); + } + }, + [api, projectPath] + ); + if (!projectPath) { return (

@@ -77,26 +108,69 @@ export const ProjectSettingsSection: React.FC = () => { )}

    - {Object.entries(servers).map(([name, command]) => ( -
  • -
    -
    {name}
    -
    {command}
    -
    - -
  • - ))} +
    +
    +
    {name}
    +
    {command}
    +
    +
    + + +
    +
    + {/* Test result display */} + {testResult && ( +
    + {testResult.success ? ( + <> + + + {testResult.tools.length} tools: {testResult.tools.slice(0, 5).join(", ")} + {testResult.tools.length > 5 && ` (+${testResult.tools.length - 5} more)`} + + + ) : ( + <> + + {testResult.error} + + )} +
    + )} + + ); + })}
); diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index 379367cefe..849d2a1e91 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -39,7 +39,13 @@ export { SecretSchema } from "./schemas/secrets"; export { MuxProviderOptionsSchema } from "./schemas/providerOptions"; // MCP schemas -export { MCPServerMapSchema, MCPAddParamsSchema, MCPRemoveParamsSchema } from "./schemas/mcp"; +export { + MCPServerMapSchema, + MCPAddParamsSchema, + MCPRemoveParamsSchema, + MCPTestParamsSchema, + MCPTestResultSchema, +} from "./schemas/mcp"; // Terminal schemas export { diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 59cba82630..1163c9a80d 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -15,7 +15,13 @@ import { } from "./terminal"; import { BashToolResultSchema, FileTreeNodeSchema } from "./tools"; import { FrontendWorkspaceMetadataSchema, WorkspaceActivitySnapshotSchema } from "./workspace"; -import { MCPAddParamsSchema, MCPRemoveParamsSchema, MCPServerMapSchema } from "./mcp"; +import { + MCPAddParamsSchema, + MCPRemoveParamsSchema, + MCPServerMapSchema, + MCPTestParamsSchema, + MCPTestResultSchema, +} from "./mcp"; // Re-export telemetry schemas export { telemetry, TelemetryEventSchema } from "./telemetry"; @@ -130,6 +136,10 @@ export const projects = { input: MCPRemoveParamsSchema, output: ResultSchema(z.void(), z.string()), }, + test: { + input: MCPTestParamsSchema, + output: MCPTestResultSchema, + }, }, secrets: { get: { diff --git a/src/common/orpc/schemas/mcp.ts b/src/common/orpc/schemas/mcp.ts index 7fe7d42b0f..918017018c 100644 --- a/src/common/orpc/schemas/mcp.ts +++ b/src/common/orpc/schemas/mcp.ts @@ -12,3 +12,13 @@ export const MCPRemoveParamsSchema = z.object({ projectPath: z.string(), name: z.string(), }); + +export const MCPTestParamsSchema = z.object({ + projectPath: z.string(), + name: z.string(), +}); + +export const MCPTestResultSchema = z.discriminatedUnion("success", [ + z.object({ success: z.literal(true), tools: z.array(z.string()) }), + z.object({ success: z.literal(false), error: z.string() }), +]); diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index b757dd129d..d34f972e1f 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -185,6 +185,12 @@ export const router = (authToken?: string) => { .handler(({ context, input }) => context.mcpConfigService.removeServer(input.projectPath, input.name) ), + test: t + .input(schemas.projects.mcp.test.input) + .output(schemas.projects.mcp.test.output) + .handler(({ context, input }) => + context.mcpServerManager.testServer(input.projectPath, input.name) + ), }, }, nameGeneration: { diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 0cdfb56b3f..38929d48bd 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -5,6 +5,11 @@ import { MCPStdioTransport } from "@/node/services/mcpStdioTransport"; import type { MCPServerMap } from "@/common/types/mcp"; import type { Runtime } from "@/node/runtime/Runtime"; import type { MCPConfigService } from "@/node/services/mcpConfigService"; +import { createRuntime } from "@/node/runtime/runtimeFactory"; + +const TEST_TIMEOUT_MS = 30_000; + +export type MCPTestResult = { success: true; tools: string[] } | { success: false; error: string }; interface MCPServerInstance { name: string; @@ -71,6 +76,57 @@ export class MCPServerManager { this.workspaceServers.delete(workspaceId); } + /** + * Test an MCP server configuration by spawning it, fetching tools, then closing. + * Used by the Settings UI to verify a server works before relying on it. + */ + async testServer(projectPath: string, name: string): Promise { + const servers = await this.configService.listServers(projectPath); + const command = servers?.[name]; + if (!command) { + return { success: false, error: `Server "${name}" not found in configuration` }; + } + + const runtime = createRuntime({ type: "local", srcBaseDir: projectPath }); + const timeoutPromise = new Promise((resolve) => + setTimeout(() => resolve({ success: false, error: "Connection timed out" }), TEST_TIMEOUT_MS) + ); + + const testPromise = (async (): Promise => { + let transport: MCPStdioTransport | null = null; + try { + log.debug("[MCP] Testing server", { name, command }); + const execStream = await runtime.exec(command, { + cwd: projectPath, + timeout: TEST_TIMEOUT_MS / 1000, + }); + + transport = new MCPStdioTransport(execStream); + await transport.start(); + const client = await experimental_createMCPClient({ transport }); + const tools = await client.tools(); + const toolNames = Object.keys(tools); + await client.close(); + await transport.close(); + log.info("[MCP] Test successful", { name, tools: toolNames }); + return { success: true, tools: toolNames }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.warn("[MCP] Test failed", { name, error: message }); + if (transport) { + try { + await transport.close(); + } catch { + // ignore cleanup errors + } + } + return { success: false, error: message }; + } + })(); + + return Promise.race([testPromise, timeoutPromise]); + } + private collectTools(instances: Map): Record { const aggregated: Record = {}; for (const instance of instances.values()) { diff --git a/tests/ipc/mcpConfig.test.ts b/tests/ipc/mcpConfig.test.ts index 2129a87de6..3c4aefb238 100644 --- a/tests/ipc/mcpConfig.test.ts +++ b/tests/ipc/mcpConfig.test.ts @@ -75,7 +75,6 @@ describeIntegration("MCP project configuration", () => { }); describeIntegration("MCP server integration with model", () => { - test.concurrent( "MCP tools are available to the model", async () => { From 03fa43ab2e081df0c8fc48649af5bccfd1af4ea6 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 13:15:35 -0600 Subject: [PATCH 05/25] chore: reduce MCP test timeout to 10s --- src/node/services/mcpServerManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 38929d48bd..d931dfb840 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -7,7 +7,7 @@ import type { Runtime } from "@/node/runtime/Runtime"; import type { MCPConfigService } from "@/node/services/mcpConfigService"; import { createRuntime } from "@/node/runtime/runtimeFactory"; -const TEST_TIMEOUT_MS = 30_000; +const TEST_TIMEOUT_MS = 10_000; export type MCPTestResult = { success: true; tools: string[] } | { success: false; error: string }; From 0c4ace66751df920c22355ed029fd63b7b785c7b Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 13:20:17 -0600 Subject: [PATCH 06/25] feat: improve Projects settings page UX - Rename sidebar tab from 'Project' to 'Projects' - Add project dropdown selector at top of page - Show full project path below dropdown - Add inline form to add MCP servers (name + command) - Enter key submits the add form - Remove reference to /mcp add command (now done in UI) --- .../components/Settings/SettingsModal.tsx | 4 +- .../sections/ProjectSettingsSection.tsx | 141 +++++++++++++++--- 2 files changed, 124 insertions(+), 21 deletions(-) diff --git a/src/browser/components/Settings/SettingsModal.tsx b/src/browser/components/Settings/SettingsModal.tsx index bf22334381..e0eeea391f 100644 --- a/src/browser/components/Settings/SettingsModal.tsx +++ b/src/browser/components/Settings/SettingsModal.tsx @@ -23,8 +23,8 @@ const SECTIONS: SettingsSection[] = [ component: ProvidersSection, }, { - id: "project", - label: "Project", + id: "projects", + label: "Projects", icon: , component: ProjectSettingsSection, }, diff --git a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx index 20abf008e3..6f6246da33 100644 --- a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx @@ -1,26 +1,39 @@ import React, { useCallback, useEffect, useState } from "react"; import { useAPI } from "@/browser/contexts/API"; -import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; -import { Trash2, Play, Loader2, CheckCircle, XCircle } from "lucide-react"; +import { useProjectContext } from "@/browser/contexts/ProjectContext"; +import { Trash2, Play, Loader2, CheckCircle, XCircle, Plus, ChevronDown } from "lucide-react"; type TestResult = { success: true; tools: string[] } | { success: false; error: string }; export const ProjectSettingsSection: React.FC = () => { const { api } = useAPI(); - const { selectedWorkspace } = useWorkspaceContext(); - const projectPath = selectedWorkspace?.projectPath; + const { projects } = useProjectContext(); + const projectList = Array.from(projects.keys()); + const [selectedProject, setSelectedProject] = useState(""); const [servers, setServers] = useState>({}); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [testingServer, setTestingServer] = useState(null); const [testResults, setTestResults] = useState>(new Map()); + // Add server form state + const [newServerName, setNewServerName] = useState(""); + const [newServerCommand, setNewServerCommand] = useState(""); + const [addingServer, setAddingServer] = useState(false); + + // Set default project when projects load + useEffect(() => { + if (projectList.length > 0 && !selectedProject) { + setSelectedProject(projectList[0]); + } + }, [projectList, selectedProject]); + const refresh = useCallback(async () => { - if (!api || !projectPath) return; + if (!api || !selectedProject) return; setLoading(true); try { - const result = await api.projects.mcp.list({ projectPath }); + const result = await api.projects.mcp.list({ projectPath: selectedProject }); setServers(result ?? {}); setError(null); } catch (err) { @@ -28,18 +41,20 @@ export const ProjectSettingsSection: React.FC = () => { } finally { setLoading(false); } - }, [api, projectPath]); + }, [api, selectedProject]); useEffect(() => { void refresh(); + // Clear test results when project changes + setTestResults(new Map()); }, [refresh]); const handleRemove = useCallback( async (name: string) => { - if (!api || !projectPath) return; + if (!api || !selectedProject) return; setLoading(true); try { - const result = await api.projects.mcp.remove({ projectPath, name }); + const result = await api.projects.mcp.remove({ projectPath: selectedProject, name }); if (!result.success) { setError(result.error ?? "Failed to remove MCP server"); } else { @@ -51,12 +66,12 @@ export const ProjectSettingsSection: React.FC = () => { setLoading(false); } }, - [api, projectPath, refresh] + [api, selectedProject, refresh] ); const handleTest = useCallback( async (name: string) => { - if (!api || !projectPath) return; + if (!api || !selectedProject) return; setTestingServer(name); // Clear previous result for this server setTestResults((prev) => { @@ -65,7 +80,7 @@ export const ProjectSettingsSection: React.FC = () => { return next; }); try { - const result = await api.projects.mcp.test({ projectPath, name }); + const result = await api.projects.mcp.test({ projectPath: selectedProject, name }); setTestResults((prev) => new Map(prev).set(name, result)); } catch (err) { setTestResults((prev) => @@ -78,24 +93,73 @@ export const ProjectSettingsSection: React.FC = () => { setTestingServer(null); } }, - [api, projectPath] + [api, selectedProject] ); - if (!projectPath) { + const handleAddServer = useCallback(async () => { + if (!api || !selectedProject || !newServerName.trim() || !newServerCommand.trim()) return; + setAddingServer(true); + setError(null); + try { + const result = await api.projects.mcp.add({ + projectPath: selectedProject, + name: newServerName.trim(), + command: newServerCommand.trim(), + }); + if (!result.success) { + setError(result.error ?? "Failed to add MCP server"); + } else { + setNewServerName(""); + setNewServerCommand(""); + await refresh(); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to add MCP server"); + } finally { + setAddingServer(false); + } + }, [api, selectedProject, newServerName, newServerCommand, refresh]); + + if (projectList.length === 0) { return (

- Select a workspace to manage project settings. + No projects configured. Add a project first to manage its settings.

); } + const projectName = (path: string) => path.split(/[\\/]/).pop() ?? path; + return ( -
+
+ {/* Project selector */} +
+ +
+ + +
+

{selectedProject}

+
+ + {/* MCP Servers section */}
-

MCP Servers

+

MCP Servers

- Servers are stored in .mux/mcp.jsonc in this project. Use{" "} - /mcp add to add new entries. + Servers are stored in .mux/mcp.jsonc

@@ -172,6 +236,45 @@ export const ProjectSettingsSection: React.FC = () => { ); })} + + {/* Add server form */} +
+

Add MCP Server

+
+ setNewServerName(e.target.value)} + className="border-border-medium bg-secondary/30 text-foreground placeholder:text-muted-foreground focus:ring-accent w-full rounded-md border px-3 py-1.5 text-sm focus:ring-1 focus:outline-none" + /> + setNewServerCommand(e.target.value)} + className="border-border-medium bg-secondary/30 text-foreground placeholder:text-muted-foreground focus:ring-accent w-full rounded-md border px-3 py-1.5 text-sm focus:ring-1 focus:outline-none" + onKeyDown={(e) => { + if (e.key === "Enter" && newServerName.trim() && newServerCommand.trim()) { + void handleAddServer(); + } + }} + /> +
+ +
); }; From 7334de828c3492148867bc6c793ad18a67ff6613 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 13:24:59 -0600 Subject: [PATCH 07/25] feat: redesign MCP settings with test-before-add UX - Add testCommand endpoint to test a command before saving to config - Separate Test and Add buttons in the form - Test shows success with tool count and names, or error message - Polished UI with better spacing, labels, and visual hierarchy - Server list shows tool badge when tested successfully - Error messages use styled alert boxes - Empty state with icon for no projects --- .../sections/ProjectSettingsSection.tsx | 339 ++++++++++++------ src/common/orpc/schemas.ts | 1 + src/common/orpc/schemas/api.ts | 5 + src/common/orpc/schemas/mcp.ts | 5 + src/node/orpc/router.ts | 6 + src/node/services/mcpServerManager.ts | 49 +++ 6 files changed, 286 insertions(+), 119 deletions(-) diff --git a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx index 6f6246da33..91db4e4acb 100644 --- a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx @@ -1,7 +1,16 @@ import React, { useCallback, useEffect, useState } from "react"; import { useAPI } from "@/browser/contexts/API"; import { useProjectContext } from "@/browser/contexts/ProjectContext"; -import { Trash2, Play, Loader2, CheckCircle, XCircle, Plus, ChevronDown } from "lucide-react"; +import { + Trash2, + Play, + Loader2, + CheckCircle, + XCircle, + Plus, + ChevronDown, + Server, +} from "lucide-react"; type TestResult = { success: true; tools: string[] } | { success: false; error: string }; @@ -21,6 +30,8 @@ export const ProjectSettingsSection: React.FC = () => { const [newServerName, setNewServerName] = useState(""); const [newServerCommand, setNewServerCommand] = useState(""); const [addingServer, setAddingServer] = useState(false); + const [testingNewCommand, setTestingNewCommand] = useState(false); + const [newCommandTestResult, setNewCommandTestResult] = useState(null); // Set default project when projects load useEffect(() => { @@ -45,10 +56,14 @@ export const ProjectSettingsSection: React.FC = () => { useEffect(() => { void refresh(); - // Clear test results when project changes setTestResults(new Map()); }, [refresh]); + // Clear new command test result when command changes + useEffect(() => { + setNewCommandTestResult(null); + }, [newServerCommand]); + const handleRemove = useCallback( async (name: string) => { if (!api || !selectedProject) return; @@ -73,7 +88,6 @@ export const ProjectSettingsSection: React.FC = () => { async (name: string) => { if (!api || !selectedProject) return; setTestingServer(name); - // Clear previous result for this server setTestResults((prev) => { const next = new Map(prev); next.delete(name); @@ -96,6 +110,26 @@ export const ProjectSettingsSection: React.FC = () => { [api, selectedProject] ); + const handleTestNewCommand = useCallback(async () => { + if (!api || !selectedProject || !newServerCommand.trim()) return; + setTestingNewCommand(true); + setNewCommandTestResult(null); + try { + const result = await api.projects.mcp.testCommand({ + projectPath: selectedProject, + command: newServerCommand.trim(), + }); + setNewCommandTestResult(result); + } catch (err) { + setNewCommandTestResult({ + success: false, + error: err instanceof Error ? err.message : "Test failed", + }); + } finally { + setTestingNewCommand(false); + } + }, [api, selectedProject, newServerCommand]); + const handleAddServer = useCallback(async () => { if (!api || !selectedProject || !newServerName.trim() || !newServerCommand.trim()) return; setAddingServer(true); @@ -111,6 +145,7 @@ export const ProjectSettingsSection: React.FC = () => { } else { setNewServerName(""); setNewServerCommand(""); + setNewCommandTestResult(null); await refresh(); } } catch (err) { @@ -122,19 +157,24 @@ export const ProjectSettingsSection: React.FC = () => { if (projectList.length === 0) { return ( -

- No projects configured. Add a project first to manage its settings. -

+
+ +

+ No projects configured. Add a project first to manage MCP servers. +

+
); } const projectName = (path: string) => path.split(/[\\/]/).pop() ?? path; + const canAdd = newServerName.trim() && newServerCommand.trim(); + const canTest = newServerCommand.trim(); return (
{/* Project selector */} -
-
+ )} - {/* Add server form */} -
-

Add MCP Server

-
- setNewServerName(e.target.value)} - className="border-border-medium bg-secondary/30 text-foreground placeholder:text-muted-foreground focus:ring-accent w-full rounded-md border px-3 py-1.5 text-sm focus:ring-1 focus:outline-none" - /> - setNewServerCommand(e.target.value)} - className="border-border-medium bg-secondary/30 text-foreground placeholder:text-muted-foreground focus:ring-accent w-full rounded-md border px-3 py-1.5 text-sm focus:ring-1 focus:outline-none" - onKeyDown={(e) => { - if (e.key === "Enter" && newServerName.trim() && newServerCommand.trim()) { - void handleAddServer(); - } - }} - /> +
+ + +
-
); diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index 849d2a1e91..9be6ddbc95 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -44,6 +44,7 @@ export { MCPAddParamsSchema, MCPRemoveParamsSchema, MCPTestParamsSchema, + MCPTestCommandParamsSchema, MCPTestResultSchema, } from "./schemas/mcp"; diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 1163c9a80d..d450cc4dc2 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -20,6 +20,7 @@ import { MCPRemoveParamsSchema, MCPServerMapSchema, MCPTestParamsSchema, + MCPTestCommandParamsSchema, MCPTestResultSchema, } from "./mcp"; @@ -140,6 +141,10 @@ export const projects = { input: MCPTestParamsSchema, output: MCPTestResultSchema, }, + testCommand: { + input: MCPTestCommandParamsSchema, + output: MCPTestResultSchema, + }, }, secrets: { get: { diff --git a/src/common/orpc/schemas/mcp.ts b/src/common/orpc/schemas/mcp.ts index 918017018c..fa0ffb30b3 100644 --- a/src/common/orpc/schemas/mcp.ts +++ b/src/common/orpc/schemas/mcp.ts @@ -18,6 +18,11 @@ export const MCPTestParamsSchema = z.object({ name: z.string(), }); +export const MCPTestCommandParamsSchema = z.object({ + projectPath: z.string(), + command: z.string(), +}); + export const MCPTestResultSchema = z.discriminatedUnion("success", [ z.object({ success: z.literal(true), tools: z.array(z.string()) }), z.object({ success: z.literal(false), error: z.string() }), diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index d34f972e1f..d48a9fcc81 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -191,6 +191,12 @@ export const router = (authToken?: string) => { .handler(({ context, input }) => context.mcpServerManager.testServer(input.projectPath, input.name) ), + testCommand: t + .input(schemas.projects.mcp.testCommand.input) + .output(schemas.projects.mcp.testCommand.output) + .handler(({ context, input }) => + context.mcpServerManager.testCommand(input.projectPath, input.command) + ), }, }, nameGeneration: { diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index d931dfb840..80e9b63f80 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -127,6 +127,55 @@ export class MCPServerManager { return Promise.race([testPromise, timeoutPromise]); } + /** + * Test an MCP command directly without requiring it to be in config. + * Used by Settings UI to validate before adding. + */ + async testCommand(projectPath: string, command: string): Promise { + if (!command.trim()) { + return { success: false, error: "Command is required" }; + } + + const runtime = createRuntime({ type: "local", srcBaseDir: projectPath }); + const timeoutPromise = new Promise((resolve) => + setTimeout(() => resolve({ success: false, error: "Connection timed out" }), TEST_TIMEOUT_MS) + ); + + const testPromise = (async (): Promise => { + let transport: MCPStdioTransport | null = null; + try { + log.debug("[MCP] Testing command", { command }); + const execStream = await runtime.exec(command, { + cwd: projectPath, + timeout: TEST_TIMEOUT_MS / 1000, + }); + + transport = new MCPStdioTransport(execStream); + await transport.start(); + const client = await experimental_createMCPClient({ transport }); + const tools = await client.tools(); + const toolNames = Object.keys(tools); + await client.close(); + await transport.close(); + log.info("[MCP] Command test successful", { command, tools: toolNames }); + return { success: true, tools: toolNames }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.warn("[MCP] Command test failed", { command, error: message }); + if (transport) { + try { + await transport.close(); + } catch { + // ignore cleanup errors + } + } + return { success: false, error: message }; + } + })(); + + return Promise.race([testPromise, timeoutPromise]); + } + private collectTools(instances: Map): Record { const aggregated: Record = {}; for (const instance of instances.values()) { From 0e35707fe2800431c618322e07c23d91557cc683 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 13:35:38 -0600 Subject: [PATCH 08/25] =?UTF-8?q?=F0=9F=A4=96=20docs:=20add=20MCP=20server?= =?UTF-8?q?s=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/docs.json | 1 + docs/mcp-servers.mdx | 50 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 docs/mcp-servers.mdx diff --git a/docs/docs.json b/docs/docs.json index 7b69809c5a..96be48abd8 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -54,6 +54,7 @@ }, "context-management", "instruction-files", + "mcp-servers", { "group": "Project Secrets", "pages": ["project-secrets", "agentic-git-identity"] diff --git a/docs/mcp-servers.mdx b/docs/mcp-servers.mdx new file mode 100644 index 0000000000..85333bd637 --- /dev/null +++ b/docs/mcp-servers.mdx @@ -0,0 +1,50 @@ +--- +title: MCP Servers +description: Extend agent capabilities with Model Context Protocol servers +--- + +MCP (Model Context Protocol) servers provide additional tools to agents. Configure them per-project in `.mux/mcp.jsonc`. + +## Configuration + +Create `.mux/mcp.jsonc` in your project root: + +```jsonc +{ + "servers": { + // Knowledge graph for persistent memory + "memory": "npx -y @anthropic-ai/mcp-server-memory", + // Access external APIs + "github": "npx -y @modelcontextprotocol/server-github", + }, +} +``` + +Each entry maps a server name to its shell command. The command must start a process that speaks MCP over stdio (NDJSON format). + +## Settings UI + +Configure servers in **Settings → Projects**: + +1. Select a project from the dropdown +2. Add servers with name and command +3. Use **Test** to verify before adding +4. View available tools after successful test + +## Behavior + +- **Hot reload** — Config changes apply on your next message (no restart needed) +- **Per-workspace** — Each workspace maintains its own server instances +- **Isolated** — Server processes run in the workspace directory with its environment + +## Finding MCP Servers + +Browse available servers at [mcp.so](https://mcp.so/) or the [MCP servers repository](https://github.com/modelcontextprotocol/servers). + +## Troubleshooting + +If a server fails to start: + +1. **Test the command manually** — Run the command in your terminal to verify it works +2. **Check dependencies** — Ensure required packages are installed (`npx -y` downloads on first run) +3. **Review logs** — Server errors appear in mux logs (`~/.mux/logs/`) From ea186604574745435e39200f9fb81ee1cddbc642 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 17:20:16 -0600 Subject: [PATCH 09/25] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20MCP=20server?= =?UTF-8?q?=20edit=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add /mcp edit slash command - Add inline edit button in Settings UI - Enter to save, Esc to cancel --- src/browser/components/ChatInput/index.tsx | 19 ++- .../sections/ProjectSettingsSection.tsx | 155 +++++++++++++++--- src/browser/utils/slashCommands/registry.ts | 12 ++ src/browser/utils/slashCommands/types.ts | 1 + 4 files changed, 155 insertions(+), 32 deletions(-) diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index eb6b20fe7a..a5c1939dd9 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -740,7 +740,11 @@ export const ChatInput: React.FC = (props) => { return; } - if (parsed.type === "mcp-add" || parsed.type === "mcp-remove") { + if ( + parsed.type === "mcp-add" || + parsed.type === "mcp-edit" || + parsed.type === "mcp-remove" + ) { if (!api) { setToast({ id: Date.now().toString(), @@ -763,7 +767,7 @@ export const ChatInput: React.FC = (props) => { try { const projectPath = selectedWorkspace.projectPath; const result = - parsed.type === "mcp-add" + parsed.type === "mcp-add" || parsed.type === "mcp-edit" ? await api.projects.mcp.add({ projectPath, name: parsed.name, @@ -779,13 +783,16 @@ export const ChatInput: React.FC = (props) => { }); setInput(messageText); } else { + const successMessage = + parsed.type === "mcp-add" + ? `Added MCP server ${parsed.name}` + : parsed.type === "mcp-edit" + ? `Updated MCP server ${parsed.name}` + : `Removed MCP server ${parsed.name}`; setToast({ id: Date.now().toString(), type: "success", - message: - parsed.type === "mcp-add" - ? `Added MCP server ${parsed.name}` - : `Removed MCP server ${parsed.name}`, + message: successMessage, }); } } catch (error) { diff --git a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx index 91db4e4acb..c63f23f921 100644 --- a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx @@ -10,6 +10,9 @@ import { Plus, ChevronDown, Server, + Pencil, + Check, + X, } from "lucide-react"; type TestResult = { success: true; tools: string[] } | { success: false; error: string }; @@ -33,6 +36,11 @@ export const ProjectSettingsSection: React.FC = () => { const [testingNewCommand, setTestingNewCommand] = useState(false); const [newCommandTestResult, setNewCommandTestResult] = useState(null); + // Edit server state + const [editingServer, setEditingServer] = useState(null); + const [editCommand, setEditCommand] = useState(""); + const [savingEdit, setSavingEdit] = useState(false); + // Set default project when projects load useEffect(() => { if (projectList.length > 0 && !selectedProject) { @@ -155,6 +163,46 @@ export const ProjectSettingsSection: React.FC = () => { } }, [api, selectedProject, newServerName, newServerCommand, refresh]); + const handleStartEdit = useCallback((name: string, command: string) => { + setEditingServer(name); + setEditCommand(command); + }, []); + + const handleCancelEdit = useCallback(() => { + setEditingServer(null); + setEditCommand(""); + }, []); + + const handleSaveEdit = useCallback(async () => { + if (!api || !selectedProject || !editingServer || !editCommand.trim()) return; + setSavingEdit(true); + setError(null); + try { + const result = await api.projects.mcp.add({ + projectPath: selectedProject, + name: editingServer, + command: editCommand.trim(), + }); + if (!result.success) { + setError(result.error ?? "Failed to update MCP server"); + } else { + setEditingServer(null); + setEditCommand(""); + // Clear test result for this server since command changed + setTestResults((prev) => { + const next = new Map(prev); + next.delete(editingServer); + return next; + }); + await refresh(); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update MCP server"); + } finally { + setSavingEdit(false); + } + }, [api, selectedProject, editingServer, editCommand, refresh]); + if (projectList.length === 0) { return (
@@ -227,52 +275,107 @@ export const ProjectSettingsSection: React.FC = () => { {Object.entries(servers).map(([name, command]) => { const isTesting = testingServer === name; const testResult = testResults.get(name); + const isEditing = editingServer === name; return (
  • {name} - {testResult?.success && ( + {testResult?.success && !isEditing && ( {testResult.tools.length} tools )}
    -

    {command}

    + {isEditing ? ( + setEditCommand(e.target.value)} + className="border-border-medium bg-secondary/30 text-foreground placeholder:text-muted-foreground focus:ring-accent mt-1 w-full rounded-md border px-2 py-1 text-xs focus:ring-1 focus:outline-none" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") { + void handleSaveEdit(); + } else if (e.key === "Escape") { + handleCancelEdit(); + } + }} + /> + ) : ( +

    {command}

    + )}
    - - + {isEditing ? ( + <> + + + + ) : ( + <> + + + + + )}
    - {testResult && !testResult.success && ( + {testResult && !testResult.success && !isEditing && (
    {testResult.error}
    )} - {testResult?.success && testResult.tools.length > 0 && ( + {testResult?.success && testResult.tools.length > 0 && !isEditing && (

    Tools: {testResult.tools.join(", ")}

    diff --git a/src/browser/utils/slashCommands/registry.ts b/src/browser/utils/slashCommands/registry.ts index e364acb521..65ca729719 100644 --- a/src/browser/utils/slashCommands/registry.ts +++ b/src/browser/utils/slashCommands/registry.ts @@ -606,6 +606,18 @@ const mcpCommandDefinition: SlashCommandDefinition = { return { type: "mcp-add", name, command: commandText }; } + if (sub === "edit") { + const name = cleanRemainingTokens[1]; + const commandText = rawInput + .trim() + .replace(/^edit\s+[^\s]+\s*/i, "") + .trim(); + if (!name || !commandText) { + return { type: "unknown-command", command: "mcp", subcommand: "edit" }; + } + return { type: "mcp-edit", name, command: commandText }; + } + if (sub === "remove") { const name = cleanRemainingTokens[1]; if (!name) { diff --git a/src/browser/utils/slashCommands/types.ts b/src/browser/utils/slashCommands/types.ts index d3b2245100..d42c5462c6 100644 --- a/src/browser/utils/slashCommands/types.ts +++ b/src/browser/utils/slashCommands/types.ts @@ -30,6 +30,7 @@ export type ParsedCommand = } | { type: "vim-toggle" } | { type: "mcp-add"; name: string; command: string } + | { type: "mcp-edit"; name: string; command: string } | { type: "mcp-remove"; name: string } | { type: "mcp-open" } | { type: "unknown-command"; command: string; subcommand?: string } From c4cae8711330668007e743b33a7f437bad0908b1 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 17:22:40 -0600 Subject: [PATCH 10/25] =?UTF-8?q?=F0=9F=A4=96=20fix:=20stop=20Escape=20pro?= =?UTF-8?q?pagation=20in=20Settings=20edit=20inputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents Escape from closing the modal when canceling inline edits. Also disables spellcheck on MCP command input. --- src/browser/components/Settings/sections/ModelRow.tsx | 5 ++++- .../components/Settings/sections/ProjectSettingsSection.tsx | 2 ++ .../components/Settings/sections/ProvidersSection.tsx | 5 ++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/browser/components/Settings/sections/ModelRow.tsx b/src/browser/components/Settings/sections/ModelRow.tsx index 2a4c463ec6..94d6d76e80 100644 --- a/src/browser/components/Settings/sections/ModelRow.tsx +++ b/src/browser/components/Settings/sections/ModelRow.tsx @@ -47,7 +47,10 @@ export function ModelRow(props: ModelRowProps) { onChange={(e) => props.onEditChange?.(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") props.onSaveEdit?.(); - if (e.key === "Escape") props.onCancelEdit?.(); + if (e.key === "Escape") { + e.stopPropagation(); + props.onCancelEdit?.(); + } }} className="bg-modal-bg border-border-medium focus:border-accent min-w-0 flex-1 rounded border px-2 py-0.5 font-mono text-xs focus:outline-none" autoFocus diff --git a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx index c63f23f921..785bfd7705 100644 --- a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx @@ -295,10 +295,12 @@ export const ProjectSettingsSection: React.FC = () => { onChange={(e) => setEditCommand(e.target.value)} className="border-border-medium bg-secondary/30 text-foreground placeholder:text-muted-foreground focus:ring-accent mt-1 w-full rounded-md border px-2 py-1 text-xs focus:ring-1 focus:outline-none" autoFocus + spellCheck={false} onKeyDown={(e) => { if (e.key === "Enter") { void handleSaveEdit(); } else if (e.key === "Escape") { + e.stopPropagation(); handleCancelEdit(); } }} diff --git a/src/browser/components/Settings/sections/ProvidersSection.tsx b/src/browser/components/Settings/sections/ProvidersSection.tsx index f507c41995..9a6937820b 100644 --- a/src/browser/components/Settings/sections/ProvidersSection.tsx +++ b/src/browser/components/Settings/sections/ProvidersSection.tsx @@ -264,7 +264,10 @@ export function ProvidersSection() { autoFocus onKeyDown={(e) => { if (e.key === "Enter") handleSaveEdit(); - if (e.key === "Escape") handleCancelEdit(); + if (e.key === "Escape") { + e.stopPropagation(); + handleCancelEdit(); + } }} />
  • @@ -412,7 +414,8 @@ export const ProjectSettingsSection: React.FC = () => { placeholder="e.g., npx -y @modelcontextprotocol/server-memory" value={newServerCommand} onChange={(e) => setNewServerCommand(e.target.value)} - className="border-border-medium bg-secondary/30 text-foreground placeholder:text-muted-foreground focus:ring-accent w-full rounded-md border px-3 py-2 text-sm focus:ring-1 focus:outline-none" + spellCheck={false} + className="border-border-medium bg-secondary/30 text-foreground placeholder:text-muted-foreground focus:ring-accent w-full rounded-md border px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none" />
    From c0e7166d2590fde195135cc31ba3121936610792 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 17:42:59 -0600 Subject: [PATCH 13/25] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20MCP=20server?= =?UTF-8?q?s=20section=20to=20system=20prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows configured servers (name + command) when at least one exists. Explains they're configured in user's local project's .mux/mcp.jsonc. --- .mux/mcp.jsonc | 5 +++++ docs/system-prompt.mdx | 21 ++++++++++++++++++ src/node/services/aiService.ts | 8 ++++++- src/node/services/mcpServerManager.ts | 8 +++++++ src/node/services/systemMessage.ts | 31 ++++++++++++++++++++++++++- 5 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 .mux/mcp.jsonc diff --git a/.mux/mcp.jsonc b/.mux/mcp.jsonc new file mode 100644 index 0000000000..e97ad73d7b --- /dev/null +++ b/.mux/mcp.jsonc @@ -0,0 +1,5 @@ +{ + "servers": { + "chrome": "npx -y chrome-devtools-mcp@latest --headless --chromeArg='--no-sandbox'" + } +} diff --git a/docs/system-prompt.mdx b/docs/system-prompt.mdx index 9a794dbe39..aa1a81edd6 100644 --- a/docs/system-prompt.mdx +++ b/docs/system-prompt.mdx @@ -59,6 +59,27 @@ You are in a git worktree at ${workspacePath} `; } + +/** + * Build MCP servers context XML block. + * Only included when at least one MCP server is configured. + */ +function buildMCPContext(mcpServers: MCPServerMap): string { + const entries = Object.entries(mcpServers); + if (entries.length === 0) return ""; + + const serverList = entries.map(([name, command]) => `- ${name}: \`${command}\``).join("\n"); + + return ` + +MCP (Model Context Protocol) servers provide additional tools. Configured in user's local project's .mux/mcp.jsonc: + +${serverList} + +Use /mcp add|edit|remove or Settings → Projects to manage servers. + +`; +} ``` {/* END SYSTEM_PROMPT_DOCS */} diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 37c265e24c..b48789a0cf 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -982,6 +982,11 @@ export class AIService extends EventEmitter { ? metadata.projectPath : runtime.getWorkspacePath(metadata.projectPath, metadata.name); + // Fetch MCP server config for system prompt (before building message) + const mcpServers = this.mcpServerManager + ? await this.mcpServerManager.listServers(metadata.projectPath) + : undefined; + // Build system message from workspace metadata const systemMessage = await buildSystemMessage( metadata, @@ -989,7 +994,8 @@ export class AIService extends EventEmitter { workspacePath, mode, additionalSystemInstructions, - modelString + modelString, + mcpServers ); // Count system message tokens for cost tracking diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 80e9b63f80..08568bc094 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -28,6 +28,14 @@ export class MCPServerManager { constructor(private readonly configService: MCPConfigService) {} + /** + * List configured MCP servers for a project (name -> command). + * Used to show server info in the system prompt. + */ + async listServers(projectPath: string): Promise { + return this.configService.listServers(projectPath); + } + async getToolsForWorkspace(options: { workspaceId: string; projectPath: string; diff --git a/src/node/services/systemMessage.ts b/src/node/services/systemMessage.ts index e021cbac79..291c6a9e9d 100644 --- a/src/node/services/systemMessage.ts +++ b/src/node/services/systemMessage.ts @@ -1,4 +1,5 @@ import type { WorkspaceMetadata } from "@/common/types/workspace"; +import type { MCPServerMap } from "@/common/types/mcp"; import { readInstructionSet, readInstructionSetFromRuntime, @@ -77,6 +78,27 @@ You are in a git worktree at ${workspacePath} `; } + +/** + * Build MCP servers context XML block. + * Only included when at least one MCP server is configured. + */ +function buildMCPContext(mcpServers: MCPServerMap): string { + const entries = Object.entries(mcpServers); + if (entries.length === 0) return ""; + + const serverList = entries.map(([name, command]) => `- ${name}: \`${command}\``).join("\n"); + + return ` + +MCP (Model Context Protocol) servers provide additional tools. Configured in user's local project's .mux/mcp.jsonc: + +${serverList} + +Use /mcp add|edit|remove or Settings → Projects to manage servers. + +`; +} // #endregion SYSTEM_PROMPT_DOCS /** @@ -183,6 +205,7 @@ async function readInstructionSources( * @param mode - Optional mode name (e.g., "plan", "exec") * @param additionalSystemInstructions - Optional instructions appended last * @param modelString - Active model identifier used for Model-specific sections + * @param mcpServers - Optional MCP server configuration (name -> command) * @throws Error if metadata or workspacePath invalid */ export async function buildSystemMessage( @@ -191,7 +214,8 @@ export async function buildSystemMessage( workspacePath: string, mode?: string, additionalSystemInstructions?: string, - modelString?: string + modelString?: string, + mcpServers?: MCPServerMap ): Promise { if (!metadata) throw new Error("Invalid workspace metadata: metadata is required"); if (!workspacePath) throw new Error("Invalid workspace path: workspacePath is required"); @@ -237,6 +261,11 @@ export async function buildSystemMessage( // Build system message let systemMessage = `${PRELUDE.trim()}\n\n${buildEnvironmentContext(workspacePath)}`; + // Add MCP context if servers are configured + if (mcpServers && Object.keys(mcpServers).length > 0) { + systemMessage += buildMCPContext(mcpServers); + } + if (customInstructions) { systemMessage += `\n\n${customInstructions}\n`; } From 30ab4e6728291d266722990472868d7ceecc9e73 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 17:43:07 -0600 Subject: [PATCH 14/25] =?UTF-8?q?=F0=9F=A4=96=20chore:=20remove=20test=20M?= =?UTF-8?q?CP=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .mux/mcp.jsonc | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .mux/mcp.jsonc diff --git a/.mux/mcp.jsonc b/.mux/mcp.jsonc deleted file mode 100644 index e97ad73d7b..0000000000 --- a/.mux/mcp.jsonc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "servers": { - "chrome": "npx -y chrome-devtools-mcp@latest --headless --chromeArg='--no-sandbox'" - } -} From 7c1422e98463925f4a2f075d88ffac12827f9bdc Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 17:52:58 -0600 Subject: [PATCH 15/25] =?UTF-8?q?=F0=9F=A4=96=20fix:=20transform=20MCP=20i?= =?UTF-8?q?mage=20content=20to=20AI=20SDK=20media=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP returns images with type 'image' and 'mimeType', but AI SDK expects type 'media' with 'mediaType'. This wraps MCP tool execute functions to transform the result format, enabling proper image handling for tools like Chrome DevTools MCP's take_screenshot. --- src/node/services/mcpServerManager.ts | 102 +++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 08568bc094..07fb6b9e24 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -9,6 +9,105 @@ import { createRuntime } from "@/node/runtime/runtimeFactory"; const TEST_TIMEOUT_MS = 10_000; +/** + * MCP CallToolResult content types (from @ai-sdk/mcp) + */ +interface MCPTextContent { + type: "text"; + text: string; +} + +interface MCPImageContent { + type: "image"; + data: string; // base64 + mimeType: string; +} + +interface MCPResourceContent { + type: "resource"; + resource: { uri: string; text?: string; blob?: string; mimeType?: string }; +} + +type MCPContent = MCPTextContent | MCPImageContent | MCPResourceContent; + +interface MCPCallToolResult { + content?: MCPContent[]; + isError?: boolean; + toolResult?: unknown; +} + +/** + * AI SDK LanguageModelV2ToolResultOutput content types + */ +type AISDKContentPart = + | { type: "text"; text: string } + | { type: "media"; data: string; mediaType: string }; + +/** + * Transform MCP tool result to AI SDK format. + * Converts MCP's "image" content type to AI SDK's "media" type. + */ +function transformMCPResult(result: MCPCallToolResult): unknown { + // If it's an error or has toolResult, pass through as-is + if (result.isError || result.toolResult !== undefined) { + return result; + } + + // If no content array, pass through + if (!result.content || !Array.isArray(result.content)) { + return result; + } + + // Check if any content is an image + const hasImage = result.content.some((c) => c.type === "image"); + if (!hasImage) { + return result; + } + + // Transform to AI SDK content format + const transformedContent: AISDKContentPart[] = result.content.map((item) => { + if (item.type === "text") { + return { type: "text" as const, text: item.text }; + } + if (item.type === "image") { + return { type: "media" as const, data: item.data, mediaType: item.mimeType }; + } + // For resource type, convert to text representation + if (item.type === "resource") { + const text = item.resource.text ?? item.resource.uri; + return { type: "text" as const, text }; + } + // Fallback: stringify unknown content + return { type: "text" as const, text: JSON.stringify(item) }; + }); + + return { type: "content", value: transformedContent }; +} + +/** + * Wrap MCP tools to transform their results to AI SDK format. + * This ensures image content is properly converted to media type. + */ +function wrapMCPTools(tools: Record): Record { + const wrapped: Record = {}; + for (const [name, tool] of Object.entries(tools)) { + // Only wrap tools that have an execute function + if (!tool.execute) { + wrapped[name] = tool; + continue; + } + const originalExecute = tool.execute; + wrapped[name] = { + ...tool, + execute: async (args: Parameters[0], options) => { + const result: unknown = await originalExecute(args, options); + return transformMCPResult(result as MCPCallToolResult); + }, + }; + } + return wrapped; +} + export type MCPTestResult = { success: true; tools: string[] } | { success: false; error: string }; interface MCPServerInstance { @@ -231,7 +330,8 @@ export class MCPServerManager { await transport.start(); const client = await experimental_createMCPClient({ transport }); - const tools = await client.tools(); + const rawTools = await client.tools(); + const tools = wrapMCPTools(rawTools); const toolNames = Object.keys(tools); log.info("[MCP] Server ready", { name, tools: toolNames }); From f3e63b2706e26127546350c397af12fc4d1d67b6 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 18:09:00 -0600 Subject: [PATCH 16/25] fix: ensure mediaType is present in MCP image transformation - Add fallback to 'image/png' when MCP mimeType is undefined - JSON.stringify omits undefined values, which caused mediaType to be missing from tool results, breaking image rendering in Anthropic provider - Add debug logging to trace MCP image content transformation - Add integration test verifying Chrome DevTools MCP screenshot handling --- src/node/services/mcpServerManager.ts | 14 ++- tests/ipc/mcpConfig.test.ts | 138 ++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 1 deletion(-) diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 07fb6b9e24..6d725add6b 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -64,13 +64,25 @@ function transformMCPResult(result: MCPCallToolResult): unknown { return result; } + // Debug: log what we received from MCP + log.debug("[MCP] transformMCPResult input", { + contentTypes: result.content.map((c) => c.type), + imageItems: result.content + .filter((c): c is MCPImageContent => c.type === "image") + .map((c) => ({ type: c.type, mimeType: c.mimeType, dataLen: c.data?.length })), + }); + // Transform to AI SDK content format const transformedContent: AISDKContentPart[] = result.content.map((item) => { if (item.type === "text") { return { type: "text" as const, text: item.text }; } if (item.type === "image") { - return { type: "media" as const, data: item.data, mediaType: item.mimeType }; + const imageItem = item; + // Ensure mediaType is present - default to image/png if missing + const mediaType = imageItem.mimeType || "image/png"; + log.debug("[MCP] Transforming image content", { mimeType: imageItem.mimeType, mediaType }); + return { type: "media" as const, data: imageItem.data, mediaType }; } // For resource type, convert to text representation if (item.type === "resource") { diff --git a/tests/ipc/mcpConfig.test.ts b/tests/ipc/mcpConfig.test.ts index 3c4aefb238..48a87997a5 100644 --- a/tests/ipc/mcpConfig.test.ts +++ b/tests/ipc/mcpConfig.test.ts @@ -75,6 +75,144 @@ describeIntegration("MCP project configuration", () => { }); describeIntegration("MCP server integration with model", () => { + test.concurrent( + "MCP image content is correctly transformed to AI SDK format", + async () => { + console.log("[MCP Image Test] Setting up workspace..."); + // Setup workspace with Anthropic provider + const { env, workspaceId, tempGitRepo, cleanup } = await setupWorkspace( + "anthropic", + "mcp-chrome" + ); + const client = resolveOrpcClient(env); + console.log("[MCP Image Test] Workspace created:", { workspaceId, tempGitRepo }); + + try { + // Add the Chrome DevTools MCP server to the project + // Use --headless and --no-sandbox for CI/root environments + console.log("[MCP Image Test] Adding Chrome DevTools MCP server..."); + const addResult = await client.projects.mcp.add({ + projectPath: tempGitRepo, + name: "chrome", + command: + "npx -y chrome-devtools-mcp@latest --headless --isolated --chromeArg='--no-sandbox'", + }); + expect(addResult.success).toBe(true); + console.log("[MCP Image Test] MCP server added"); + + // Create stream collector to capture events + console.log("[MCP Image Test] Creating stream collector..."); + const collector = createStreamCollector(env.orpc, workspaceId); + collector.start(); + await collector.waitForSubscription(); + console.log("[MCP Image Test] Stream collector ready"); + + // Send a message that should trigger screenshot + // First navigate to a simple page, then take a screenshot + console.log("[MCP Image Test] Sending message..."); + const result = await sendMessageWithModel( + env, + workspaceId, + "Navigate to https://example.com and take a screenshot. Describe what you see in the screenshot.", + HAIKU_MODEL + ); + console.log("[MCP Image Test] Message sent, result:", result.success); + + expect(result.success).toBe(true); + + // Wait for stream to complete (this may take a while with Chrome) + console.log("[MCP Image Test] Waiting for stream-end..."); + await collector.waitForEvent("stream-end", 120000); // 2 minutes for Chrome operations + console.log("[MCP Image Test] Stream ended"); + assertStreamSuccess(collector); + + // Find the screenshot tool call and its result + const events = collector.getEvents(); + const toolCallEnds = events.filter( + (e): e is Extract => e.type === "tool-call-end" + ); + console.log( + "[MCP Image Test] Tool call ends:", + toolCallEnds.map((e) => ({ toolName: e.toolName, resultType: typeof e.result })) + ); + + // Find the screenshot tool result + const screenshotResult = toolCallEnds.find((e) => e.toolName === "take_screenshot"); + expect(screenshotResult).toBeDefined(); + + // Verify the result has correct AI SDK format with mediaType + const result_output = screenshotResult!.result as + | { type: string; value: unknown[] } + | unknown; + // Log media items to verify mediaType presence + if ( + typeof result_output === "object" && + result_output !== null && + "value" in result_output + ) { + const value = (result_output as { value: unknown[] }).value; + const mediaPreview = value + .filter( + (v): v is object => + typeof v === "object" && + v !== null && + "type" in v && + (v as { type: string }).type === "media" + ) + .map((m) => ({ + type: (m as { type: string }).type, + mediaType: (m as { mediaType?: string }).mediaType, + dataLen: ((m as { data?: string }).data || "").length, + })); + console.log("[MCP Image Test] Media items:", JSON.stringify(mediaPreview)); + } + + // If it's properly transformed, it should have { type: "content", value: [...] } + if ( + typeof result_output === "object" && + result_output !== null && + "type" in result_output + ) { + const typedResult = result_output as { type: string; value: unknown[] }; + expect(typedResult.type).toBe("content"); + expect(Array.isArray(typedResult.value)).toBe(true); + + // Check for media content with mediaType + const mediaItems = typedResult.value.filter( + (item): item is { type: "media"; data: string; mediaType: string } => + typeof item === "object" && + item !== null && + "type" in item && + (item as { type: string }).type === "media" + ); + + expect(mediaItems.length).toBeGreaterThan(0); + // Verify mediaType is present and is a valid image type + for (const media of mediaItems) { + expect(media.mediaType).toBeDefined(); + expect(media.mediaType).toMatch(/^image\//); + expect(media.data).toBeDefined(); + expect(media.data.length).toBeGreaterThan(100); // Should have actual image data + } + } + + // Verify model's response mentions seeing something (proves it understood the image) + const deltas = collector.getDeltas(); + const responseText = extractTextFromEvents(deltas).toLowerCase(); + console.log("[MCP Image Test] Response text preview:", responseText.slice(0, 200)); + // Model should describe something it sees - domain name, content, or visual elements + expect(responseText).toMatch(/example|domain|website|page|text|heading|title/i); + + collector.stop(); + } finally { + console.log("[MCP Image Test] Cleaning up..."); + await cleanup(); + console.log("[MCP Image Test] Done"); + } + }, + 180000 // 3 minutes - Chrome operations can be slow + ); + test.concurrent( "MCP tools are available to the model", async () => { From 512aead41d318047ceb9fd1d46ff0e44c03063ce Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 18:42:55 -0600 Subject: [PATCH 17/25] docs: update MCP examples to chrome/memory, add slash command docs --- docs/mcp-servers.mdx | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/mcp-servers.mdx b/docs/mcp-servers.mdx index 85333bd637..93c5d4e90e 100644 --- a/docs/mcp-servers.mdx +++ b/docs/mcp-servers.mdx @@ -13,23 +13,42 @@ Create `.mux/mcp.jsonc` in your project root: { "servers": { // Knowledge graph for persistent memory - "memory": "npx -y @anthropic-ai/mcp-server-memory", - // Access external APIs - "github": "npx -y @modelcontextprotocol/server-github", + "memory": "npx -y @modelcontextprotocol/server-memory", + // Browser automation and screenshots + "chrome": "npx -y chrome-devtools-mcp@latest --headless", }, } ``` Each entry maps a server name to its shell command. The command must start a process that speaks MCP over stdio (NDJSON format). +## Slash Commands + +Manage MCP servers directly from chat: + +| Command | Description | +| ---------------------------- | ----------------------------------- | +| `/mcp add ` | Add a new MCP server | +| `/mcp remove ` | Remove an MCP server | +| `/mcp edit ` | Update an existing server's command | + +Examples: + +``` +/mcp add memory npx -y @modelcontextprotocol/server-memory +/mcp add chrome npx -y chrome-devtools-mcp@latest --headless +/mcp remove github +/mcp edit chrome npx -y chrome-devtools-mcp@latest --headless --isolated +``` + ## Settings UI Configure servers in **Settings → Projects**: 1. Select a project from the dropdown 2. Add servers with name and command -3. Use **Test** to verify before adding -4. View available tools after successful test +3. Use **Test** (play button) to verify before adding +4. Use **Edit** (pencil) or **Remove** (trash) to manage existing servers ## Behavior From 899b0a34739bbafec0790b6c8626aa5033580b44 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 19:26:47 -0600 Subject: [PATCH 18/25] refactor: DRY and consolidate MCP types - Extract runServerTest() helper to deduplicate testServer/testCommand - Move MCPTestResult type to common/types/mcp.ts (single source of truth) - Remove redundant nullish coalescing where getConfig() guarantees non-null - Net reduction of 29 lines --- .../sections/ProjectSettingsSection.tsx | 7 +- src/common/types/mcp.ts | 3 + src/node/services/mcpConfigService.ts | 5 +- src/node/services/mcpServerManager.ts | 150 +++++++----------- 4 files changed, 68 insertions(+), 97 deletions(-) diff --git a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx index 386331290e..897f1a52b0 100644 --- a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx @@ -15,8 +15,7 @@ import { X, } from "lucide-react"; import { createEditKeyHandler } from "@/browser/utils/ui/keybinds"; - -type TestResult = { success: true; tools: string[] } | { success: false; error: string }; +import type { MCPTestResult } from "@/common/types/mcp"; export const ProjectSettingsSection: React.FC = () => { const { api } = useAPI(); @@ -28,14 +27,14 @@ export const ProjectSettingsSection: React.FC = () => { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [testingServer, setTestingServer] = useState(null); - const [testResults, setTestResults] = useState>(new Map()); + const [testResults, setTestResults] = useState>(new Map()); // Add server form state const [newServerName, setNewServerName] = useState(""); const [newServerCommand, setNewServerCommand] = useState(""); const [addingServer, setAddingServer] = useState(false); const [testingNewCommand, setTestingNewCommand] = useState(false); - const [newCommandTestResult, setNewCommandTestResult] = useState(null); + const [newCommandTestResult, setNewCommandTestResult] = useState(null); // Edit server state const [editingServer, setEditingServer] = useState(null); diff --git a/src/common/types/mcp.ts b/src/common/types/mcp.ts index 15e7c8e0af..76527357ca 100644 --- a/src/common/types/mcp.ts +++ b/src/common/types/mcp.ts @@ -3,3 +3,6 @@ export interface MCPConfig { } export type MCPServerMap = Record; + +/** Result of testing an MCP server connection */ +export type MCPTestResult = { success: true; tools: string[] } | { success: false; error: string }; diff --git a/src/node/services/mcpConfigService.ts b/src/node/services/mcpConfigService.ts index 12cdcff782..ccd5583eb5 100644 --- a/src/node/services/mcpConfigService.ts +++ b/src/node/services/mcpConfigService.ts @@ -40,7 +40,7 @@ export class MCPConfigService { if (!parsed || typeof parsed !== "object" || !parsed.servers) { return { servers: {} }; } - return { servers: parsed.servers ?? {} }; + return { servers: parsed.servers }; } catch (error) { log.error("Failed to read MCP config", { projectPath, error }); return { servers: {} }; @@ -55,7 +55,7 @@ export class MCPConfigService { async listServers(projectPath: string): Promise { const cfg = await this.getConfig(projectPath); - return cfg.servers ?? {}; + return cfg.servers; } async addServer(projectPath: string, name: string, command: string): Promise> { @@ -67,7 +67,6 @@ export class MCPConfigService { } const cfg = await this.getConfig(projectPath); - cfg.servers = cfg.servers ?? {}; cfg.servers[name] = command; try { diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 6d725add6b..ab74629d37 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -2,7 +2,7 @@ import { experimental_createMCPClient, type MCPTransport } from "@ai-sdk/mcp"; import type { Tool } from "ai"; import { log } from "@/node/services/log"; import { MCPStdioTransport } from "@/node/services/mcpStdioTransport"; -import type { MCPServerMap } from "@/common/types/mcp"; +import type { MCPServerMap, MCPTestResult } from "@/common/types/mcp"; import type { Runtime } from "@/node/runtime/Runtime"; import type { MCPConfigService } from "@/node/services/mcpConfigService"; import { createRuntime } from "@/node/runtime/runtimeFactory"; @@ -120,7 +120,56 @@ function wrapMCPTools(tools: Record): Record { return wrapped; } -export type MCPTestResult = { success: true; tools: string[] } | { success: false; error: string }; +export type { MCPTestResult } from "@/common/types/mcp"; + +/** + * Run a test connection to an MCP server command. + * Spawns the process, connects, fetches tools, then closes. + */ +async function runServerTest( + command: string, + projectPath: string, + logContext: string +): Promise { + const runtime = createRuntime({ type: "local", srcBaseDir: projectPath }); + const timeoutPromise = new Promise((resolve) => + setTimeout(() => resolve({ success: false, error: "Connection timed out" }), TEST_TIMEOUT_MS) + ); + + const testPromise = (async (): Promise => { + let transport: MCPStdioTransport | null = null; + try { + log.debug(`[MCP] Testing ${logContext}`, { command }); + const execStream = await runtime.exec(command, { + cwd: projectPath, + timeout: TEST_TIMEOUT_MS / 1000, + }); + + transport = new MCPStdioTransport(execStream); + await transport.start(); + const client = await experimental_createMCPClient({ transport }); + const tools = await client.tools(); + const toolNames = Object.keys(tools); + await client.close(); + await transport.close(); + log.info(`[MCP] ${logContext} test successful`, { tools: toolNames }); + return { success: true, tools: toolNames }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.warn(`[MCP] ${logContext} test failed`, { error: message }); + if (transport) { + try { + await transport.close(); + } catch { + // ignore cleanup errors + } + } + return { success: false, error: message }; + } + })(); + + return Promise.race([testPromise, timeoutPromise]); +} interface MCPServerInstance { name: string; @@ -155,21 +204,18 @@ export class MCPServerManager { }): Promise> { const { workspaceId, projectPath, runtime, workspacePath } = options; const servers = await this.configService.listServers(projectPath); - const signature = JSON.stringify(servers ?? {}); - const serverCount = Object.keys(servers ?? {}).length; + const signature = JSON.stringify(servers); + const serverNames = Object.keys(servers); const existing = this.workspaceServers.get(workspaceId); if (existing?.configSignature === signature) { - log.debug("[MCP] Using cached servers", { workspaceId, serverCount }); + log.debug("[MCP] Using cached servers", { workspaceId, serverCount: serverNames.length }); return this.collectTools(existing.instances); } // Config changed or not started yet -> restart - if (serverCount > 0) { - log.info("[MCP] Starting servers", { - workspaceId, - servers: Object.keys(servers ?? {}), - }); + if (serverNames.length > 0) { + log.info("[MCP] Starting servers", { workspaceId, servers: serverNames }); } await this.stopServers(workspaceId); const instances = await this.startServers(servers, runtime, workspacePath); @@ -201,49 +247,11 @@ export class MCPServerManager { */ async testServer(projectPath: string, name: string): Promise { const servers = await this.configService.listServers(projectPath); - const command = servers?.[name]; + const command = servers[name]; if (!command) { return { success: false, error: `Server "${name}" not found in configuration` }; } - - const runtime = createRuntime({ type: "local", srcBaseDir: projectPath }); - const timeoutPromise = new Promise((resolve) => - setTimeout(() => resolve({ success: false, error: "Connection timed out" }), TEST_TIMEOUT_MS) - ); - - const testPromise = (async (): Promise => { - let transport: MCPStdioTransport | null = null; - try { - log.debug("[MCP] Testing server", { name, command }); - const execStream = await runtime.exec(command, { - cwd: projectPath, - timeout: TEST_TIMEOUT_MS / 1000, - }); - - transport = new MCPStdioTransport(execStream); - await transport.start(); - const client = await experimental_createMCPClient({ transport }); - const tools = await client.tools(); - const toolNames = Object.keys(tools); - await client.close(); - await transport.close(); - log.info("[MCP] Test successful", { name, tools: toolNames }); - return { success: true, tools: toolNames }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log.warn("[MCP] Test failed", { name, error: message }); - if (transport) { - try { - await transport.close(); - } catch { - // ignore cleanup errors - } - } - return { success: false, error: message }; - } - })(); - - return Promise.race([testPromise, timeoutPromise]); + return runServerTest(command, projectPath, `server "${name}"`); } /** @@ -254,45 +262,7 @@ export class MCPServerManager { if (!command.trim()) { return { success: false, error: "Command is required" }; } - - const runtime = createRuntime({ type: "local", srcBaseDir: projectPath }); - const timeoutPromise = new Promise((resolve) => - setTimeout(() => resolve({ success: false, error: "Connection timed out" }), TEST_TIMEOUT_MS) - ); - - const testPromise = (async (): Promise => { - let transport: MCPStdioTransport | null = null; - try { - log.debug("[MCP] Testing command", { command }); - const execStream = await runtime.exec(command, { - cwd: projectPath, - timeout: TEST_TIMEOUT_MS / 1000, - }); - - transport = new MCPStdioTransport(execStream); - await transport.start(); - const client = await experimental_createMCPClient({ transport }); - const tools = await client.tools(); - const toolNames = Object.keys(tools); - await client.close(); - await transport.close(); - log.info("[MCP] Command test successful", { command, tools: toolNames }); - return { success: true, tools: toolNames }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log.warn("[MCP] Command test failed", { command, error: message }); - if (transport) { - try { - await transport.close(); - } catch { - // ignore cleanup errors - } - } - return { success: false, error: message }; - } - })(); - - return Promise.race([testPromise, timeoutPromise]); + return runServerTest(command, projectPath, "command"); } private collectTools(instances: Map): Record { @@ -309,7 +279,7 @@ export class MCPServerManager { workspacePath: string ): Promise> { const result = new Map(); - const entries = Object.entries(servers ?? {}); + const entries = Object.entries(servers); for (const [name, command] of entries) { try { const instance = await this.startSingleServer(name, command, runtime, workspacePath); From 37bbe8fc3ef350aceb03dc3dca57c69638154a5b Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 20:00:08 -0600 Subject: [PATCH 19/25] docs: fix incorrect logs path in MCP troubleshooting --- docs/mcp-servers.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mcp-servers.mdx b/docs/mcp-servers.mdx index 93c5d4e90e..74cb9ae1c0 100644 --- a/docs/mcp-servers.mdx +++ b/docs/mcp-servers.mdx @@ -66,4 +66,4 @@ If a server fails to start: 1. **Test the command manually** — Run the command in your terminal to verify it works 2. **Check dependencies** — Ensure required packages are installed (`npx -y` downloads on first run) -3. **Review logs** — Server errors appear in mux logs (`~/.mux/logs/`) +3. **Use the Test button** — Settings → Projects shows connection errors inline From 37720ce5149ec2f6257ae5415e5a4f57858a8765 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 20:17:52 -0600 Subject: [PATCH 20/25] refactor: address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Consolidate ProjectSettingsSection state (18 useState → grouped objects + custom hook) - Extract parseMCPNameCommand helper to dedupe add/edit parsing in registry.ts - Unify MCP test API (testServer + testCommand → single test endpoint) - Add localStorage caching for test results with CachedMCPTestResult type - Show test age in Settings UI (formatRelativeTime) - Cache successful test results when adding servers --- .../sections/ProjectSettingsSection.tsx | 232 +++++++++++------- src/browser/utils/slashCommands/registry.ts | 44 ++-- src/common/constants/storage.ts | 9 + src/common/orpc/schemas.ts | 1 - src/common/orpc/schemas/api.ts | 5 - src/common/orpc/schemas/mcp.ts | 12 +- src/common/types/mcp.ts | 6 + src/node/orpc/router.ts | 8 +- src/node/services/mcpConfigService.ts | 2 +- src/node/services/mcpServerManager.ts | 32 ++- 10 files changed, 203 insertions(+), 148 deletions(-) diff --git a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx index 897f1a52b0..dca0dae90a 100644 --- a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useAPI } from "@/browser/contexts/API"; import { useProjectContext } from "@/browser/contexts/ProjectContext"; import { @@ -15,30 +15,87 @@ import { X, } from "lucide-react"; import { createEditKeyHandler } from "@/browser/utils/ui/keybinds"; -import type { MCPTestResult } from "@/common/types/mcp"; +import { formatRelativeTime } from "@/browser/utils/ui/dateTime"; +import type { CachedMCPTestResult } from "@/common/types/mcp"; +import { getMCPTestResultsKey } from "@/common/constants/storage"; +import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; + +type CachedResults = Record; + +/** Hook to manage MCP test results with localStorage caching */ +function useMCPTestCache(projectPath: string) { + const storageKey = useMemo( + () => (projectPath ? getMCPTestResultsKey(projectPath) : ""), + [projectPath] + ); + + const [cache, setCache] = useState(() => + storageKey ? readPersistedState(storageKey, {}) : {} + ); + + // Reload cache when project changes + useEffect(() => { + if (storageKey) { + setCache(readPersistedState(storageKey, {})); + } else { + setCache({}); + } + }, [storageKey]); + + const setResult = useCallback( + (name: string, result: CachedMCPTestResult["result"]) => { + const entry: CachedMCPTestResult = { result, testedAt: Date.now() }; + setCache((prev) => { + const next = { ...prev, [name]: entry }; + if (storageKey) updatePersistedState(storageKey, next); + return next; + }); + }, + [storageKey] + ); + + const clearResult = useCallback( + (name: string) => { + setCache((prev) => { + const next = { ...prev }; + delete next[name]; + if (storageKey) updatePersistedState(storageKey, next); + return next; + }); + }, + [storageKey] + ); + + return { cache, setResult, clearResult }; +} export const ProjectSettingsSection: React.FC = () => { const { api } = useAPI(); const { projects } = useProjectContext(); const projectList = Array.from(projects.keys()); + // Core state const [selectedProject, setSelectedProject] = useState(""); const [servers, setServers] = useState>({}); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + + // Test state with caching + const { + cache: testCache, + setResult: cacheTestResult, + clearResult: clearTestResult, + } = useMCPTestCache(selectedProject); const [testingServer, setTestingServer] = useState(null); - const [testResults, setTestResults] = useState>(new Map()); - // Add server form state - const [newServerName, setNewServerName] = useState(""); - const [newServerCommand, setNewServerCommand] = useState(""); + // Add form state + const [newServer, setNewServer] = useState({ name: "", command: "" }); const [addingServer, setAddingServer] = useState(false); - const [testingNewCommand, setTestingNewCommand] = useState(false); - const [newCommandTestResult, setNewCommandTestResult] = useState(null); + const [testingNew, setTestingNew] = useState(false); + const [newTestResult, setNewTestResult] = useState(null); - // Edit server state - const [editingServer, setEditingServer] = useState(null); - const [editCommand, setEditCommand] = useState(""); + // Edit state + const [editing, setEditing] = useState<{ name: string; command: string } | null>(null); const [savingEdit, setSavingEdit] = useState(false); // Set default project when projects load @@ -64,13 +121,12 @@ export const ProjectSettingsSection: React.FC = () => { useEffect(() => { void refresh(); - setTestResults(new Map()); }, [refresh]); // Clear new command test result when command changes useEffect(() => { - setNewCommandTestResult(null); - }, [newServerCommand]); + setNewTestResult(null); + }, [newServer.command]); const handleRemove = useCallback( async (name: string) => { @@ -81,6 +137,7 @@ export const ProjectSettingsSection: React.FC = () => { if (!result.success) { setError(result.error ?? "Failed to remove MCP server"); } else { + clearTestResult(name); await refresh(); } } catch (err) { @@ -89,71 +146,67 @@ export const ProjectSettingsSection: React.FC = () => { setLoading(false); } }, - [api, selectedProject, refresh] + [api, selectedProject, refresh, clearTestResult] ); const handleTest = useCallback( async (name: string) => { if (!api || !selectedProject) return; setTestingServer(name); - setTestResults((prev) => { - const next = new Map(prev); - next.delete(name); - return next; - }); try { const result = await api.projects.mcp.test({ projectPath: selectedProject, name }); - setTestResults((prev) => new Map(prev).set(name, result)); + cacheTestResult(name, result); } catch (err) { - setTestResults((prev) => - new Map(prev).set(name, { - success: false, - error: err instanceof Error ? err.message : "Test failed", - }) - ); + cacheTestResult(name, { + success: false, + error: err instanceof Error ? err.message : "Test failed", + }); } finally { setTestingServer(null); } }, - [api, selectedProject] + [api, selectedProject, cacheTestResult] ); const handleTestNewCommand = useCallback(async () => { - if (!api || !selectedProject || !newServerCommand.trim()) return; - setTestingNewCommand(true); - setNewCommandTestResult(null); + if (!api || !selectedProject || !newServer.command.trim()) return; + setTestingNew(true); + setNewTestResult(null); try { - const result = await api.projects.mcp.testCommand({ + const result = await api.projects.mcp.test({ projectPath: selectedProject, - command: newServerCommand.trim(), + command: newServer.command.trim(), }); - setNewCommandTestResult(result); + setNewTestResult({ result, testedAt: Date.now() }); } catch (err) { - setNewCommandTestResult({ - success: false, - error: err instanceof Error ? err.message : "Test failed", + setNewTestResult({ + result: { success: false, error: err instanceof Error ? err.message : "Test failed" }, + testedAt: Date.now(), }); } finally { - setTestingNewCommand(false); + setTestingNew(false); } - }, [api, selectedProject, newServerCommand]); + }, [api, selectedProject, newServer.command]); const handleAddServer = useCallback(async () => { - if (!api || !selectedProject || !newServerName.trim() || !newServerCommand.trim()) return; + if (!api || !selectedProject || !newServer.name.trim() || !newServer.command.trim()) return; setAddingServer(true); setError(null); try { const result = await api.projects.mcp.add({ projectPath: selectedProject, - name: newServerName.trim(), - command: newServerCommand.trim(), + name: newServer.name.trim(), + command: newServer.command.trim(), }); if (!result.success) { setError(result.error ?? "Failed to add MCP server"); } else { - setNewServerName(""); - setNewServerCommand(""); - setNewCommandTestResult(null); + // Cache the test result if we have one + if (newTestResult?.result.success) { + cacheTestResult(newServer.name.trim(), newTestResult.result); + } + setNewServer({ name: "", command: "" }); + setNewTestResult(null); await refresh(); } } catch (err) { @@ -161,39 +214,32 @@ export const ProjectSettingsSection: React.FC = () => { } finally { setAddingServer(false); } - }, [api, selectedProject, newServerName, newServerCommand, refresh]); + }, [api, selectedProject, newServer, newTestResult, refresh, cacheTestResult]); const handleStartEdit = useCallback((name: string, command: string) => { - setEditingServer(name); - setEditCommand(command); + setEditing({ name, command }); }, []); const handleCancelEdit = useCallback(() => { - setEditingServer(null); - setEditCommand(""); + setEditing(null); }, []); const handleSaveEdit = useCallback(async () => { - if (!api || !selectedProject || !editingServer || !editCommand.trim()) return; + if (!api || !selectedProject || !editing?.command.trim()) return; setSavingEdit(true); setError(null); try { const result = await api.projects.mcp.add({ projectPath: selectedProject, - name: editingServer, - command: editCommand.trim(), + name: editing.name, + command: editing.command.trim(), }); if (!result.success) { setError(result.error ?? "Failed to update MCP server"); } else { - setEditingServer(null); - setEditCommand(""); - // Clear test result for this server since command changed - setTestResults((prev) => { - const next = new Map(prev); - next.delete(editingServer); - return next; - }); + // Clear cached test result since command changed + clearTestResult(editing.name); + setEditing(null); await refresh(); } } catch (err) { @@ -201,7 +247,7 @@ export const ProjectSettingsSection: React.FC = () => { } finally { setSavingEdit(false); } - }, [api, selectedProject, editingServer, editCommand, refresh]); + }, [api, selectedProject, editing, refresh, clearTestResult]); if (projectList.length === 0) { return ( @@ -215,8 +261,8 @@ export const ProjectSettingsSection: React.FC = () => { } const projectName = (path: string) => path.split(/[\\/]/).pop() ?? path; - const canAdd = newServerName.trim() && newServerCommand.trim(); - const canTest = newServerCommand.trim(); + const canAdd = newServer.name.trim() && newServer.command.trim(); + const canTest = newServer.command.trim(); return (
    @@ -274,25 +320,28 @@ export const ProjectSettingsSection: React.FC = () => {
      {Object.entries(servers).map(([name, command]) => { const isTesting = testingServer === name; - const testResult = testResults.get(name); - const isEditing = editingServer === name; + const cached = testCache[name]; + const isEditing = editing?.name === name; return (
    • {name} - {testResult?.success && !isEditing && ( - - {testResult.tools.length} tools + {cached?.result.success && !isEditing && ( + + {cached.result.tools.length} tools )}
      {isEditing ? ( setEditCommand(e.target.value)} + value={editing.command} + onChange={(e) => setEditing({ ...editing, command: e.target.value })} className="border-border-medium bg-secondary/30 text-foreground placeholder:text-muted-foreground focus:ring-accent mt-1 w-full rounded-md border px-2 py-1 font-mono text-xs focus:ring-1 focus:outline-none" autoFocus spellCheck={false} @@ -313,7 +362,7 @@ export const ProjectSettingsSection: React.FC = () => {
      - {testResult && !testResult.success && !isEditing && ( + {cached && !cached.result.success && !isEditing && (
      - {testResult.error} + {cached.result.error}
      )} - {testResult?.success && testResult.tools.length > 0 && !isEditing && ( + {cached?.result.success && cached.result.tools.length > 0 && !isEditing && (

      - Tools: {testResult.tools.join(", ")} + Tools: {cached.result.tools.join(", ")} + + ({formatRelativeTime(cached.testedAt)}) +

      )}
    • @@ -398,8 +450,8 @@ export const ProjectSettingsSection: React.FC = () => { id="server-name" type="text" placeholder="e.g., memory" - value={newServerName} - onChange={(e) => setNewServerName(e.target.value)} + value={newServer.name} + onChange={(e) => setNewServer((prev) => ({ ...prev, name: e.target.value }))} className="border-border-medium bg-secondary/30 text-foreground placeholder:text-muted-foreground focus:ring-accent w-full rounded-md border px-3 py-2 text-sm focus:ring-1 focus:outline-none" />
    @@ -411,32 +463,32 @@ export const ProjectSettingsSection: React.FC = () => { id="server-command" type="text" placeholder="e.g., npx -y @modelcontextprotocol/server-memory" - value={newServerCommand} - onChange={(e) => setNewServerCommand(e.target.value)} + value={newServer.command} + onChange={(e) => setNewServer((prev) => ({ ...prev, command: e.target.value }))} spellCheck={false} className="border-border-medium bg-secondary/30 text-foreground placeholder:text-muted-foreground focus:ring-accent w-full rounded-md border px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none" />
    {/* Test result for new command */} - {newCommandTestResult && ( + {newTestResult && (
    - {newCommandTestResult.success ? ( + {newTestResult.result.success ? ( <>
    - Connection successful — {newCommandTestResult.tools.length} tools available + Connection successful — {newTestResult.result.tools.length} tools available - {newCommandTestResult.tools.length > 0 && ( + {newTestResult.result.tools.length > 0 && (

    - {newCommandTestResult.tools.join(", ")} + {newTestResult.result.tools.join(", ")}

    )}
    @@ -444,7 +496,7 @@ export const ProjectSettingsSection: React.FC = () => { ) : ( <> - {newCommandTestResult.error} + {newTestResult.result.error} )}
    @@ -454,15 +506,15 @@ export const ProjectSettingsSection: React.FC = () => { -