Skip to content

Commit d4911b6

Browse files
committed
🤖 feat: add MCP server configuration and runtime
1 parent ea494db commit d4911b6

File tree

25 files changed

+715
-6
lines changed

25 files changed

+715
-6
lines changed

bun.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"@ai-sdk/amazon-bedrock": "^3.0.61",
99
"@ai-sdk/anthropic": "^2.0.47",
1010
"@ai-sdk/google": "^2.0.43",
11+
"@ai-sdk/mcp": "^0.0.11",
1112
"@ai-sdk/openai": "^2.0.72",
1213
"@ai-sdk/xai": "^2.0.36",
1314
"@aws-sdk/credential-providers": "^3.940.0",
@@ -174,6 +175,8 @@
174175

175176
"@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=="],
176177

178+
"@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=="],
179+
177180
"@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=="],
178181

179182
"@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=="],
@@ -2988,6 +2991,8 @@
29882991

29892992
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
29902993

2994+
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
2995+
29912996
"pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="],
29922997

29932998
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"@ai-sdk/amazon-bedrock": "^3.0.61",
4949
"@ai-sdk/anthropic": "^2.0.47",
5050
"@ai-sdk/google": "^2.0.43",
51+
"@ai-sdk/mcp": "^0.0.11",
5152
"@ai-sdk/openai": "^2.0.72",
5253
"@ai-sdk/xai": "^2.0.36",
5354
"@aws-sdk/credential-providers": "^3.940.0",

src/browser/components/ChatInput/index.tsx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { ChatInputToast } from "../ChatInputToast";
1414
import { createCommandToast, createErrorToast } from "../ChatInputToasts";
1515
import { parseCommand } from "@/browser/utils/slashCommands/parser";
1616
import { usePersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
17+
import { useSettings } from "@/browser/contexts/SettingsContext";
18+
import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext";
1719
import { useMode } from "@/browser/contexts/ModeContext";
1820
import { ThinkingSliderComponent } from "../ThinkingSlider";
1921
import { ModelSettings } from "../ModelSettings";
@@ -167,6 +169,8 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
167169
[setInput]
168170
);
169171
const preEditDraftRef = useRef<DraftState>({ text: "", images: [] });
172+
const { open } = useSettings();
173+
const { selectedWorkspace } = useWorkspaceContext();
170174
const [mode, setMode] = useMode();
171175
const { recentModels, addModel, defaultModel, setDefaultModel } = useModelLRU();
172176
const commandListId = useId();
@@ -730,6 +734,75 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
730734
}
731735

732736
// Handle /vim command
737+
if (parsed.type === "mcp-open") {
738+
setInput("");
739+
open("project");
740+
return;
741+
}
742+
743+
if (parsed.type === "mcp-add" || parsed.type === "mcp-remove") {
744+
if (!api) {
745+
setToast({
746+
id: Date.now().toString(),
747+
type: "error",
748+
message: "Not connected to server",
749+
});
750+
return;
751+
}
752+
if (!selectedWorkspace?.projectPath) {
753+
setToast({
754+
id: Date.now().toString(),
755+
type: "error",
756+
message: "Select a workspace to manage MCP servers",
757+
});
758+
return;
759+
}
760+
761+
setIsSending(true);
762+
setInput("");
763+
try {
764+
const projectPath = selectedWorkspace.projectPath;
765+
const result =
766+
parsed.type === "mcp-add"
767+
? await api.projects.mcp.add({
768+
projectPath,
769+
name: parsed.name,
770+
command: parsed.command,
771+
})
772+
: await api.projects.mcp.remove({ projectPath, name: parsed.name });
773+
774+
if (!result.success) {
775+
setToast({
776+
id: Date.now().toString(),
777+
type: "error",
778+
message: result.error ?? "Failed to update MCP servers",
779+
});
780+
setInput(messageText);
781+
} else {
782+
setToast({
783+
id: Date.now().toString(),
784+
type: "success",
785+
message:
786+
parsed.type === "mcp-add"
787+
? `Added MCP server ${parsed.name}`
788+
: `Removed MCP server ${parsed.name}`,
789+
});
790+
}
791+
} catch (error) {
792+
console.error("Failed to update MCP servers", error);
793+
setToast({
794+
id: Date.now().toString(),
795+
type: "error",
796+
message: error instanceof Error ? error.message : "Failed to update MCP servers",
797+
});
798+
setInput(messageText);
799+
} finally {
800+
setIsSending(false);
801+
}
802+
803+
return;
804+
}
805+
733806
if (parsed.type === "vim-toggle") {
734807
setInput(""); // Clear input immediately
735808
setVimEnabled((prev) => !prev);

src/browser/components/Settings/SettingsModal.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import React from "react";
2-
import { Settings, Key, Cpu, X } from "lucide-react";
2+
import { Settings, Key, Cpu, X, Briefcase } from "lucide-react";
33
import { useSettings } from "@/browser/contexts/SettingsContext";
44
import { Dialog, DialogContent, DialogTitle, VisuallyHidden } from "@/browser/components/ui/dialog";
55
import { GeneralSection } from "./sections/GeneralSection";
66
import { ProvidersSection } from "./sections/ProvidersSection";
77
import { ModelsSection } from "./sections/ModelsSection";
88
import { Button } from "@/browser/components/ui/button";
9+
import { ProjectSettingsSection } from "./sections/ProjectSettingsSection";
910
import type { SettingsSection } from "./types";
1011

1112
const SECTIONS: SettingsSection[] = [
@@ -21,6 +22,12 @@ const SECTIONS: SettingsSection[] = [
2122
icon: <Key className="h-4 w-4" />,
2223
component: ProvidersSection,
2324
},
25+
{
26+
id: "project",
27+
label: "Project",
28+
icon: <Briefcase className="h-4 w-4" />,
29+
component: ProjectSettingsSection,
30+
},
2431
{
2532
id: "models",
2633
label: "Models",
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import React, { useCallback, useEffect, useState } from "react";
2+
import { useAPI } from "@/browser/contexts/API";
3+
import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext";
4+
import { Trash2 } from "lucide-react";
5+
6+
export const ProjectSettingsSection: React.FC = () => {
7+
const { api } = useAPI();
8+
const { selectedWorkspace } = useWorkspaceContext();
9+
const projectPath = selectedWorkspace?.projectPath;
10+
11+
const [servers, setServers] = useState<Record<string, string>>({});
12+
const [loading, setLoading] = useState(false);
13+
const [error, setError] = useState<string | null>(null);
14+
15+
const refresh = useCallback(async () => {
16+
if (!api || !projectPath) return;
17+
setLoading(true);
18+
try {
19+
const result = await api.projects.mcp.list({ projectPath });
20+
setServers(result ?? {});
21+
setError(null);
22+
} catch (err) {
23+
setError(err instanceof Error ? err.message : "Failed to load MCP servers");
24+
} finally {
25+
setLoading(false);
26+
}
27+
}, [api, projectPath]);
28+
29+
useEffect(() => {
30+
void refresh();
31+
}, [refresh]);
32+
33+
const handleRemove = useCallback(
34+
async (name: string) => {
35+
if (!api || !projectPath) return;
36+
setLoading(true);
37+
try {
38+
const result = await api.projects.mcp.remove({ projectPath, name });
39+
if (!result.success) {
40+
setError(result.error ?? "Failed to remove MCP server");
41+
} else {
42+
await refresh();
43+
}
44+
} catch (err) {
45+
setError(err instanceof Error ? err.message : "Failed to remove MCP server");
46+
} finally {
47+
setLoading(false);
48+
}
49+
},
50+
[api, projectPath, refresh]
51+
);
52+
53+
if (!projectPath) {
54+
return (
55+
<p className="text-muted-foreground text-sm">
56+
Select a workspace to manage project settings.
57+
</p>
58+
);
59+
}
60+
61+
return (
62+
<div className="space-y-4">
63+
<div>
64+
<h3 className="text-lg font-semibold">MCP Servers</h3>
65+
<p className="text-muted-foreground text-sm">
66+
Servers are stored in <code>.mux/mcp.jsonc</code> in this project. Use{" "}
67+
<code>/mcp add</code> to add new entries.
68+
</p>
69+
</div>
70+
71+
{error && <p className="text-destructive text-sm">{error}</p>}
72+
73+
{loading && <p className="text-muted-foreground text-sm">Loading…</p>}
74+
75+
{!loading && Object.keys(servers).length === 0 && (
76+
<p className="text-muted-foreground text-sm">No MCP servers configured.</p>
77+
)}
78+
79+
<ul className="space-y-2">
80+
{Object.entries(servers).map(([name, command]) => (
81+
<li
82+
key={name}
83+
className="border-border-medium/60 bg-secondary/30 flex items-start justify-between rounded-md border px-3 py-2"
84+
>
85+
<div className="space-y-1">
86+
<div className="font-medium">{name}</div>
87+
<div className="text-muted-foreground text-xs break-all">{command}</div>
88+
</div>
89+
<button
90+
type="button"
91+
onClick={() => void handleRemove(name)}
92+
className="text-muted-foreground hover:text-destructive flex items-center gap-1 text-xs"
93+
aria-label={`Remove MCP server ${name}`}
94+
disabled={loading}
95+
>
96+
<Trash2 className="h-4 w-4" /> Remove
97+
</button>
98+
</li>
99+
))}
100+
</ul>
101+
</div>
102+
);
103+
};

src/browser/utils/slashCommands/registry.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,39 @@ const newCommandDefinition: SlashCommandDefinition = {
585585
},
586586
};
587587

588+
const mcpCommandDefinition: SlashCommandDefinition = {
589+
key: "mcp",
590+
description: "Manage MCP servers for this project",
591+
handler: ({ cleanRemainingTokens, rawInput }) => {
592+
if (cleanRemainingTokens.length === 0) {
593+
return { type: "mcp-open" };
594+
}
595+
596+
const sub = cleanRemainingTokens[0];
597+
if (sub === "add") {
598+
const name = cleanRemainingTokens[1];
599+
const commandText = rawInput
600+
.trim()
601+
.replace(/^add\s+[^\s]+\s*/i, "")
602+
.trim();
603+
if (!name || !commandText) {
604+
return { type: "unknown-command", command: "mcp", subcommand: "add" };
605+
}
606+
return { type: "mcp-add", name, command: commandText };
607+
}
608+
609+
if (sub === "remove") {
610+
const name = cleanRemainingTokens[1];
611+
if (!name) {
612+
return { type: "unknown-command", command: "mcp", subcommand: "remove" };
613+
}
614+
return { type: "mcp-remove", name };
615+
}
616+
617+
return { type: "unknown-command", command: "mcp", subcommand: sub };
618+
},
619+
};
620+
588621
export const SLASH_COMMAND_DEFINITIONS: readonly SlashCommandDefinition[] = [
589622
clearCommandDefinition,
590623
truncateCommandDefinition,
@@ -595,6 +628,7 @@ export const SLASH_COMMAND_DEFINITIONS: readonly SlashCommandDefinition[] = [
595628
forkCommandDefinition,
596629
newCommandDefinition,
597630
vimCommandDefinition,
631+
mcpCommandDefinition,
598632
];
599633

600634
export const SLASH_COMMAND_DEFINITION_MAP = new Map(

src/browser/utils/slashCommands/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ export type ParsedCommand =
2929
startMessage?: string;
3030
}
3131
| { type: "vim-toggle" }
32+
| { type: "mcp-add"; name: string; command: string }
33+
| { type: "mcp-remove"; name: string }
34+
| { type: "mcp-open" }
3235
| { type: "unknown-command"; command: string; subcommand?: string }
3336
| null;
3437

src/cli/cli.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ async function createTestServer(authToken?: string): Promise<TestServerHandle> {
6767
updateService: services.updateService,
6868
tokenizerService: services.tokenizerService,
6969
serverService: services.serverService,
70+
mcpConfigService: services.mcpConfigService,
71+
mcpServerManager: services.mcpServerManager,
7072
menuEventService: services.menuEventService,
7173
voiceService: services.voiceService,
7274
telemetryService: services.telemetryService,

src/cli/server.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ async function createTestServer(): Promise<TestServerHandle> {
7070
updateService: services.updateService,
7171
tokenizerService: services.tokenizerService,
7272
serverService: services.serverService,
73+
mcpConfigService: services.mcpConfigService,
74+
mcpServerManager: services.mcpServerManager,
7375
menuEventService: services.menuEventService,
7476
voiceService: services.voiceService,
7577
telemetryService: services.telemetryService,

src/cli/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ const mockWindow: BrowserWindow = {
7979
tokenizerService: serviceContainer.tokenizerService,
8080
serverService: serviceContainer.serverService,
8181
menuEventService: serviceContainer.menuEventService,
82+
mcpConfigService: serviceContainer.mcpConfigService,
83+
mcpServerManager: serviceContainer.mcpServerManager,
8284
voiceService: serviceContainer.voiceService,
8385
telemetryService: serviceContainer.telemetryService,
8486
};

0 commit comments

Comments
 (0)