feat: improve MCP config UI with comprehensive add/edit/delete functionality (#10145)

Co-authored-by: OpenHands <openhands@all-hands.dev>
This commit is contained in:
Xingyao Wang 2025-08-18 12:33:27 -04:00 committed by GitHub
parent c64b1ae111
commit 3fea7fd2fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 14174 additions and 12758 deletions

View File

@ -144,6 +144,35 @@ Your specialized knowledge and instructions here...
- Add the setting to the `Settings` model in `openhands/storage/data_models/settings.py`
- Update any relevant backend code to apply the setting (e.g., in session creation)
#### Settings UI Patterns:
There are two main patterns for saving settings in the OpenHands frontend:
**Pattern 1: Entity-based Resources (Immediate Save)**
- Used for: API Keys, Secrets, MCP Servers
- Behavior: Changes are saved immediately when user performs actions (add/edit/delete)
- Implementation:
- No "Save Changes" button
- No local state management or `isDirty` tracking
- Uses dedicated mutation hooks for each operation (e.g., `use-add-mcp-server.ts`, `use-delete-mcp-server.ts`)
- Each mutation triggers immediate API call with query invalidation for UI updates
- Example: MCP settings, API Keys & Secrets tabs
- Benefits: Simpler UX, no risk of losing changes, consistent with modern web app patterns
**Pattern 2: Form-based Settings (Manual Save)**
- Used for: Application settings, LLM configuration
- Behavior: Changes are accumulated locally and saved when user clicks "Save Changes"
- Implementation:
- Has "Save Changes" button that becomes enabled when changes are detected
- Uses local state management with `isDirty` tracking
- Uses `useSaveSettings` hook to save all changes at once
- Example: LLM tab, Application tab
- Benefits: Allows bulk changes, explicit save action, can validate all fields before saving
**When to use each pattern:**
- Use Pattern 1 (Immediate Save) for entity management where each item is independent
- Use Pattern 2 (Manual Save) for configuration forms where settings are interdependent or need validation
### Adding New LLM Models
To add a new LLM model to OpenHands, you need to update multiple files across both frontend and backend:

View File

@ -0,0 +1,110 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { MCPServerForm } from "../mcp-server-form";
// i18n mock
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe("MCPServerForm validation", () => {
const noop = () => {};
it("rejects invalid env var lines and allows blank lines", () => {
const onSubmit = vi.fn();
render(
<MCPServerForm
mode="add"
server={{ id: "tmp", type: "stdio" }}
existingServers={[]}
onSubmit={onSubmit}
onCancel={noop}
/>,
);
// Fill required fields
fireEvent.change(screen.getByTestId("name-input"), {
target: { value: "my-server" },
});
fireEvent.change(screen.getByTestId("command-input"), {
target: { value: "npx" },
});
// Invalid env entries mixed with blank lines
fireEvent.change(screen.getByTestId("env-input"), {
target: { value: "invalid\n\nKEY=value\n=novalue\nKEY_ONLY=" },
});
fireEvent.click(screen.getByTestId("submit-button"));
// Should show invalid env format error
expect(
screen.getByText("SETTINGS$MCP_ERROR_ENV_INVALID_FORMAT"),
).toBeInTheDocument();
// Fix env with valid lines and blank lines
fireEvent.change(screen.getByTestId("env-input"), {
target: { value: "KEY=value\n\nANOTHER=123" },
});
fireEvent.click(screen.getByTestId("submit-button"));
// No error; submit should be called
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it("rejects duplicate URLs across sse/shttp types", () => {
const onSubmit = vi.fn();
const existingServers = [
{ id: "sse-1", type: "sse" as const, url: "https://api.example.com" },
{ id: "shttp-1", type: "shttp" as const, url: "https://x.example.com" },
];
const r1 = render(
<MCPServerForm
mode="add"
server={{ id: "tmp", type: "sse" }}
existingServers={existingServers}
onSubmit={onSubmit}
onCancel={noop}
/>,
);
fireEvent.change(screen.getAllByTestId("url-input")[0], {
target: { value: "https://api.example.com" },
});
fireEvent.click(screen.getAllByTestId("submit-button")[0]);
expect(
screen.getByText("SETTINGS$MCP_ERROR_URL_DUPLICATE"),
).toBeInTheDocument();
// Unmount first form, then check shttp duplicate
r1.unmount();
const r2 = render(
<MCPServerForm
mode="add"
server={{ id: "tmp2", type: "shttp" }}
existingServers={existingServers}
onSubmit={onSubmit}
onCancel={noop}
/>,
);
fireEvent.change(screen.getAllByTestId("url-input")[0], {
target: { value: "https://api.example.com" },
});
fireEvent.click(screen.getAllByTestId("submit-button")[0]);
expect(
screen.getByText("SETTINGS$MCP_ERROR_URL_DUPLICATE"),
).toBeInTheDocument();
r2.unmount();
});
});

View File

@ -0,0 +1,158 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { MCPServerList } from "../mcp-server-list";
// Mock react-i18next
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
const mockServers = [
{
id: "sse-0",
type: "sse" as const,
url: "https://very-long-url-that-could-cause-layout-overflow.example.com/api/v1/mcp/server/endpoint/with/many/path/segments",
},
{
id: "stdio-0",
type: "stdio" as const,
name: "test-stdio-server",
command: "python",
args: ["-m", "test_server"],
},
];
describe("MCPServerList", () => {
it("should render servers with proper layout structure", () => {
const mockOnEdit = vi.fn();
const mockOnDelete = vi.fn();
render(
<MCPServerList
servers={mockServers}
onEdit={mockOnEdit}
onDelete={mockOnDelete}
/>,
);
// Check that the table structure is rendered
const table = screen.getByRole("table");
expect(table).toBeInTheDocument();
expect(table).toHaveClass("w-full");
// Check that server items are rendered
const serverItems = screen.getAllByTestId("mcp-server-item");
expect(serverItems).toHaveLength(2);
// Check that action buttons are present for each server
const editButtons = screen.getAllByTestId("edit-mcp-server-button");
const deleteButtons = screen.getAllByTestId("delete-mcp-server-button");
expect(editButtons).toHaveLength(2);
expect(deleteButtons).toHaveLength(2);
});
it("should render empty state when no servers", () => {
const mockOnEdit = vi.fn();
const mockOnDelete = vi.fn();
render(
<MCPServerList
servers={[]}
onEdit={mockOnEdit}
onDelete={mockOnDelete}
/>,
);
expect(screen.getByText("SETTINGS$MCP_NO_SERVERS")).toBeInTheDocument();
});
it("should handle long URLs without breaking layout", () => {
const longUrlServer = {
id: "sse-0",
type: "sse" as const,
url: "https://extremely-long-url-that-would-previously-cause-layout-overflow-and-push-action-buttons-out-of-view.example.com/api/v1/mcp/server/endpoint/with/many/path/segments/and/query/parameters?param1=value1&param2=value2&param3=value3",
};
const mockOnEdit = vi.fn();
const mockOnDelete = vi.fn();
render(
<MCPServerList
servers={[longUrlServer]}
onEdit={mockOnEdit}
onDelete={mockOnDelete}
/>,
);
// Check that action buttons are still present and accessible
const editButton = screen.getByTestId("edit-mcp-server-button");
const deleteButton = screen.getByTestId("delete-mcp-server-button");
expect(editButton).toBeInTheDocument();
expect(deleteButton).toBeInTheDocument();
// Check that the URL is properly displayed with title attribute for accessibility
const detailsCells = screen.getAllByTitle(longUrlServer.url);
expect(detailsCells).toHaveLength(2); // Name and Details columns both have the URL
// Check that both name and details cells use truncation and have title for tooltip
const [nameCell, detailsCell] = detailsCells;
expect(nameCell).toHaveClass("truncate");
expect(detailsCell).toHaveClass("truncate");
});
it("should display command and arguments for STDIO servers", () => {
const stdioServer = {
id: "stdio-1",
type: "stdio" as const,
name: "test-server",
command: "python",
args: ["-m", "test_module", "--verbose"],
};
const mockOnEdit = vi.fn();
const mockOnDelete = vi.fn();
render(
<MCPServerList
servers={[stdioServer]}
onEdit={mockOnEdit}
onDelete={mockOnDelete}
/>,
);
// Check that the server details show command + arguments
const expectedDetails = "python -m test_module --verbose";
expect(screen.getByTitle(expectedDetails)).toBeInTheDocument();
expect(screen.getByText(expectedDetails)).toBeInTheDocument();
});
it("should fallback to server name for STDIO servers without command", () => {
const stdioServer = {
id: "stdio-2",
type: "stdio" as const,
name: "fallback-server",
};
const mockOnEdit = vi.fn();
const mockOnDelete = vi.fn();
render(
<MCPServerList
servers={[stdioServer]}
onEdit={mockOnEdit}
onDelete={mockOnDelete}
/>,
);
// Check that the server details show the server name as fallback
// Both name and details columns will have the same value, so we expect 2 elements
const fallbackElements = screen.getAllByTitle("fallback-server");
expect(fallbackElements).toHaveLength(2);
const fallbackTextElements = screen.getAllByText("fallback-server");
expect(fallbackTextElements).toHaveLength(2);
});
});

View File

@ -1,78 +0,0 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { MCPConfig } from "#/types/settings";
import { I18nKey } from "#/i18n/declaration";
import { MCPSSEServers } from "./mcp-sse-servers";
import { MCPStdioServers } from "./mcp-stdio-servers";
import { MCPJsonEditor } from "./mcp-json-editor";
import { BrandButton } from "../brand-button";
interface MCPConfigEditorProps {
mcpConfig?: MCPConfig;
onChange: (config: MCPConfig) => void;
}
export function MCPConfigEditor({ mcpConfig, onChange }: MCPConfigEditorProps) {
const { t } = useTranslation();
const [isEditing, setIsEditing] = useState(false);
const handleConfigChange = (newConfig: MCPConfig) => {
onChange(newConfig);
setIsEditing(false);
};
const config = mcpConfig || { sse_servers: [], stdio_servers: [] };
return (
<div>
<div className="flex flex-col gap-2 mb-6">
<div className="text-sm font-medium">
{t(I18nKey.SETTINGS$MCP_TITLE)}
</div>
<p className="text-xs text-[#A3A3A3]">
{t(I18nKey.SETTINGS$MCP_DESCRIPTION)}
</p>
</div>
{!isEditing && (
<div className="flex justify-between items-center mb-4">
<div className="flex items-center">
<BrandButton
type="button"
variant="primary"
onClick={() => setIsEditing(true)}
>
{t(I18nKey.SETTINGS$MCP_EDIT_CONFIGURATION)}
</BrandButton>
</div>
</div>
)}
<div>
{isEditing ? (
<MCPJsonEditor
mcpConfig={mcpConfig}
onChange={handleConfigChange}
onCancel={() => setIsEditing(false)}
/>
) : (
<>
<div className="flex flex-col gap-6">
<div>
<MCPSSEServers servers={config.sse_servers} />
</div>
<div>
<MCPStdioServers servers={config.stdio_servers} />
</div>
</div>
{config.sse_servers.length === 0 &&
config.stdio_servers.length === 0 && (
<div className="mt-4 p-2 bg-yellow-50 border border-yellow-200 rounded-md text-sm text-yellow-700">
{t(I18nKey.SETTINGS$MCP_NO_SERVERS_CONFIGURED)}
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@ -1,139 +0,0 @@
import React, { useState, useRef, useEffect } from "react";
import { useTranslation, Trans } from "react-i18next";
import { MCPConfig } from "#/types/settings";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../brand-button";
import { cn } from "#/utils/utils";
interface MCPJsonEditorProps {
mcpConfig?: MCPConfig;
onChange: (config: MCPConfig) => void;
onCancel: () => void;
}
const MCP_DEFAULT_CONFIG: MCPConfig = {
sse_servers: [],
stdio_servers: [],
};
export function MCPJsonEditor({
mcpConfig,
onChange,
onCancel,
}: MCPJsonEditorProps) {
const { t } = useTranslation();
const [configText, setConfigText] = useState(() =>
mcpConfig
? JSON.stringify(mcpConfig, null, 2)
: JSON.stringify(MCP_DEFAULT_CONFIG, null, 2),
);
const [error, setError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
textareaRef.current?.focus();
}, []);
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setConfigText(e.target.value);
};
const handleSave = () => {
try {
const newConfig = JSON.parse(configText);
// Validate the structure
if (!newConfig.sse_servers || !Array.isArray(newConfig.sse_servers)) {
throw new Error(t(I18nKey.SETTINGS$MCP_ERROR_SSE_ARRAY));
}
if (!newConfig.stdio_servers || !Array.isArray(newConfig.stdio_servers)) {
throw new Error(t(I18nKey.SETTINGS$MCP_ERROR_STDIO_ARRAY));
}
// Validate SSE servers
for (const server of newConfig.sse_servers) {
if (
typeof server !== "string" &&
(!server.url || typeof server.url !== "string")
) {
throw new Error(t(I18nKey.SETTINGS$MCP_ERROR_SSE_URL));
}
}
// Validate stdio servers
for (const server of newConfig.stdio_servers) {
if (!server.name || !server.command) {
throw new Error(t(I18nKey.SETTINGS$MCP_ERROR_STDIO_PROPS));
}
}
onChange(newConfig);
setError(null);
} catch (e) {
setError(
e instanceof Error
? e.message
: t(I18nKey.SETTINGS$MCP_ERROR_INVALID_JSON),
);
}
};
return (
<div>
<p className="mb-2 text-sm text-gray-400">
<Trans
i18nKey={I18nKey.SETTINGS$MCP_CONFIG_DESCRIPTION}
components={{
a: (
<a
href="https://docs.all-hands.dev/usage/mcp"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline"
>
documentation
</a>
),
}}
/>
</p>
<textarea
ref={textareaRef}
className={cn(
"w-full h-64 resize-y p-2 rounded-sm text-sm font-mono",
"bg-tertiary border border-[#717888]",
"placeholder:italic placeholder:text-tertiary-alt",
"focus:outline-none focus:ring-1 focus:ring-primary",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
value={configText}
onChange={handleTextChange}
spellCheck="false"
/>
{error && (
<div className="mt-2 p-2 bg-red-100 border border-red-300 rounded-md text-sm text-red-700">
<strong>{t(I18nKey.SETTINGS$MCP_CONFIG_ERROR)}</strong> {error}
</div>
)}
<div className="mt-2 text-sm text-gray-400">
<strong>{t(I18nKey.SETTINGS$MCP_CONFIG_EXAMPLE)}</strong>{" "}
<code>
{
'{ "sse_servers": ["https://example-mcp-server.com/sse"], "stdio_servers": [{ "name": "fetch", "command": "uvx", "args": ["mcp-server-fetch"] }] }'
}
</code>
</div>
<div className="mt-4 flex justify-end gap-3">
<BrandButton type="button" variant="secondary" onClick={onCancel}>
{t(I18nKey.BUTTON$CANCEL)}
</BrandButton>
<BrandButton type="button" variant="primary" onClick={handleSave}>
{t(I18nKey.SETTINGS$MCP_PREVIEW_CHANGES)}
</BrandButton>
</div>
</div>
);
}

View File

@ -0,0 +1,376 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "../settings-input";
import { SettingsDropdownInput } from "../settings-dropdown-input";
import { BrandButton } from "../brand-button";
import { OptionalTag } from "../optional-tag";
import { cn } from "#/utils/utils";
type MCPServerType = "sse" | "stdio" | "shttp";
interface MCPServerConfig {
id: string;
type: MCPServerType;
name?: string;
url?: string;
api_key?: string;
command?: string;
args?: string[];
env?: Record<string, string>;
}
interface MCPServerFormProps {
mode: "add" | "edit";
server?: MCPServerConfig;
existingServers?: MCPServerConfig[];
onSubmit: (server: MCPServerConfig) => void;
onCancel: () => void;
}
export function MCPServerForm({
mode,
server,
existingServers,
onSubmit,
onCancel,
}: MCPServerFormProps) {
const { t } = useTranslation();
const [serverType, setServerType] = React.useState<MCPServerType>(
server?.type || "sse",
);
const [error, setError] = React.useState<string | null>(null);
const serverTypeOptions = [
{ key: "sse", label: t(I18nKey.SETTINGS$MCP_SERVER_TYPE_SSE) },
{ key: "stdio", label: t(I18nKey.SETTINGS$MCP_SERVER_TYPE_STDIO) },
{ key: "shttp", label: t(I18nKey.SETTINGS$MCP_SERVER_TYPE_SHTTP) },
];
const validateUrl = (url: string): string | null => {
if (!url) return t(I18nKey.SETTINGS$MCP_ERROR_URL_REQUIRED);
try {
const urlObj = new URL(url);
if (!["http:", "https:"].includes(urlObj.protocol)) {
return t(I18nKey.SETTINGS$MCP_ERROR_URL_INVALID_PROTOCOL);
}
} catch {
return t(I18nKey.SETTINGS$MCP_ERROR_URL_INVALID);
}
return null;
};
const validateName = (name: string): string | null => {
if (!name) return t(I18nKey.SETTINGS$MCP_ERROR_NAME_REQUIRED);
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
return t(I18nKey.SETTINGS$MCP_ERROR_NAME_INVALID);
}
return null;
};
const validateNameUniqueness = (name: string): string | null => {
if (!existingServers) return null;
const shouldCheckUniqueness =
mode === "add" || (mode === "edit" && server?.name !== name);
if (!shouldCheckUniqueness) return null;
const existingStdioNames = existingServers
.filter((s) => s.type === "stdio")
.map((s) => s.name)
.filter(Boolean);
if (existingStdioNames.includes(name)) {
return t(I18nKey.SETTINGS$MCP_ERROR_NAME_DUPLICATE);
}
return null;
};
const validateCommand = (command: string): string | null => {
if (!command) return t(I18nKey.SETTINGS$MCP_ERROR_COMMAND_REQUIRED);
if (command.includes(" ")) {
return t(I18nKey.SETTINGS$MCP_ERROR_COMMAND_NO_SPACES);
}
return null;
};
const validateUrlUniqueness = (url: string): string | null => {
if (!existingServers) return null;
const originalUrl = server?.url;
const changed = mode === "add" || (mode === "edit" && originalUrl !== url);
if (!changed) return null;
// For URL-based servers (sse/shttp), ensure URL is unique across both types
const exists = existingServers.some(
(s) => (s.type === "sse" || s.type === "shttp") && s.url === url,
);
if (exists) return t(I18nKey.SETTINGS$MCP_ERROR_URL_DUPLICATE);
return null;
};
const validateEnvFormat = (envString: string): string | null => {
if (!envString.trim()) return null;
const lines = envString.split("\n");
for (let i = 0; i < lines.length; i += 1) {
const trimmed = lines[i].trim();
if (trimmed) {
const eq = trimmed.indexOf("=");
if (eq === -1) return t(I18nKey.SETTINGS$MCP_ERROR_ENV_INVALID_FORMAT);
const key = trimmed.substring(0, eq).trim();
if (!key) return t(I18nKey.SETTINGS$MCP_ERROR_ENV_INVALID_FORMAT);
}
}
return null;
};
const validateStdioServer = (formData: FormData): string | null => {
const name = formData.get("name")?.toString().trim() || "";
const command = formData.get("command")?.toString().trim() || "";
const envString = formData.get("env")?.toString() || "";
const nameError = validateName(name);
if (nameError) return nameError;
const uniquenessError = validateNameUniqueness(name);
if (uniquenessError) return uniquenessError;
const commandError = validateCommand(command);
if (commandError) return commandError;
// Validate environment variable format
const envError = validateEnvFormat(envString);
if (envError) return envError;
return null;
};
const validateForm = (formData: FormData): string | null => {
if (serverType === "sse" || serverType === "shttp") {
const url = formData.get("url")?.toString().trim() || "";
const urlError = validateUrl(url);
if (urlError) return urlError;
const urlDupError = validateUrlUniqueness(url);
if (urlDupError) return urlDupError;
return null;
}
if (serverType === "stdio") {
return validateStdioServer(formData);
}
return null;
};
const parseEnvironmentVariables = (
envString: string,
): Record<string, string> => {
const env: Record<string, string> = {};
const input = envString.trim();
if (!input) return env;
for (const line of input.split("\n")) {
const trimmed = line.trim();
const eq = trimmed.indexOf("=");
const key = eq >= 0 ? trimmed.substring(0, eq).trim() : "";
if (trimmed && eq !== -1 && key) {
env[key] = trimmed.substring(eq + 1).trim();
}
}
return env;
};
const formatEnvironmentVariables = (env?: Record<string, string>): string => {
if (!env) return "";
return Object.entries(env)
.map(([key, value]) => `${key}=${value}`)
.join("\n");
};
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
const formData = new FormData(event.currentTarget);
const validationError = validateForm(formData);
if (validationError) {
setError(validationError);
return;
}
const baseConfig = {
id: server?.id || `${serverType}-${Date.now()}`,
type: serverType,
};
if (serverType === "sse" || serverType === "shttp") {
const url = formData.get("url")?.toString().trim();
const apiKey = formData.get("api_key")?.toString().trim();
onSubmit({
...baseConfig,
url: url!,
...(apiKey && { api_key: apiKey }),
});
} else if (serverType === "stdio") {
const name = formData.get("name")?.toString().trim();
const command = formData.get("command")?.toString().trim();
const argsString = formData.get("args")?.toString().trim();
const envString = formData.get("env")?.toString().trim();
const args = argsString
? argsString
.split("\n")
.map((arg) => arg.trim())
.filter(Boolean)
: [];
const env = parseEnvironmentVariables(envString || "");
onSubmit({
...baseConfig,
name: name!,
command: command!,
...(args.length > 0 && { args }),
...(Object.keys(env).length > 0 && { env }),
});
}
};
const formTestId =
mode === "add" ? "add-mcp-server-form" : "edit-mcp-server-form";
return (
<form
data-testid={formTestId}
onSubmit={handleSubmit}
className="flex flex-col items-start gap-6"
>
{mode === "add" && (
<SettingsDropdownInput
testId="server-type-dropdown"
name="server-type"
label={t(I18nKey.SETTINGS$MCP_SERVER_TYPE)}
items={serverTypeOptions}
selectedKey={serverType}
onSelectionChange={(key) => setServerType(key as MCPServerType)}
onInputChange={() => {}} // Prevent input changes
isClearable={false}
allowsCustomValue={false}
required
wrapperClassName={cn("w-full", "max-w-[680px]")}
/>
)}
{error && <p className="text-red-500 text-sm">{error}</p>}
{(serverType === "sse" || serverType === "shttp") && (
<>
<SettingsInput
testId="url-input"
name="url"
type="url"
label={t(I18nKey.SETTINGS$MCP_URL)}
className="w-full max-w-[680px]"
required
defaultValue={server?.url || ""}
placeholder="https://api.example.com"
/>
<SettingsInput
testId="api-key-input"
name="api_key"
type="password"
label={t(I18nKey.SETTINGS$MCP_API_KEY)}
className="w-full max-w-[680px]"
showOptionalTag
defaultValue={server?.api_key || ""}
placeholder={t(I18nKey.SETTINGS$MCP_API_KEY_PLACEHOLDER)}
/>
</>
)}
{serverType === "stdio" && (
<>
<SettingsInput
testId="name-input"
name="name"
type="text"
label={t(I18nKey.SETTINGS$MCP_NAME)}
className="w-full max-w-[680px]"
required
defaultValue={server?.name || ""}
placeholder="my-mcp-server"
pattern="^[a-zA-Z0-9_-]+$"
/>
<SettingsInput
testId="command-input"
name="command"
type="text"
label={t(I18nKey.SETTINGS$MCP_COMMAND)}
className="w-full max-w-[680px]"
required
defaultValue={server?.command || ""}
placeholder="npx"
/>
<label className="flex flex-col gap-2.5 w-full max-w-[680px]">
<div className="flex items-center gap-2">
<span className="text-sm">
{t(I18nKey.SETTINGS$MCP_COMMAND_ARGUMENTS)}
</span>
<OptionalTag />
</div>
<textarea
data-testid="args-input"
name="args"
rows={3}
defaultValue={server?.args?.join("\n") || ""}
placeholder="arg1&#10;arg2&#10;arg3"
className={cn(
"bg-tertiary border border-[#717888] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt resize-none",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
<p className="text-xs text-tertiary-alt">
{t(I18nKey.SETTINGS$MCP_COMMAND_ARGUMENTS_HELP)}
</p>
</label>
<label className="flex flex-col gap-2.5 w-full max-w-[680px]">
<div className="flex items-center gap-2">
<span className="text-sm">
{t(I18nKey.SETTINGS$MCP_ENVIRONMENT_VARIABLES)}
</span>
<OptionalTag />
</div>
<textarea
data-testid="env-input"
name="env"
rows={4}
defaultValue={formatEnvironmentVariables(server?.env)}
placeholder="KEY1=value1&#10;KEY2=value2"
className={cn(
"resize-none",
"bg-tertiary border border-[#717888] rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
</label>
</>
)}
<div className="flex items-center gap-4">
<BrandButton
testId="cancel-button"
type="button"
variant="secondary"
onClick={onCancel}
>
{t(I18nKey.BUTTON$CANCEL)}
</BrandButton>
<BrandButton testId="submit-button" type="submit" variant="primary">
{mode === "add" && t(I18nKey.SETTINGS$MCP_ADD_SERVER)}
{mode === "edit" && t(I18nKey.SETTINGS$MCP_SAVE_SERVER)}
</BrandButton>
</div>
</form>
);
}

View File

@ -0,0 +1,110 @@
import { FaPencil, FaTrash } from "react-icons/fa6";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
interface MCPServerConfig {
id: string;
type: "sse" | "stdio" | "shttp";
name?: string;
url?: string;
api_key?: string;
command?: string;
args?: string[];
env?: Record<string, string>;
}
export function MCPServerListItem({
server,
onEdit,
onDelete,
}: {
server: MCPServerConfig;
onEdit: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
const getServerTypeLabel = (type: string) => {
switch (type) {
case "sse":
return t(I18nKey.SETTINGS$MCP_SERVER_TYPE_SSE);
case "stdio":
return t(I18nKey.SETTINGS$MCP_SERVER_TYPE_STDIO);
case "shttp":
return t(I18nKey.SETTINGS$MCP_SERVER_TYPE_SHTTP);
default:
return type.toUpperCase();
}
};
const getServerDescription = (serverConfig: MCPServerConfig) => {
if (serverConfig.type === "stdio") {
if (serverConfig.command) {
const args =
serverConfig.args && serverConfig.args.length > 0
? ` ${serverConfig.args.join(" ")}`
: "";
return `${serverConfig.command}${args}`;
}
return serverConfig.name || "";
}
if (
(serverConfig.type === "sse" || serverConfig.type === "shttp") &&
serverConfig.url
) {
return serverConfig.url;
}
return "";
};
const serverName = server.type === "stdio" ? server.name : server.url;
const serverDescription = getServerDescription(server);
return (
<tr
data-testid="mcp-server-item"
className="grid grid-cols-[minmax(0,0.25fr)_120px_minmax(0,1fr)_120px] gap-4 items-start border-t border-tertiary"
>
<td
className="p-3 text-sm text-content-2 truncate min-w-0"
title={serverName}
>
{serverName}
</td>
<td className="p-3 text-sm text-content-2 whitespace-nowrap">
{getServerTypeLabel(server.type)}
</td>
<td
className="p-3 text-sm text-content-2 opacity-80 italic min-w-0 truncate"
title={serverDescription}
>
<span className="inline-block max-w-full align-bottom">
{serverDescription}
</span>
</td>
<td className="p-3 flex items-start justify-end gap-4 whitespace-nowrap">
<button
data-testid="edit-mcp-server-button"
type="button"
onClick={onEdit}
aria-label={`Edit ${serverName}`}
className="cursor-pointer hover:text-content-1 transition-colors"
>
<FaPencil size={16} />
</button>
<button
data-testid="delete-mcp-server-button"
type="button"
onClick={onDelete}
aria-label={`Delete ${serverName}`}
className="cursor-pointer hover:text-content-1 transition-colors"
>
<FaTrash size={16} />
</button>
</td>
</tr>
);
}

View File

@ -0,0 +1,71 @@
import { useTranslation } from "react-i18next";
import { MCPServerListItem } from "./mcp-server-list-item";
import { I18nKey } from "#/i18n/declaration";
interface MCPServerConfig {
id: string;
type: "sse" | "stdio" | "shttp";
name?: string;
url?: string;
api_key?: string;
command?: string;
args?: string[];
env?: Record<string, string>;
}
interface MCPServerListProps {
servers: MCPServerConfig[];
onEdit: (server: MCPServerConfig) => void;
onDelete: (serverId: string) => void;
}
export function MCPServerList({
servers,
onEdit,
onDelete,
}: MCPServerListProps) {
const { t } = useTranslation();
if (servers.length === 0) {
return (
<div className="border border-tertiary rounded-md p-8 text-center">
<p className="text-content-2 text-sm">
{t(I18nKey.SETTINGS$MCP_NO_SERVERS)}
</p>
</div>
);
}
return (
<div className="border border-tertiary rounded-md overflow-hidden">
<table className="w-full">
<thead className="bg-base-tertiary">
<tr className="grid grid-cols-[minmax(0,0.25fr)_120px_minmax(0,1fr)_120px] gap-4 items-start">
<th className="text-left p-3 text-sm font-medium">
{t(I18nKey.SETTINGS$NAME)}
</th>
<th className="text-left p-3 text-sm font-medium">
{t(I18nKey.SETTINGS$MCP_SERVER_TYPE)}
</th>
<th className="text-left p-3 text-sm font-medium">
{t(I18nKey.SETTINGS$MCP_SERVER_DETAILS)}
</th>
<th className="text-right p-3 text-sm font-medium">
{t(I18nKey.SETTINGS$ACTIONS)}
</th>
</tr>
</thead>
<tbody>
{servers.map((server) => (
<MCPServerListItem
key={server.id}
server={server}
onEdit={() => onEdit(server)}
onDelete={() => onDelete(server.id)}
/>
))}
</tbody>
</table>
</div>
);
}

View File

@ -0,0 +1,67 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import OpenHands from "#/api/open-hands";
import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings";
type MCPServerType = "sse" | "stdio" | "shttp";
interface MCPServerConfig {
type: MCPServerType;
name?: string;
url?: string;
api_key?: string;
command?: string;
args?: string[];
env?: Record<string, string>;
}
export function useAddMcpServer() {
const queryClient = useQueryClient();
const { data: settings } = useSettings();
return useMutation({
mutationFn: async (server: MCPServerConfig): Promise<void> => {
if (!settings) return;
const currentConfig = settings.MCP_CONFIG || {
sse_servers: [],
stdio_servers: [],
shttp_servers: [],
};
const newConfig = { ...currentConfig };
if (server.type === "sse") {
const sseServer: MCPSSEServer = {
url: server.url!,
...(server.api_key && { api_key: server.api_key }),
};
newConfig.sse_servers.push(sseServer);
} else if (server.type === "stdio") {
const stdioServer: MCPStdioServer = {
name: server.name!,
command: server.command!,
...(server.args && { args: server.args }),
...(server.env && { env: server.env }),
};
newConfig.stdio_servers.push(stdioServer);
} else if (server.type === "shttp") {
const shttpServer: MCPSHTTPServer = {
url: server.url!,
...(server.api_key && { api_key: server.api_key }),
};
newConfig.shttp_servers.push(shttpServer);
}
const apiSettings = {
mcp_config: newConfig,
};
await OpenHands.saveSettings(apiSettings);
},
onSuccess: () => {
// Invalidate the settings query to trigger a refetch
queryClient.invalidateQueries({ queryKey: ["settings"] });
},
});
}

View File

@ -0,0 +1,37 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import OpenHands from "#/api/open-hands";
import { MCPConfig } from "#/types/settings";
export function useDeleteMcpServer() {
const queryClient = useQueryClient();
const { data: settings } = useSettings();
return useMutation({
mutationFn: async (serverId: string): Promise<void> => {
if (!settings?.MCP_CONFIG) return;
const newConfig: MCPConfig = { ...settings.MCP_CONFIG };
const [serverType, indexStr] = serverId.split("-");
const index = parseInt(indexStr, 10);
if (serverType === "sse") {
newConfig.sse_servers.splice(index, 1);
} else if (serverType === "stdio") {
newConfig.stdio_servers.splice(index, 1);
} else if (serverType === "shttp") {
newConfig.shttp_servers.splice(index, 1);
}
const apiSettings = {
mcp_config: newConfig,
};
await OpenHands.saveSettings(apiSettings);
},
onSuccess: () => {
// Invalidate the settings query to trigger a refetch
queryClient.invalidateQueries({ queryKey: ["settings"] });
},
});
}

View File

@ -0,0 +1,69 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import OpenHands from "#/api/open-hands";
import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings";
type MCPServerType = "sse" | "stdio" | "shttp";
interface MCPServerConfig {
type: MCPServerType;
name?: string;
url?: string;
api_key?: string;
command?: string;
args?: string[];
env?: Record<string, string>;
}
export function useUpdateMcpServer() {
const queryClient = useQueryClient();
const { data: settings } = useSettings();
return useMutation({
mutationFn: async ({
serverId,
server,
}: {
serverId: string;
server: MCPServerConfig;
}): Promise<void> => {
if (!settings?.MCP_CONFIG) return;
const newConfig = { ...settings.MCP_CONFIG };
const [serverType, indexStr] = serverId.split("-");
const index = parseInt(indexStr, 10);
if (serverType === "sse") {
const sseServer: MCPSSEServer = {
url: server.url!,
...(server.api_key && { api_key: server.api_key }),
};
newConfig.sse_servers[index] = sseServer;
} else if (serverType === "stdio") {
const stdioServer: MCPStdioServer = {
name: server.name!,
command: server.command!,
...(server.args && { args: server.args }),
...(server.env && { env: server.env }),
};
newConfig.stdio_servers[index] = stdioServer;
} else if (serverType === "shttp") {
const shttpServer: MCPSHTTPServer = {
url: server.url!,
...(server.api_key && { api_key: server.api_key }),
};
newConfig.shttp_servers[index] = shttpServer;
}
const apiSettings = {
mcp_config: newConfig,
};
await OpenHands.saveSettings(apiSettings);
},
onSuccess: () => {
// Invalidate the settings query to trigger a refetch
queryClient.invalidateQueries({ queryKey: ["settings"] });
},
});
}

View File

@ -781,4 +781,33 @@ export enum I18nKey {
PROJECT_MANAGEMENT$SVC_ACC_EMAIL_VALIDATION_ERROR = "PROJECT_MANAGEMENT$SVC_ACC_EMAIL_VALIDATION_ERROR",
PROJECT_MANAGEMENT$SVC_ACC_API_KEY_VALIDATION_ERROR = "PROJECT_MANAGEMENT$SVC_ACC_API_KEY_VALIDATION_ERROR",
MICROAGENT_MANAGEMENT$ERROR_LOADING_MICROAGENT_CONTENT = "MICROAGENT_MANAGEMENT$ERROR_LOADING_MICROAGENT_CONTENT",
SETTINGS$MCP_ERROR_ENV_INVALID_FORMAT = "SETTINGS$MCP_ERROR_ENV_INVALID_FORMAT",
SETTINGS$MCP_ERROR_URL_DUPLICATE = "SETTINGS$MCP_ERROR_URL_DUPLICATE",
SETTINGS$MCP_SERVER_TYPE_SSE = "SETTINGS$MCP_SERVER_TYPE_SSE",
SETTINGS$MCP_SERVER_TYPE_STDIO = "SETTINGS$MCP_SERVER_TYPE_STDIO",
SETTINGS$MCP_SERVER_TYPE_SHTTP = "SETTINGS$MCP_SERVER_TYPE_SHTTP",
SETTINGS$MCP_ERROR_URL_REQUIRED = "SETTINGS$MCP_ERROR_URL_REQUIRED",
SETTINGS$MCP_ERROR_URL_INVALID_PROTOCOL = "SETTINGS$MCP_ERROR_URL_INVALID_PROTOCOL",
SETTINGS$MCP_ERROR_URL_INVALID = "SETTINGS$MCP_ERROR_URL_INVALID",
SETTINGS$MCP_ERROR_NAME_REQUIRED = "SETTINGS$MCP_ERROR_NAME_REQUIRED",
SETTINGS$MCP_ERROR_NAME_INVALID = "SETTINGS$MCP_ERROR_NAME_INVALID",
SETTINGS$MCP_ERROR_NAME_DUPLICATE = "SETTINGS$MCP_ERROR_NAME_DUPLICATE",
SETTINGS$MCP_ERROR_COMMAND_REQUIRED = "SETTINGS$MCP_ERROR_COMMAND_REQUIRED",
SETTINGS$MCP_ERROR_COMMAND_NO_SPACES = "SETTINGS$MCP_ERROR_COMMAND_NO_SPACES",
SETTINGS$MCP_SERVER_TYPE = "SETTINGS$MCP_SERVER_TYPE",
SETTINGS$MCP_API_KEY_PLACEHOLDER = "SETTINGS$MCP_API_KEY_PLACEHOLDER",
SETTINGS$MCP_COMMAND_ARGUMENTS = "SETTINGS$MCP_COMMAND_ARGUMENTS",
SETTINGS$MCP_COMMAND_ARGUMENTS_HELP = "SETTINGS$MCP_COMMAND_ARGUMENTS_HELP",
SETTINGS$MCP_ENVIRONMENT_VARIABLES = "SETTINGS$MCP_ENVIRONMENT_VARIABLES",
SETTINGS$MCP_ADD_SERVER = "SETTINGS$MCP_ADD_SERVER",
SETTINGS$MCP_SAVE_SERVER = "SETTINGS$MCP_SAVE_SERVER",
SETTINGS$MCP_NO_SERVERS = "SETTINGS$MCP_NO_SERVERS",
SETTINGS$MCP_SERVER_DETAILS = "SETTINGS$MCP_SERVER_DETAILS",
SETTINGS$MCP_CONFIRM_DELETE = "SETTINGS$MCP_CONFIRM_DELETE",
SETTINGS$MCP_CONFIRM_CHANGES = "SETTINGS$MCP_CONFIRM_CHANGES",
SETTINGS$MCP_DEFAULT_CONFIG = "SETTINGS$MCP_DEFAULT_CONFIG",
PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER = "PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER",
PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION = "PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION",
PROJECT_MANAGEMENT$IMPORTANT_WORKSPACE_INTEGRATION = "PROJECT_MANAGEMENT$IMPORTANT_WORKSPACE_INTEGRATION",
SETTINGS = "SETTINGS",
}

File diff suppressed because it is too large Load Diff

View File

@ -1,86 +1,191 @@
import React, { useState, useEffect } from "react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import posthog from "posthog-js";
import { useSettings } from "#/hooks/query/use-settings";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { MCPConfig } from "#/types/settings";
import { MCPConfigEditor } from "#/components/features/settings/mcp-settings/mcp-config-editor";
import { BrandButton } from "#/components/features/settings/brand-button";
import { useDeleteMcpServer } from "#/hooks/mutation/use-delete-mcp-server";
import { useAddMcpServer } from "#/hooks/mutation/use-add-mcp-server";
import { useUpdateMcpServer } from "#/hooks/mutation/use-update-mcp-server";
import { I18nKey } from "#/i18n/declaration";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { MCPServerList } from "#/components/features/settings/mcp-settings/mcp-server-list";
import { MCPServerForm } from "#/components/features/settings/mcp-settings/mcp-server-form";
import { ConfirmationModal } from "#/components/shared/modals/confirmation-modal";
import { BrandButton } from "#/components/features/settings/brand-button";
import { MCPConfig } from "#/types/settings";
type MCPServerType = "sse" | "stdio" | "shttp";
interface MCPServerConfig {
id: string;
type: MCPServerType;
name?: string;
url?: string;
api_key?: string;
command?: string;
args?: string[];
env?: Record<string, string>;
}
function MCPSettingsScreen() {
const { t } = useTranslation();
const { data: settings, isLoading } = useSettings();
const { mutate: saveSettings, isPending } = useSaveSettings();
const { mutate: deleteMcpServer } = useDeleteMcpServer();
const { mutate: addMcpServer } = useAddMcpServer();
const { mutate: updateMcpServer } = useUpdateMcpServer();
const [mcpConfig, setMcpConfig] = useState<MCPConfig | undefined>(undefined);
const [isDirty, setIsDirty] = useState(false);
const [view, setView] = useState<"list" | "add" | "edit">("list");
const [editingServer, setEditingServer] = useState<MCPServerConfig | null>(
null,
);
const [confirmationModalIsVisible, setConfirmationModalIsVisible] =
useState(false);
const [serverToDelete, setServerToDelete] = useState<string | null>(null);
useEffect(() => {
if (!mcpConfig && settings?.MCP_CONFIG) {
setMcpConfig(settings.MCP_CONFIG);
}
}, [settings, mcpConfig]);
const handleConfigChange = (config: MCPConfig) => {
setMcpConfig(config);
setIsDirty(true);
const mcpConfig: MCPConfig = settings?.MCP_CONFIG || {
sse_servers: [],
stdio_servers: [],
shttp_servers: [],
};
const formAction = () => {
if (!settings) return;
// Convert servers to a unified format for display
const allServers: MCPServerConfig[] = [
...mcpConfig.sse_servers.map((server, index) => ({
id: `sse-${index}`,
type: "sse" as const,
url: typeof server === "string" ? server : server.url,
api_key: typeof server === "object" ? server.api_key : undefined,
})),
...mcpConfig.stdio_servers.map((server, index) => ({
id: `stdio-${index}`,
type: "stdio" as const,
name: server.name,
command: server.command,
args: server.args,
env: server.env,
})),
...mcpConfig.shttp_servers.map((server, index) => ({
id: `shttp-${index}`,
type: "shttp" as const,
url: typeof server === "string" ? server : server.url,
api_key: typeof server === "object" ? server.api_key : undefined,
})),
];
saveSettings(
{ MCP_CONFIG: mcpConfig },
const handleAddServer = (serverConfig: MCPServerConfig) => {
addMcpServer(serverConfig, {
onSuccess: () => {
setView("list");
},
});
};
const handleEditServer = (serverConfig: MCPServerConfig) => {
updateMcpServer(
{
serverId: serverConfig.id,
server: serverConfig,
},
{
onSuccess: () => {
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
posthog.capture("settings_saved", {
HAS_MCP_CONFIG: mcpConfig ? "YES" : "NO",
MCP_SSE_SERVERS_COUNT: mcpConfig?.sse_servers?.length || 0,
MCP_STDIO_SERVERS_COUNT: mcpConfig?.stdio_servers?.length || 0,
});
setIsDirty(false);
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
setView("list");
},
},
);
};
const handleDeleteServer = (serverId: string) => {
deleteMcpServer(serverId, {
onSuccess: () => {
setConfirmationModalIsVisible(false);
},
});
};
const handleEditClick = (server: MCPServerConfig) => {
setEditingServer(server);
setView("edit");
};
const handleDeleteClick = (serverId: string) => {
setServerToDelete(serverId);
setConfirmationModalIsVisible(true);
};
const handleConfirmDelete = () => {
if (serverToDelete) {
handleDeleteServer(serverToDelete);
setServerToDelete(null);
}
};
const handleCancelDelete = () => {
setConfirmationModalIsVisible(false);
setServerToDelete(null);
};
if (isLoading) {
return <div className="p-9">{t(I18nKey.HOME$LOADING)}</div>;
return (
<div className="px-11 py-9 flex flex-col gap-5">
<div className="animate-pulse">
<div className="h-6 bg-gray-300 rounded w-1/4 mb-4" />
<div className="h-4 bg-gray-300 rounded w-1/2 mb-8" />
<div className="h-10 bg-gray-300 rounded w-32" />
</div>
</div>
);
}
return (
<form
data-testid="mcp-settings-screen"
action={formAction}
className="flex flex-col h-full justify-between"
>
<div className="p-9 flex flex-col gap-12">
<MCPConfigEditor mcpConfig={mcpConfig} onChange={handleConfigChange} />
</div>
<div className="px-11 py-9 flex flex-col gap-5">
{view === "list" && (
<>
<BrandButton
testId="add-mcp-server-button"
type="button"
variant="primary"
onClick={() => setView("add")}
isDisabled={isLoading}
>
{t(I18nKey.SETTINGS$MCP_ADD_SERVER)}
</BrandButton>
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
<BrandButton
testId="submit-button"
type="submit"
variant="primary"
isDisabled={!isDirty || isPending}
>
{!isPending && t(I18nKey.SETTINGS$SAVE_CHANGES)}
{isPending && t(I18nKey.SETTINGS$SAVING)}
</BrandButton>
</div>
</form>
<MCPServerList
servers={allServers}
onEdit={handleEditClick}
onDelete={handleDeleteClick}
/>
</>
)}
{view === "add" && (
<MCPServerForm
mode="add"
existingServers={allServers}
onSubmit={handleAddServer}
onCancel={() => setView("list")}
/>
)}
{view === "edit" && editingServer && (
<MCPServerForm
mode="edit"
server={editingServer}
existingServers={allServers}
onSubmit={handleEditServer}
onCancel={() => {
setView("list");
setEditingServer(null);
}}
/>
)}
{confirmationModalIsVisible && (
<ConfirmationModal
text={t(I18nKey.SETTINGS$MCP_CONFIRM_DELETE)}
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
)}
</div>
);
}

View File

@ -26,6 +26,7 @@ export const DEFAULT_SETTINGS: Settings = {
MCP_CONFIG: {
sse_servers: [],
stdio_servers: [],
shttp_servers: [],
},
GIT_USER_NAME: "openhands",
GIT_USER_EMAIL: "openhands@all-hands.dev",

View File

@ -24,9 +24,15 @@ export type MCPStdioServer = {
env?: Record<string, string>;
};
export type MCPSHTTPServer = {
url: string;
api_key?: string;
};
export type MCPConfig = {
sse_servers: (string | MCPSSEServer)[];
stdio_servers: MCPStdioServer[];
shttp_servers: (string | MCPSHTTPServer)[];
};
export type Settings = {
@ -77,6 +83,7 @@ export type ApiSettings = {
mcp_config?: {
sse_servers: (string | MCPSSEServer)[];
stdio_servers: MCPStdioServer[];
shttp_servers: (string | MCPSHTTPServer)[];
};
email?: string;
email_verified?: boolean;