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
1 change: 1 addition & 0 deletions src/backend/services/mcp/mcp-server-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export class MCPServerManager {
}
}
for (const s of externalServers) {
if (s.builtin) continue;
if (!seen.has(s.id)) {
seen.add(s.id);
result.push(s);
Expand Down
136 changes: 136 additions & 0 deletions src/shared/utils/mcp-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, expect, it } from "vitest";
import type { MCPServerConfig } from "../types/mcp";
import { ensureBuiltinServerIds, getBuiltinServerIds, getConnectedOrBuiltinIds } from "./mcp-utils";

describe("getBuiltinServerIds", () => {
it("returns only ids of built-in servers", () => {
const servers: MCPServerConfig[] = [
{ id: "builtin-1", name: "Built-in Server 1", transport: "inMemory", builtin: true },
{
id: "external-1",
name: "External Server",
transport: "streamableHttp",
url: "http://example.com",
},
{ id: "builtin-2", name: "Built-in Server 2", transport: "inMemory", builtin: true },
];

const result = getBuiltinServerIds(servers);

expect(result).toEqual(["builtin-1", "builtin-2"]);
});

it("returns an empty array when there are no built-in servers", () => {
const servers: MCPServerConfig[] = [
{
id: "external-1",
name: "External Server",
transport: "streamableHttp",
url: "http://example.com",
},
];

const result = getBuiltinServerIds(servers);

expect(result).toEqual([]);
});

it("returns an empty array when given an empty server list", () => {
const result = getBuiltinServerIds([]);

expect(result).toEqual([]);
});

it("excludes servers with missing ids", () => {
const servers: MCPServerConfig[] = [
{ name: "No ID Server", transport: "inMemory", builtin: true } as unknown as MCPServerConfig,
{
id: "builtin-1",
name: "Built-in Server 1",
transport: "inMemory",
builtin: true,
} as MCPServerConfig,
];

const result = getBuiltinServerIds(servers);

expect(result).toEqual(["builtin-1"]);
});
});

describe("getConnectedOrBuiltinIds", () => {
it("includes built-in and connected servers", () => {
const servers: MCPServerConfig[] = [
{ id: "builtin-1", name: "Built-in", transport: "inMemory", builtin: true },
{
id: "connected-1",
name: "Connected",
transport: "streamableHttp",
url: "http://example.com",
enabled: true,
},
{
id: "disconnected-1",
name: "Disconnected",
transport: "streamableHttp",
url: "http://example.com",
enabled: false,
},
];

const result = getConnectedOrBuiltinIds(servers);

expect(result).toEqual(new Set(["builtin-1", "connected-1"]));
});

it("treats servers without explicit enabled flag as connected", () => {
const servers: MCPServerConfig[] = [
{
id: "implicit-1",
name: "Implicit",
transport: "streamableHttp",
url: "http://example.com",
},
];

const result = getConnectedOrBuiltinIds(servers);

expect(result).toEqual(new Set(["implicit-1"]));
});

it("returns an empty set when given an empty list", () => {
expect(getConnectedOrBuiltinIds([])).toEqual(new Set());
});
});

describe("ensureBuiltinServerIds", () => {
it("preserves selected disabled servers while adding missing built-ins", () => {
const servers: MCPServerConfig[] = [
{ id: "builtin-1", name: "Built-in", transport: "inMemory", builtin: true },
{
id: "disabled-1",
name: "Disabled External",
transport: "streamableHttp",
url: "http://example.com",
enabled: false,
},
{
id: "enabled-1",
name: "Enabled External",
transport: "streamableHttp",
url: "http://example.com",
enabled: true,
},
];

expect(ensureBuiltinServerIds(["disabled-1"], servers)).toEqual(["disabled-1", "builtin-1"]);
});

it("avoids duplicating built-in ids that are already selected", () => {
const servers: MCPServerConfig[] = [
{ id: "builtin-1", name: "Built-in", transport: "inMemory", builtin: true },
];

expect(ensureBuiltinServerIds(["builtin-1"], servers)).toEqual(["builtin-1"]);
});
});
37 changes: 37 additions & 0 deletions src/shared/utils/mcp-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { MCPServerConfig } from "../types/mcp";

/**
* Returns the IDs of built-in MCP servers from the given list.
* Used to pre-select built-in servers when creating a new custom prompt.
* The id guard defends against deserialized storage objects with missing ids.
*/
export function getBuiltinServerIds(servers: readonly MCPServerConfig[]): string[] {
return servers
.filter((s) => s.builtin)
.map((s) => s.id)
.filter((id): id is string => !!id);
}

/**
* Returns the IDs of servers that are either built-in or currently connected (enabled).
* Used to compute which servers are selectable in the prompt editor.
*/
export function getConnectedOrBuiltinIds(servers: readonly MCPServerConfig[]): Set<string> {
return new Set(
servers
.filter((s) => s.builtin || s.enabled !== false)
.map((s) => s.id)
.filter((id): id is string => !!id),
);
}

/**
* Preserves the current prompt selection while ensuring built-in servers stay selected.
* This avoids dropping temporarily disabled external servers from saved prompt configs.
*/
export function ensureBuiltinServerIds(
selectedServerIds: readonly string[] | undefined,
servers: readonly MCPServerConfig[],
): string[] {
return [...new Set([...(selectedServerIds ?? []), ...getBuiltinServerIds(servers)])];
}
123 changes: 86 additions & 37 deletions src/ui/src/components/settings/custom-prompt/PromptForm.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { ensureBuiltinServerIds, getBuiltinServerIds } from "@shared/utils/mcp-utils";
import { ChevronLeft, ChevronRight, Copy, Trash2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
Expand Down Expand Up @@ -104,45 +105,79 @@ export function PromptForm({
});
}, [mcpServers.length]);

// Auto-select all servers for existing prompts without selectedMcpServerIds
// This runs once after servers are loaded
// Initialise MCP server selection once after servers are loaded. Three cases:
// default prompt → select all servers (locked, not editable)
// new prompt → pre-select built-in server IDs only
// existing prompt → keep saved selection and ensure built-ins are always included;
// if no prior selection exists, default to all non-builtin servers
// biome-ignore lint/correctness/useExhaustiveDependencies: Intentionally omitting form methods to prevent re-runs
useEffect(() => {
if (serversLoaded && !isNewPrompt && !hasAutoSelectedServers.current && mcpServers.length > 0) {
const currentSelection = form.getValues("selectedMcpServerIds");
if (!currentSelection || currentSelection.length === 0) {
if (serversLoaded && !hasAutoSelectedServers.current && mcpServers.length > 0) {
const builtinIds = getBuiltinServerIds(mcpServers);

if (isDefault) {
const allServerIds = mcpServers.map((s) => s.id).filter((id): id is string => !!id);
form.setValue("selectedMcpServerIds", allServerIds, { shouldDirty: false });
} else if (isNewPrompt) {
form.setValue("selectedMcpServerIds", builtinIds, { shouldDirty: false });
} else {
const currentSelection = form.getValues("selectedMcpServerIds");
if (!currentSelection || currentSelection.length === 0) {
// No prior selection: default to all non-builtin servers (regardless of connection status)
const allNonBuiltinIds = mcpServers
.filter((s) => !s.builtin)
.map((s) => s.id)
.filter((id): id is string => !!id);
form.setValue(
"selectedMcpServerIds",
ensureBuiltinServerIds(allNonBuiltinIds, mcpServers),
{ shouldDirty: false },
);
} else {
// Existing selection: preserve it and silently add any missing built-ins
form.setValue(
"selectedMcpServerIds",
ensureBuiltinServerIds(currentSelection, mcpServers),
{ shouldDirty: false },
);
}
}
hasAutoSelectedServers.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serversLoaded, isNewPrompt, mcpServers]);
}, [serversLoaded, isDefault, isNewPrompt, mcpServers]);

const handleSubmit = async (andActivate: boolean) => {
const isValid = await form.trigger();
if (!isValid) return;

const data = form.getValues();

// Validate that at least one enabled non-built-in MCP server is selected (if any are available)
// Skip validation for default prompts since their server selection cannot be changed
if (!isDefault) {
const availableEnabledNonBuiltinServers = mcpServers.filter(
(server) => !server.builtin && server.enabled !== false,
const nonBuiltinServers = mcpServers.filter((s) => !s.builtin);

// Case 1: no non-builtin servers exist at all → hard block
if (nonBuiltinServers.length === 0) {
form.setError("selectedMcpServerIds", {
type: "manual",
message: "No MCP servers configured. Please add a server in the MCP settings tab.",
});
return;
}

// Case 2: non-builtin servers exist but none are selected → hard block
const selectedNonBuiltinIds = (data.selectedMcpServerIds ?? []).filter((id) =>
nonBuiltinServers.some((s) => s.id === id),
);
if (availableEnabledNonBuiltinServers.length > 0) {
const selectedEnabledNonBuiltinServers = availableEnabledNonBuiltinServers.filter(
(server) => server.id && data.selectedMcpServerIds?.includes(server.id),
);
if (selectedEnabledNonBuiltinServers.length === 0) {
form.setError("selectedMcpServerIds", {
type: "manual",
message: "Please select at least one enabled MCP server (excluding built-in servers)",
});
return;
}
if (selectedNonBuiltinIds.length === 0) {
form.setError("selectedMcpServerIds", {
type: "manual",
message: "Please select at least one MCP server (excluding built-in servers).",
});
return;
}

// Case 3: all selected non-builtins are disconnected → soft warning, allow save (handled in UI)
}

await onSubmit(data, andActivate);
Expand Down Expand Up @@ -201,7 +236,7 @@ export function PromptForm({
render={({ field }) => (
<FormItem className="flex flex-col flex-1 min-h-0 overflow-hidden shrink-0 max-w-full">
<div className="flex items-center justify-between">
<FormLabel className="text-white/90 text-sm">Prompt Instructions</FormLabel>
<FormLabel className="text-white/90 text-sm">Prompt Instructions *</FormLabel>
<Button
type="button"
variant="ghost"
Expand Down Expand Up @@ -240,6 +275,12 @@ export function PromptForm({
.filter((s) => field.value?.includes(s.id))
.map((s) => s.name);

const hasDisconnectedSelection =
!isDefault &&
serversWithIds.some(
(s) => !s.builtin && s.enabled === false && field.value?.includes(s.id),
);

return (
<FormItem className="shrink-0">
<FormLabel>MCP Servers *</FormLabel>
Expand All @@ -262,39 +303,40 @@ export function PromptForm({
aria-live="polite"
>
{paginatedServers.map((server) => {
const isChecked = field.value?.includes(server.id) ?? false;
const isDisabled = isDefault || server.enabled === false;
const isBuiltin = server.builtin ?? false;
const isServerDisabled = server.enabled === false;
const isChecked =
isDefault || isBuiltin || (field.value?.includes(server.id) ?? false);
// Only lock for default prompts and built-ins; disabled servers remain toggleable
const isCheckboxDisabled = isDefault || isBuiltin;
const handleToggle = () => {
const newValue = isChecked
? (field.value || []).filter((id) => id !== server.id)
: [...(field.value || []), server.id];
field.onChange(newValue);
};
return (
<div
key={server.id}
className={`flex items-center gap-3 p-1 rounded ${
isDisabled ? "opacity-50" : ""
}`}
>
<div key={server.id} className="flex items-center gap-3 p-1 rounded">
<Checkbox
id={`server-${server.id}`}
checked={isChecked}
onCheckedChange={handleToggle}
disabled={isDisabled}
disabled={isCheckboxDisabled}
/>
<label
htmlFor={`server-${server.id}`}
className={`text-sm flex-1 select-none ${
isDisabled ? "cursor-not-allowed" : "cursor-pointer"
isCheckboxDisabled ? "cursor-not-allowed" : "cursor-pointer"
}`}
>
{server.name}
{server.builtin && (
{isBuiltin && (
<span className="ml-2 text-xs text-white/50">(Built-in)</span>
)}
{isDisabled && (
<span className="ml-2 text-xs text-yellow-500/70">(Disabled)</span>
{isServerDisabled && (
<span className="ml-2 text-xs text-yellow-500/70">
(Disconnected)
</span>
)}
</label>
</div>
Expand Down Expand Up @@ -332,16 +374,23 @@ export function PromptForm({
</div>
)}
</div>
{hasDisconnectedSelection && (
<p className="text-xs text-yellow-500/80 mt-1">
Some selected servers are disconnected. Connect them in MCP settings tab to
make their tools available.
</p>
)}
<FormMessage />
</FormItem>
);
}}
/>
)}

{serversLoaded && mcpServers.length === 0 && (
{serversLoaded && mcpServers.every((s) => s.builtin) && (
<div className="text-sm text-yellow-500/80 p-3 rounded-md border border-yellow-500/30 bg-yellow-500/10">
No MCP servers configured. Please add MCP servers in the MCP settings tab.
No external MCP servers configured. You can add additional MCP servers in the MCP
settings tab.
</div>
)}

Expand Down
Loading