Remove MCP tab and per-project MCP UI from frontend
Claude Code manages MCP natively now (`claude mcp add/list/remove`, `.mcp.json`, `/mcp`), so Triple-C's own MCP server library is redundant. Deletes components/mcp/, hooks/useMcpServers.ts, the MCP sidebar tab and rail icon, the per-project enable checkboxes on ProjectCard, the mcpServers slice of the Zustand store, the four IPC wrappers, and the McpServer/McpTransportType types. Rust backend is untouched in this commit; the commands simply become unreachable. Backend removal and the legacy container/network cleanup follow separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,6 @@ import DockerInstallDialog from "./components/DockerInstallDialog";
|
|||||||
import { useDocker } from "./hooks/useDocker";
|
import { useDocker } from "./hooks/useDocker";
|
||||||
import { useSettings } from "./hooks/useSettings";
|
import { useSettings } from "./hooks/useSettings";
|
||||||
import { useProjects } from "./hooks/useProjects";
|
import { useProjects } from "./hooks/useProjects";
|
||||||
import { useMcpServers } from "./hooks/useMcpServers";
|
|
||||||
import { useUpdates } from "./hooks/useUpdates";
|
import { useUpdates } from "./hooks/useUpdates";
|
||||||
import { useTerminal } from "./hooks/useTerminal";
|
import { useTerminal } from "./hooks/useTerminal";
|
||||||
import { useSTT } from "./hooks/useSTT";
|
import { useSTT } from "./hooks/useSTT";
|
||||||
@@ -19,7 +18,6 @@ export default function App() {
|
|||||||
const { checkDocker, checkImage, startDockerPolling } = useDocker();
|
const { checkDocker, checkImage, startDockerPolling } = useDocker();
|
||||||
const { loadSettings } = useSettings();
|
const { loadSettings } = useSettings();
|
||||||
const { refresh } = useProjects();
|
const { refresh } = useProjects();
|
||||||
const { refresh: refreshMcp } = useMcpServers();
|
|
||||||
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
|
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
|
||||||
const { sessions, activeSessionId, setProjects, setSttToggle } = useAppState(
|
const { sessions, activeSessionId, setProjects, setSttToggle } = useAppState(
|
||||||
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects, setSttToggle: s.setSttToggle }))
|
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects, setSttToggle: s.setSttToggle }))
|
||||||
@@ -56,7 +54,6 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
refresh();
|
refresh();
|
||||||
refreshMcp();
|
|
||||||
|
|
||||||
// Update detection
|
// Update detection
|
||||||
loadVersion();
|
loadVersion();
|
||||||
|
|||||||
@@ -22,9 +22,6 @@ vi.mock("../projects/ProjectList", () => ({
|
|||||||
vi.mock("../settings/SettingsPanel", () => ({
|
vi.mock("../settings/SettingsPanel", () => ({
|
||||||
default: () => <div data-testid="settings-panel">SettingsPanel</div>,
|
default: () => <div data-testid="settings-panel">SettingsPanel</div>,
|
||||||
}));
|
}));
|
||||||
vi.mock("../mcp/McpPanel", () => ({
|
|
||||||
default: () => <div data-testid="mcp-panel">McpPanel</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("Sidebar", () => {
|
describe("Sidebar", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import type { ReactNode } from "react";
|
|||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
import ProjectList from "../projects/ProjectList";
|
import ProjectList from "../projects/ProjectList";
|
||||||
import McpPanel from "../mcp/McpPanel";
|
|
||||||
import SettingsPanel from "../settings/SettingsPanel";
|
import SettingsPanel from "../settings/SettingsPanel";
|
||||||
|
|
||||||
type SidebarView = "projects" | "mcp" | "settings";
|
type SidebarView = "projects" | "settings";
|
||||||
|
|
||||||
const RAIL_ICONS: { view: SidebarView; label: string; icon: ReactNode }[] = [
|
const RAIL_ICONS: { view: SidebarView; label: string; icon: ReactNode }[] = [
|
||||||
{
|
{
|
||||||
@@ -17,18 +16,6 @@ const RAIL_ICONS: { view: SidebarView; label: string; icon: ReactNode }[] = [
|
|||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
view: "mcp",
|
|
||||||
label: "MCP",
|
|
||||||
icon: (
|
|
||||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M9 2v6" />
|
|
||||||
<path d="M15 2v6" />
|
|
||||||
<path d="M7 8h10v4a5 5 0 0 1-10 0V8z" />
|
|
||||||
<path d="M12 17v5" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
view: "settings",
|
view: "settings",
|
||||||
label: "Settings",
|
label: "Settings",
|
||||||
@@ -108,9 +95,6 @@ export default function Sidebar() {
|
|||||||
<button onClick={() => setSidebarView("projects")} className={tabCls("projects")}>
|
<button onClick={() => setSidebarView("projects")} className={tabCls("projects")}>
|
||||||
Projects
|
Projects
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setSidebarView("mcp")} className={tabCls("mcp")}>
|
|
||||||
MCP <span className="text-[0.6rem] px-1 py-0.5 rounded bg-yellow-500/20 text-yellow-400 ml-0.5">Beta</span>
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setSidebarView("settings")} className={tabCls("settings")}>
|
<button onClick={() => setSidebarView("settings")} className={tabCls("settings")}>
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
@@ -128,13 +112,7 @@ export default function Sidebar() {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-1 min-w-0">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-1 min-w-0">
|
||||||
{sidebarView === "projects" ? (
|
{sidebarView === "projects" ? <ProjectList /> : <SettingsPanel />}
|
||||||
<ProjectList />
|
|
||||||
) : sidebarView === "mcp" ? (
|
|
||||||
<McpPanel />
|
|
||||||
) : (
|
|
||||||
<SettingsPanel />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useMcpServers } from "../../hooks/useMcpServers";
|
|
||||||
import McpServerCard from "./McpServerCard";
|
|
||||||
|
|
||||||
export default function McpPanel() {
|
|
||||||
const { mcpServers, refresh, add, update, remove } = useMcpServers();
|
|
||||||
const [newName, setNewName] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refresh();
|
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
const handleAdd = async () => {
|
|
||||||
const name = newName.trim();
|
|
||||||
if (!name) return;
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await add(name);
|
|
||||||
setNewName("");
|
|
||||||
} catch (e) {
|
|
||||||
setError(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3 p-2">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold text-[var(--text-primary)]">
|
|
||||||
MCP Servers{" "}
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-yellow-500/20 text-yellow-400">Beta</span>
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] mt-0.5">
|
|
||||||
Define MCP servers globally, then enable them per-project.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add new server */}
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<input
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") handleAdd(); }}
|
|
||||||
placeholder="Server name..."
|
|
||||||
className="flex-1 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleAdd}
|
|
||||||
disabled={!newName.trim()}
|
|
||||||
className="px-3 py-1 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="text-xs text-[var(--error)]">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Server list */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{mcpServers.length === 0 ? (
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] italic">
|
|
||||||
No MCP servers configured.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
mcpServers.map((server) => (
|
|
||||||
<McpServerCard
|
|
||||||
key={server.id}
|
|
||||||
server={server}
|
|
||||||
onUpdate={update}
|
|
||||||
onRemove={remove}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import type { McpServer, McpTransportType } from "../../lib/types";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
server: McpServer;
|
|
||||||
onUpdate: (server: McpServer) => Promise<McpServer | void>;
|
|
||||||
onRemove: (id: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const [name, setName] = useState(server.name);
|
|
||||||
const [transportType, setTransportType] = useState<McpTransportType>(server.transport_type);
|
|
||||||
const [command, setCommand] = useState(server.command ?? "");
|
|
||||||
const [args, setArgs] = useState(server.args.join(" "));
|
|
||||||
const [envPairs, setEnvPairs] = useState<[string, string][]>(Object.entries(server.env));
|
|
||||||
const [url, setUrl] = useState(server.url ?? "");
|
|
||||||
const [headerPairs, setHeaderPairs] = useState<[string, string][]>(Object.entries(server.headers));
|
|
||||||
const [dockerImage, setDockerImage] = useState(server.docker_image ?? "");
|
|
||||||
const [containerPort, setContainerPort] = useState(server.container_port?.toString() ?? "3000");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setName(server.name);
|
|
||||||
setTransportType(server.transport_type);
|
|
||||||
setCommand(server.command ?? "");
|
|
||||||
setArgs(server.args.join(" "));
|
|
||||||
setEnvPairs(Object.entries(server.env));
|
|
||||||
setUrl(server.url ?? "");
|
|
||||||
setHeaderPairs(Object.entries(server.headers));
|
|
||||||
setDockerImage(server.docker_image ?? "");
|
|
||||||
setContainerPort(server.container_port?.toString() ?? "3000");
|
|
||||||
}, [server]);
|
|
||||||
|
|
||||||
const saveServer = async (patch: Partial<McpServer>) => {
|
|
||||||
try {
|
|
||||||
await onUpdate({ ...server, ...patch });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to update MCP server:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNameBlur = () => {
|
|
||||||
if (name !== server.name) saveServer({ name });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTransportChange = (t: McpTransportType) => {
|
|
||||||
setTransportType(t);
|
|
||||||
saveServer({ transport_type: t });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCommandBlur = () => {
|
|
||||||
saveServer({ command: command || null });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleArgsBlur = () => {
|
|
||||||
const parsed = args.trim() ? args.trim().split(/\s+/) : [];
|
|
||||||
saveServer({ args: parsed });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUrlBlur = () => {
|
|
||||||
saveServer({ url: url || null });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDockerImageBlur = () => {
|
|
||||||
saveServer({ docker_image: dockerImage || null });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleContainerPortBlur = () => {
|
|
||||||
const port = parseInt(containerPort, 10);
|
|
||||||
saveServer({ container_port: isNaN(port) ? null : port });
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveEnv = (pairs: [string, string][]) => {
|
|
||||||
const env: Record<string, string> = {};
|
|
||||||
for (const [k, v] of pairs) {
|
|
||||||
if (k.trim()) env[k.trim()] = v;
|
|
||||||
}
|
|
||||||
saveServer({ env });
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveHeaders = (pairs: [string, string][]) => {
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
for (const [k, v] of pairs) {
|
|
||||||
if (k.trim()) headers[k.trim()] = v;
|
|
||||||
}
|
|
||||||
saveServer({ headers });
|
|
||||||
};
|
|
||||||
|
|
||||||
const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]";
|
|
||||||
|
|
||||||
const isDocker = !!dockerImage;
|
|
||||||
|
|
||||||
const transportBadge = {
|
|
||||||
stdio: "Stdio",
|
|
||||||
http: "HTTP",
|
|
||||||
}[transportType];
|
|
||||||
|
|
||||||
const modeBadge = isDocker ? "Docker" : "Manual";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border border-[var(--border-color)] rounded bg-[var(--bg-primary)]">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
className="flex-1 flex items-center gap-2 text-left min-w-0"
|
|
||||||
>
|
|
||||||
<span className="text-xs text-[var(--text-secondary)]">{expanded ? "\u25BC" : "\u25B6"}</span>
|
|
||||||
<span className="text-sm font-medium truncate">{server.name}</span>
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--bg-secondary)] text-[var(--text-secondary)]">
|
|
||||||
{transportBadge}
|
|
||||||
</span>
|
|
||||||
<span className={`text-xs px-1.5 py-0.5 rounded ${isDocker ? "bg-blue-500/20 text-blue-400" : "bg-[var(--bg-secondary)] text-[var(--text-secondary)]"}`}>
|
|
||||||
{modeBadge}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { if (confirm(`Remove MCP server "${server.name}"?`)) onRemove(server.id); }}
|
|
||||||
className="text-xs px-2 py-0.5 text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expanded config */}
|
|
||||||
{expanded && (
|
|
||||||
<div className="px-3 pb-3 space-y-2 border-t border-[var(--border-color)] pt-2">
|
|
||||||
{/* Name */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Name</label>
|
|
||||||
<input
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
onBlur={handleNameBlur}
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Docker Image (primary field — determines Docker vs Manual mode) */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Docker Image</label>
|
|
||||||
<input
|
|
||||||
value={dockerImage}
|
|
||||||
onChange={(e) => setDockerImage(e.target.value)}
|
|
||||||
onBlur={handleDockerImageBlur}
|
|
||||||
placeholder="e.g. mcp/filesystem:latest (leave empty for manual mode)"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-60">
|
|
||||||
Set a Docker image to run this MCP server in its own container. Leave empty to run commands inside the project container. Images are pulled automatically if not present.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Transport type */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Transport</label>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{(["stdio", "http"] as McpTransportType[]).map((t) => (
|
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
onClick={() => handleTransportChange(t)}
|
|
||||||
className={`px-2 py-0.5 text-xs rounded transition-colors ${
|
|
||||||
transportType === t
|
|
||||||
? "bg-[var(--accent)] text-white"
|
|
||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t === "stdio" ? "Stdio" : "HTTP"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mode description */}
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] opacity-60">
|
|
||||||
{transportType === "stdio" && isDocker && "Runs via docker exec in a separate MCP container."}
|
|
||||||
{transportType === "stdio" && !isDocker && "Runs inside the project container (e.g. npx commands)."}
|
|
||||||
{transportType === "http" && isDocker && "Runs in a separate container, reached by hostname on the project network."}
|
|
||||||
{transportType === "http" && !isDocker && "Connects to an MCP server at the URL you specify."}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Container Port (HTTP+Docker only) */}
|
|
||||||
{transportType === "http" && isDocker && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Container Port</label>
|
|
||||||
<input
|
|
||||||
value={containerPort}
|
|
||||||
onChange={(e) => setContainerPort(e.target.value)}
|
|
||||||
onBlur={handleContainerPortBlur}
|
|
||||||
placeholder="3000"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-60">
|
|
||||||
Port the MCP server listens on inside its container. The URL is auto-generated as http://<container>:<port>/mcp on the project network.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stdio fields */}
|
|
||||||
{transportType === "stdio" && (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Command</label>
|
|
||||||
<input
|
|
||||||
value={command}
|
|
||||||
onChange={(e) => setCommand(e.target.value)}
|
|
||||||
onBlur={handleCommandBlur}
|
|
||||||
placeholder={isDocker ? "Command inside container" : "npx"}
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Arguments (space-separated)</label>
|
|
||||||
<input
|
|
||||||
value={args}
|
|
||||||
onChange={(e) => setArgs(e.target.value)}
|
|
||||||
onBlur={handleArgsBlur}
|
|
||||||
placeholder="-y @modelcontextprotocol/server-filesystem /path"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<KeyValueEditor
|
|
||||||
label="Environment Variables"
|
|
||||||
pairs={envPairs}
|
|
||||||
onChange={(pairs) => { setEnvPairs(pairs); }}
|
|
||||||
onSave={saveEnv}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* HTTP fields (only for manual mode — Docker mode auto-generates URL) */}
|
|
||||||
{transportType === "http" && !isDocker && (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">URL</label>
|
|
||||||
<input
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
|
||||||
onBlur={handleUrlBlur}
|
|
||||||
placeholder="http://localhost:3000/mcp"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<KeyValueEditor
|
|
||||||
label="Headers"
|
|
||||||
pairs={headerPairs}
|
|
||||||
onChange={(pairs) => { setHeaderPairs(pairs); }}
|
|
||||||
onSave={saveHeaders}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Environment variables for HTTP+Docker */}
|
|
||||||
{transportType === "http" && isDocker && (
|
|
||||||
<KeyValueEditor
|
|
||||||
label="Environment Variables"
|
|
||||||
pairs={envPairs}
|
|
||||||
onChange={(pairs) => { setEnvPairs(pairs); }}
|
|
||||||
onSave={saveEnv}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function KeyValueEditor({
|
|
||||||
label,
|
|
||||||
pairs,
|
|
||||||
onChange,
|
|
||||||
onSave,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
pairs: [string, string][];
|
|
||||||
onChange: (pairs: [string, string][]) => void;
|
|
||||||
onSave: (pairs: [string, string][]) => void;
|
|
||||||
}) {
|
|
||||||
const inputCls = "flex-1 min-w-0 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">{label}</label>
|
|
||||||
{pairs.map(([key, value], i) => (
|
|
||||||
<div key={i} className="flex gap-1 items-center mb-1">
|
|
||||||
<input
|
|
||||||
value={key}
|
|
||||||
onChange={(e) => {
|
|
||||||
const updated = [...pairs] as [string, string][];
|
|
||||||
updated[i] = [e.target.value, value];
|
|
||||||
onChange(updated);
|
|
||||||
}}
|
|
||||||
onBlur={() => onSave(pairs)}
|
|
||||||
placeholder="KEY"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-[var(--text-secondary)]">=</span>
|
|
||||||
<input
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => {
|
|
||||||
const updated = [...pairs] as [string, string][];
|
|
||||||
updated[i] = [key, e.target.value];
|
|
||||||
onChange(updated);
|
|
||||||
}}
|
|
||||||
onBlur={() => onSave(pairs)}
|
|
||||||
placeholder="value"
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const updated = pairs.filter((_, j) => j !== i);
|
|
||||||
onChange(updated);
|
|
||||||
onSave(updated);
|
|
||||||
}}
|
|
||||||
className="flex-shrink-0 px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
|
|
||||||
>
|
|
||||||
x
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onChange([...pairs, ["", ""]]);
|
|
||||||
}}
|
|
||||||
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
+ Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -31,16 +31,6 @@ vi.mock("../../hooks/useTerminal", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../hooks/useMcpServers", () => ({
|
|
||||||
useMcpServers: () => ({
|
|
||||||
mcpServers: [],
|
|
||||||
refresh: vi.fn(),
|
|
||||||
add: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
remove: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
let mockSelectedProjectId: string | null = null;
|
let mockSelectedProjectId: string | null = null;
|
||||||
vi.mock("../../store/appState", () => ({
|
vi.mock("../../store/appState", () => ({
|
||||||
useAppState: vi.fn((selector) =>
|
useAppState: vi.fn((selector) =>
|
||||||
@@ -67,7 +57,6 @@ const mockProject: Project = {
|
|||||||
custom_env_vars: [],
|
custom_env_vars: [],
|
||||||
port_mappings: [],
|
port_mappings: [],
|
||||||
claude_instructions: null,
|
claude_instructions: null,
|
||||||
enabled_mcp_servers: [],
|
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
updated_at: "2026-01-01T00:00:00Z",
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import * as commands from "../../lib/tauri-commands";
|
|||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import type { Project, ProjectPath, Backend, BedrockConfig, BedrockAuthMethod, OllamaConfig, OpenAiCompatibleConfig } from "../../lib/types";
|
import type { Project, ProjectPath, Backend, BedrockConfig, BedrockAuthMethod, OllamaConfig, OpenAiCompatibleConfig } from "../../lib/types";
|
||||||
import { useProjects } from "../../hooks/useProjects";
|
import { useProjects } from "../../hooks/useProjects";
|
||||||
import { useMcpServers } from "../../hooks/useMcpServers";
|
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
import EnvVarsModal from "./EnvVarsModal";
|
import EnvVarsModal from "./EnvVarsModal";
|
||||||
@@ -24,7 +23,6 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
const selectedProjectId = useAppState(s => s.selectedProjectId);
|
const selectedProjectId = useAppState(s => s.selectedProjectId);
|
||||||
const setSelectedProject = useAppState(s => s.setSelectedProject);
|
const setSelectedProject = useAppState(s => s.setSelectedProject);
|
||||||
const { start, stop, rebuild, remove, update } = useProjects();
|
const { start, stop, rebuild, remove, update } = useProjects();
|
||||||
const { mcpServers } = useMcpServers();
|
|
||||||
const { open: openTerminal } = useTerminal();
|
const { open: openTerminal } = useTerminal();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -196,7 +194,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
const bytes = await commands.downloadContainerBackup(project.id, hostPath);
|
const bytes = await commands.downloadContainerBackup(project.id, hostPath);
|
||||||
const mb = (bytes / (1024 * 1024)).toFixed(1);
|
const mb = (bytes / (1024 * 1024)).toFixed(1);
|
||||||
const msg = `Backup saved (${mb} MB). Note: includes MCP/config — may contain MCP API keys. Keep it private.`;
|
const msg = `Backup saved (${mb} MB). Note: includes Claude config — may contain API keys. Keep it private.`;
|
||||||
setProgressMsg(msg);
|
setProgressMsg(msg);
|
||||||
// Auto-clear so the transient confirmation doesn't linger in the card
|
// Auto-clear so the transient confirmation doesn't linger in the card
|
||||||
// status; guard against clobbering a newer message (e.g. a later op).
|
// status; guard against clobbering a newer message (e.g. a later op).
|
||||||
@@ -539,7 +537,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
onClick={handleBackup}
|
onClick={handleBackup}
|
||||||
disabled={loading || backingUp}
|
disabled={loading || backingUp}
|
||||||
label={backingUp ? "Backing up…" : "Backup"}
|
label={backingUp ? "Backing up…" : "Backup"}
|
||||||
title="Downloads /workspace plus a sanitized home config (MCP servers, settings, skills). OAuth tokens are excluded, but MCP server configs may embed their own API keys/tokens — keep the archive private."
|
title="Downloads /workspace plus a sanitized home config (Claude settings, skills, MCP config). OAuth tokens are excluded, but other config may embed its own API keys/tokens — keep the archive private."
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -864,49 +862,6 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* MCP Servers */}
|
|
||||||
{mcpServers.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-1">MCP Servers<Tooltip text="Model Context Protocol servers give Claude access to external tools and data sources." /></label>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{mcpServers.map((server) => {
|
|
||||||
const enabled = project.enabled_mcp_servers.includes(server.id);
|
|
||||||
const isDocker = !!server.docker_image;
|
|
||||||
return (
|
|
||||||
<label key={server.id} className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={enabled}
|
|
||||||
disabled={!isStopped}
|
|
||||||
onChange={async () => {
|
|
||||||
const updated = enabled
|
|
||||||
? project.enabled_mcp_servers.filter((id) => id !== server.id)
|
|
||||||
: [...project.enabled_mcp_servers, server.id];
|
|
||||||
try {
|
|
||||||
await update({ ...project, enabled_mcp_servers: updated });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to update MCP servers:", err);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="rounded border-[var(--border-color)] disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-[var(--text-primary)]">{server.name}</span>
|
|
||||||
<span className="text-xs text-[var(--text-secondary)]">({server.transport_type})</span>
|
|
||||||
<span className={`text-xs px-1 py-0.5 rounded ${isDocker ? "bg-blue-500/20 text-blue-400" : "bg-[var(--bg-secondary)] text-[var(--text-secondary)]"}`}>
|
|
||||||
{isDocker ? "Docker" : "Manual"}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{mcpServers.some((s) => s.docker_image && s.transport_type === "stdio" && project.enabled_mcp_servers.includes(s.id)) && (
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] mt-1 opacity-70">
|
|
||||||
Docker access will be auto-enabled for stdio+Docker MCP servers.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bedrock config */}
|
{/* Bedrock config */}
|
||||||
{project.backend === "bedrock" && (() => {
|
{project.backend === "bedrock" && (() => {
|
||||||
const bc = project.bedrock_config ?? defaultBedrockConfig;
|
const bc = project.bedrock_config ?? defaultBedrockConfig;
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
import { useShallow } from "zustand/react/shallow";
|
|
||||||
import { useAppState } from "../store/appState";
|
|
||||||
import * as commands from "../lib/tauri-commands";
|
|
||||||
import type { McpServer } from "../lib/types";
|
|
||||||
|
|
||||||
export function useMcpServers() {
|
|
||||||
const {
|
|
||||||
mcpServers,
|
|
||||||
setMcpServers,
|
|
||||||
updateMcpServerInList,
|
|
||||||
removeMcpServerFromList,
|
|
||||||
} = useAppState(
|
|
||||||
useShallow(s => ({
|
|
||||||
mcpServers: s.mcpServers,
|
|
||||||
setMcpServers: s.setMcpServers,
|
|
||||||
updateMcpServerInList: s.updateMcpServerInList,
|
|
||||||
removeMcpServerFromList: s.removeMcpServerFromList,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
const list = await commands.listMcpServers();
|
|
||||||
setMcpServers(list);
|
|
||||||
}, [setMcpServers]);
|
|
||||||
|
|
||||||
const add = useCallback(
|
|
||||||
async (name: string) => {
|
|
||||||
const server = await commands.addMcpServer(name);
|
|
||||||
const list = await commands.listMcpServers();
|
|
||||||
setMcpServers(list);
|
|
||||||
return server;
|
|
||||||
},
|
|
||||||
[setMcpServers],
|
|
||||||
);
|
|
||||||
|
|
||||||
const update = useCallback(
|
|
||||||
async (server: McpServer) => {
|
|
||||||
const updated = await commands.updateMcpServer(server);
|
|
||||||
updateMcpServerInList(updated);
|
|
||||||
return updated;
|
|
||||||
},
|
|
||||||
[updateMcpServerInList],
|
|
||||||
);
|
|
||||||
|
|
||||||
const remove = useCallback(
|
|
||||||
async (id: string) => {
|
|
||||||
await commands.removeMcpServer(id);
|
|
||||||
removeMcpServerFromList(id);
|
|
||||||
},
|
|
||||||
[removeMcpServerFromList],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { mcpServers, refresh, add, update, remove };
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer, FileEntry, WebTerminalInfo, SttStatus, InstallOptions } from "./types";
|
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, InstallOptions } from "./types";
|
||||||
|
|
||||||
// Docker
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -64,15 +64,6 @@ export const sendAudioData = (sessionId: string, data: number[]) =>
|
|||||||
export const stopAudioBridge = (sessionId: string) =>
|
export const stopAudioBridge = (sessionId: string) =>
|
||||||
invoke<void>("stop_audio_bridge", { sessionId });
|
invoke<void>("stop_audio_bridge", { sessionId });
|
||||||
|
|
||||||
// MCP Servers
|
|
||||||
export const listMcpServers = () => invoke<McpServer[]>("list_mcp_servers");
|
|
||||||
export const addMcpServer = (name: string) =>
|
|
||||||
invoke<McpServer>("add_mcp_server", { name });
|
|
||||||
export const updateMcpServer = (server: McpServer) =>
|
|
||||||
invoke<McpServer>("update_mcp_server", { server });
|
|
||||||
export const removeMcpServer = (serverId: string) =>
|
|
||||||
invoke<void>("remove_mcp_server", { serverId });
|
|
||||||
|
|
||||||
// Files
|
// Files
|
||||||
export const listContainerFiles = (projectId: string, path: string) =>
|
export const listContainerFiles = (projectId: string, path: string) =>
|
||||||
invoke<FileEntry[]>("list_container_files", { projectId, path });
|
invoke<FileEntry[]>("list_container_files", { projectId, path });
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export interface Project {
|
|||||||
custom_env_vars: EnvVar[];
|
custom_env_vars: EnvVar[];
|
||||||
port_mappings: PortMapping[];
|
port_mappings: PortMapping[];
|
||||||
claude_instructions: string | null;
|
claude_instructions: string | null;
|
||||||
enabled_mcp_servers: string[];
|
|
||||||
claude_code_settings: ClaudeCodeSettings | null;
|
claude_code_settings: ClaudeCodeSettings | null;
|
||||||
renamed_session_names: Record<string, string>;
|
renamed_session_names: Record<string, string>;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -202,23 +201,6 @@ export interface ImageUpdateInfo {
|
|||||||
remote_updated_at: string | null;
|
remote_updated_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type McpTransportType = "stdio" | "http";
|
|
||||||
|
|
||||||
export interface McpServer {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
transport_type: McpTransportType;
|
|
||||||
command: string | null;
|
|
||||||
args: string[];
|
|
||||||
env: Record<string, string>;
|
|
||||||
url: string | null;
|
|
||||||
headers: Record<string, string>;
|
|
||||||
docker_image: string | null;
|
|
||||||
container_port: number | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileEntry {
|
export interface FileEntry {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { Project, TerminalSession, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer } from "../lib/types";
|
import type { Project, TerminalSession, AppSettings, UpdateInfo, ImageUpdateInfo } from "../lib/types";
|
||||||
|
|
||||||
const SIDEBAR_COLLAPSED_KEY = "triple-c.sidebar.collapsed";
|
const SIDEBAR_COLLAPSED_KEY = "triple-c.sidebar.collapsed";
|
||||||
|
|
||||||
@@ -35,12 +35,6 @@ interface AppState {
|
|||||||
removeSession: (id: string) => void;
|
removeSession: (id: string) => void;
|
||||||
setActiveSession: (id: string | null) => void;
|
setActiveSession: (id: string | null) => void;
|
||||||
|
|
||||||
// MCP servers
|
|
||||||
mcpServers: McpServer[];
|
|
||||||
setMcpServers: (servers: McpServer[]) => void;
|
|
||||||
updateMcpServerInList: (server: McpServer) => void;
|
|
||||||
removeMcpServerFromList: (id: string) => void;
|
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
terminalHasSelection: boolean;
|
terminalHasSelection: boolean;
|
||||||
setTerminalHasSelection: (has: boolean) => void;
|
setTerminalHasSelection: (has: boolean) => void;
|
||||||
@@ -54,8 +48,8 @@ interface AppState {
|
|||||||
setTerminalAtBottom: (v: boolean) => void;
|
setTerminalAtBottom: (v: boolean) => void;
|
||||||
scrollActiveToBottom: () => void;
|
scrollActiveToBottom: () => void;
|
||||||
setScrollActiveToBottom: (fn: () => void) => void;
|
setScrollActiveToBottom: (fn: () => void) => void;
|
||||||
sidebarView: "projects" | "mcp" | "settings";
|
sidebarView: "projects" | "settings";
|
||||||
setSidebarView: (view: "projects" | "mcp" | "settings") => void;
|
setSidebarView: (view: "projects" | "settings") => void;
|
||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||||
toggleSidebarCollapsed: () => void;
|
toggleSidebarCollapsed: () => void;
|
||||||
@@ -118,20 +112,6 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
}),
|
}),
|
||||||
setActiveSession: (id) => set({ activeSessionId: id }),
|
setActiveSession: (id) => set({ activeSessionId: id }),
|
||||||
|
|
||||||
// MCP servers
|
|
||||||
mcpServers: [],
|
|
||||||
setMcpServers: (servers) => set({ mcpServers: servers }),
|
|
||||||
updateMcpServerInList: (server) =>
|
|
||||||
set((state) => ({
|
|
||||||
mcpServers: state.mcpServers.map((s) =>
|
|
||||||
s.id === server.id ? server : s,
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
removeMcpServerFromList: (id) =>
|
|
||||||
set((state) => ({
|
|
||||||
mcpServers: state.mcpServers.filter((s) => s.id !== id),
|
|
||||||
})),
|
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
terminalHasSelection: false,
|
terminalHasSelection: false,
|
||||||
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
|
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
|
||||||
|
|||||||
Reference in New Issue
Block a user