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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .storybook/mocks/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ export interface MockORPCClientOptions {
>;
/** Session usage data per workspace (for Costs tab) */
sessionUsage?: Map<string, MockSessionUsage>;
/** MCP server configuration per project */
mcpServers?: Map<
string,
Record<string, { command: string; disabled: boolean; toolAllowlist?: string[] }>
>;
/** MCP workspace overrides per workspace */
mcpOverrides?: Map<
string,
{ disabledServers?: string[]; enabledServers?: string[]; toolAllowlist?: Record<string, string[]> }
>;
/** MCP test results - maps server name to tools list or error */
mcpTestResults?: Map<string, { success: true; tools: string[] } | { success: false; error: string }>;
}

/**
Expand Down Expand Up @@ -100,6 +112,9 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
onProjectRemove,
backgroundProcesses = new Map(),
sessionUsage = new Map(),
mcpServers = new Map(),
mcpOverrides = new Map(),
mcpTestResults = new Map(),
} = options;

const workspaceMap = new Map(workspaces.map((w) => [w.id, w]));
Expand Down Expand Up @@ -159,6 +174,20 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
get: async () => [],
update: async () => ({ success: true, data: undefined }),
},
mcp: {
list: async (input: { projectPath: string }) => mcpServers.get(input.projectPath) ?? {},
add: async () => ({ success: true, data: undefined }),
remove: async () => ({ success: true, data: undefined }),
test: async (input: { projectPath: string; name?: string }) => {
if (input.name && mcpTestResults.has(input.name)) {
return mcpTestResults.get(input.name)!;
}
// Default: return empty tools
return { success: true, tools: [] };
},
setEnabled: async () => ({ success: true, data: undefined }),
setToolAllowlist: async () => ({ success: true, data: undefined }),
},
},
workspace: {
list: async () => workspaces,
Expand Down Expand Up @@ -239,6 +268,10 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
sendToBackground: async () => ({ success: true, data: undefined }),
},
getSessionUsage: async (input: { workspaceId: string }) => sessionUsage.get(input.workspaceId),
mcp: {
get: async (input: { workspaceId: string }) => mcpOverrides.get(input.workspaceId) ?? {},
set: async () => ({ success: true, data: undefined }),
},
},
window: {
setTitle: async () => undefined,
Expand Down
1 change: 1 addition & 0 deletions src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
<WorkspaceHeader
workspaceId={workspaceId}
projectName={projectName}
projectPath={projectPath}
workspaceName={workspaceName}
namedWorkspacePath={namedWorkspacePath}
runtimeConfig={runtimeConfig}
Expand Down
187 changes: 138 additions & 49 deletions src/browser/components/Settings/sections/ProjectSettingsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { useAPI } from "@/browser/contexts/API";
import { useProjectContext } from "@/browser/contexts/ProjectContext";
import {
Expand All @@ -12,6 +12,8 @@ import {
Pencil,
Check,
X,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { Button } from "@/browser/components/ui/button";
import {
Expand All @@ -26,57 +28,143 @@ import { Switch } from "@/browser/components/ui/switch";
import { cn } from "@/common/lib/utils";
import { formatRelativeTime } from "@/browser/utils/ui/dateTime";
import type { CachedMCPTestResult, MCPServerInfo } from "@/common/types/mcp";
import { getMCPTestResultsKey } from "@/common/constants/storage";
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
import { useMCPTestCache } from "@/browser/hooks/useMCPTestCache";
import { ToolSelector } from "@/browser/components/ToolSelector";

/** Component for managing tool allowlist for a single MCP server */
const ToolAllowlistSection: React.FC<{
serverName: string;
availableTools: string[];
currentAllowlist?: string[];
testedAt: number;
projectPath: string;
}> = ({ serverName, availableTools, currentAllowlist, testedAt, projectPath }) => {
const { api } = useAPI();
const [expanded, setExpanded] = useState(false);
const [saving, setSaving] = useState(false);
// Always use an array internally - undefined from props means all tools allowed
const [localAllowlist, setLocalAllowlist] = useState<string[]>(
() => currentAllowlist ?? [...availableTools]
);

type CachedResults = Record<string, CachedMCPTestResult>;
// Sync local state when prop changes
useEffect(() => {
setLocalAllowlist(currentAllowlist ?? [...availableTools]);
}, [currentAllowlist, availableTools]);

/** Hook to manage MCP test results with localStorage caching */
function useMCPTestCache(projectPath: string) {
const storageKey = useMemo(
() => (projectPath ? getMCPTestResultsKey(projectPath) : ""),
[projectPath]
);
const allAllowed = localAllowlist.length === availableTools.length;
const allDisabled = localAllowlist.length === 0;

const [cache, setCache] = useState<CachedResults>(() =>
storageKey ? readPersistedState<CachedResults>(storageKey, {}) : {}
);
const handleToggleTool = useCallback(
async (toolName: string, allowed: boolean) => {
if (!api) return;

// Reload cache when project changes
useEffect(() => {
if (storageKey) {
setCache(readPersistedState<CachedResults>(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;
});
const newAllowlist = allowed
? [...localAllowlist, toolName]
: localAllowlist.filter((t) => t !== toolName);

// Optimistic update
setLocalAllowlist(newAllowlist);
setSaving(true);

try {
const result = await api.projects.mcp.setToolAllowlist({
projectPath,
name: serverName,
toolAllowlist: newAllowlist,
});
if (!result.success) {
setLocalAllowlist(currentAllowlist ?? [...availableTools]);
console.error("Failed to update tool allowlist:", result.error);
}
} catch (err) {
setLocalAllowlist(currentAllowlist ?? [...availableTools]);
console.error("Failed to update tool allowlist:", err);
} finally {
setSaving(false);
}
},
[storageKey]
[api, projectPath, serverName, localAllowlist, currentAllowlist, availableTools]
);

const clearResult = useCallback(
(name: string) => {
setCache((prev) => {
const next = { ...prev };
delete next[name];
if (storageKey) updatePersistedState(storageKey, next);
return next;
const handleAllowAll = useCallback(async () => {
if (!api || allAllowed) return;

const newAllowlist = [...availableTools];
setLocalAllowlist(newAllowlist);
setSaving(true);

try {
const result = await api.projects.mcp.setToolAllowlist({
projectPath,
name: serverName,
toolAllowlist: newAllowlist,
});
},
[storageKey]
);
if (!result.success) {
setLocalAllowlist(currentAllowlist ?? [...availableTools]);
console.error("Failed to clear tool allowlist:", result.error);
}
} catch (err) {
setLocalAllowlist(currentAllowlist ?? [...availableTools]);
console.error("Failed to clear tool allowlist:", err);
} finally {
setSaving(false);
}
}, [api, projectPath, serverName, allAllowed, currentAllowlist, availableTools]);

const handleSelectNone = useCallback(async () => {
if (!api || allDisabled) return;

return { cache, setResult, clearResult };
}
setLocalAllowlist([]);
setSaving(true);

try {
const result = await api.projects.mcp.setToolAllowlist({
projectPath,
name: serverName,
toolAllowlist: [],
});
if (!result.success) {
setLocalAllowlist(currentAllowlist ?? [...availableTools]);
console.error("Failed to set empty tool allowlist:", result.error);
}
} catch (err) {
setLocalAllowlist(currentAllowlist ?? [...availableTools]);
console.error("Failed to set empty tool allowlist:", err);
} finally {
setSaving(false);
}
}, [api, projectPath, serverName, allDisabled, currentAllowlist, availableTools]);

return (
<div className="mt-2">
<button
onClick={() => setExpanded(!expanded)}
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs"
>
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
<span>
Tools: {localAllowlist.length}/{availableTools.length}
</span>
<span className="text-muted-foreground/60 ml-1">({formatRelativeTime(testedAt)})</span>
{saving && <Loader2 className="ml-1 h-3 w-3 animate-spin" />}
</button>

{expanded && (
<div className="border-border-light mt-2 border-l-2 pl-3">
<ToolSelector
availableTools={availableTools}
allowedTools={localAllowlist}
onToggle={(tool, allowed) => void handleToggleTool(tool, allowed)}
onSelectAll={() => void handleAllowAll()}
onSelectNone={() => void handleSelectNone()}
disabled={saving}
/>
</div>
)}
</div>
);
};

export const ProjectSettingsSection: React.FC = () => {
const { api } = useAPI();
Expand Down Expand Up @@ -478,12 +566,13 @@ export const ProjectSettingsSection: React.FC = () => {
</div>
)}
{cached?.result.success && cached.result.tools.length > 0 && !isEditing && (
<p className="text-muted-foreground mt-2 text-xs">
Tools: {cached.result.tools.join(", ")}
<span className="text-muted-foreground/60 ml-2">
({formatRelativeTime(cached.testedAt)})
</span>
</p>
<ToolAllowlistSection
serverName={name}
availableTools={cached.result.tools}
currentAllowlist={entry.toolAllowlist}
testedAt={cached.testedAt}
projectPath={selectedProject}
/>
)}
</li>
);
Expand Down
76 changes: 76 additions & 0 deletions src/browser/components/ToolSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React from "react";
import { Checkbox } from "@/browser/components/ui/checkbox";
import { Button } from "@/browser/components/ui/button";

interface ToolSelectorProps {
/** All available tools for this server */
availableTools: string[];
/** Currently allowed tools (empty = none allowed) */
allowedTools: string[];
/** Called when tool selection changes */
onToggle: (toolName: string, allowed: boolean) => void;
/** Called to select all tools */
onSelectAll: () => void;
/** Called to deselect all tools */
onSelectNone: () => void;
/** Whether controls are disabled */
disabled?: boolean;
}

/**
* Reusable tool selector grid with All/None buttons.
* Used by both project-level and workspace-level MCP config UIs.
*/
export const ToolSelector: React.FC<ToolSelectorProps> = ({
availableTools,
allowedTools,
onToggle,
onSelectAll,
onSelectNone,
disabled = false,
}) => {
const allAllowed = allowedTools.length === availableTools.length;
const noneAllowed = allowedTools.length === 0;

return (
<div>
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-xs">Select tools to expose:</span>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-5 px-2 text-xs"
onClick={onSelectAll}
disabled={disabled || allAllowed}
>
All
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 px-2 text-xs"
onClick={onSelectNone}
disabled={disabled || noneAllowed}
>
None
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-1">
{availableTools.map((tool) => (
<label key={tool} className="flex cursor-pointer items-center gap-2 py-0.5 text-xs">
<Checkbox
checked={allowedTools.includes(tool)}
onCheckedChange={(checked) => onToggle(tool, checked === true)}
disabled={disabled}
/>
<span className="truncate font-mono" title={tool}>
{tool}
</span>
</label>
))}
</div>
</div>
);
};
Loading