From ccf2c7f2cb92c62814703101ace227be2d12207a Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Thu, 8 May 2025 00:43:53 +0800 Subject: [PATCH] Add MCP configuration visualization and editing in settings modal (#8029) Co-authored-by: openhands Co-authored-by: Ray Myers --- .github/workflows/ghcr-build.yml | 4 +- .../mcp-settings/mcp-config-editor.tsx | 84 ++++ .../mcp-settings/mcp-config-viewer.tsx | 141 ++++++ .../settings/mcp-settings/mcp-json-editor.tsx | 97 ++++ .../settings/mcp-settings/mcp-sse-servers.tsx | 42 ++ .../mcp-settings/mcp-stdio-servers.tsx | 58 +++ .../shared/modals/settings/settings-modal.tsx | 1 + .../src/hooks/mutation/use-save-settings.ts | 22 + frontend/src/hooks/query/use-settings.ts | 2 + frontend/src/i18n/declaration.ts | 29 ++ frontend/src/i18n/translation.json | 435 ++++++++++++++++++ frontend/src/react-app-env.d.ts | 6 + frontend/src/routes.ts | 1 + frontend/src/routes/mcp-settings.tsx | 83 ++++ frontend/src/routes/settings.tsx | 1 + frontend/src/services/observations.ts | 9 + frontend/src/services/settings.ts | 4 + frontend/src/state/chat-slice.ts | 25 + frontend/src/types/action-type.tsx | 3 + frontend/src/types/core/actions.ts | 12 +- frontend/src/types/core/base.ts | 4 +- frontend/src/types/core/observations.ts | 10 +- frontend/src/types/observation-type.tsx | 3 + frontend/src/types/settings.ts | 24 + .../action_execution_client.py | 4 +- openhands/server/session/session.py | 3 +- openhands/server/settings.py | 2 + openhands/storage/data_models/settings.py | 9 + 28 files changed, 1111 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/features/settings/mcp-settings/mcp-config-editor.tsx create mode 100644 frontend/src/components/features/settings/mcp-settings/mcp-config-viewer.tsx create mode 100644 frontend/src/components/features/settings/mcp-settings/mcp-json-editor.tsx create mode 100644 frontend/src/components/features/settings/mcp-settings/mcp-sse-servers.tsx create mode 100644 frontend/src/components/features/settings/mcp-settings/mcp-stdio-servers.tsx create mode 100644 frontend/src/routes/mcp-settings.tsx diff --git a/.github/workflows/ghcr-build.yml b/.github/workflows/ghcr-build.yml index 61671b7fb8..5245ae471a 100644 --- a/.github/workflows/ghcr-build.yml +++ b/.github/workflows/ghcr-build.yml @@ -40,7 +40,9 @@ jobs: # Only build nikolaik on PRs, otherwise build both nikolaik and ubuntu. if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then json=$(jq -n -c '[ - { image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" } + { image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" }, + { image: "ubuntu:24.04", tag: "ubuntu" } + ]') else json=$(jq -n -c '[ diff --git a/frontend/src/components/features/settings/mcp-settings/mcp-config-editor.tsx b/frontend/src/components/features/settings/mcp-settings/mcp-config-editor.tsx new file mode 100644 index 0000000000..2015650f52 --- /dev/null +++ b/frontend/src/components/features/settings/mcp-settings/mcp-config-editor.tsx @@ -0,0 +1,84 @@ +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 ( +
+
+
+ {t(I18nKey.SETTINGS$MCP_TITLE)} +
+

+ {t(I18nKey.SETTINGS$MCP_DESCRIPTION)} +

+
+
+
+ e.stopPropagation()} + > + Documentation + + setIsEditing(!isEditing)} + > + {isEditing + ? t(I18nKey.SETTINGS$MCP_CANCEL) + : t(I18nKey.SETTINGS$MCP_EDIT_CONFIGURATION)} + +
+
+ +
+ {isEditing ? ( + + ) : ( + <> +
+
+ +
+ +
+ +
+
+ + {config.sse_servers.length === 0 && + config.stdio_servers.length === 0 && ( +
+ {t(I18nKey.SETTINGS$MCP_NO_SERVERS_CONFIGURED)} +
+ )} + + )} +
+
+ ); +} diff --git a/frontend/src/components/features/settings/mcp-settings/mcp-config-viewer.tsx b/frontend/src/components/features/settings/mcp-settings/mcp-config-viewer.tsx new file mode 100644 index 0000000000..011d1151c1 --- /dev/null +++ b/frontend/src/components/features/settings/mcp-settings/mcp-config-viewer.tsx @@ -0,0 +1,141 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { MCPConfig, MCPSSEServer, MCPStdioServer } from "#/types/settings"; +import { I18nKey } from "#/i18n/declaration"; + +interface MCPConfigViewerProps { + mcpConfig?: MCPConfig; +} + +interface SSEServerDisplayProps { + server: string | MCPSSEServer; +} + +function SSEServerDisplay({ server }: SSEServerDisplayProps) { + const { t } = useTranslation(); + + if (typeof server === "string") { + return ( +
+
+ {t(I18nKey.SETTINGS$MCP_URL)}:{" "} + {server} +
+
+ ); + } + + return ( +
+
+ {t(I18nKey.SETTINGS$MCP_URL)}:{" "} + {server.url} +
+ {server.api_key && ( +
+ + {t(I18nKey.SETTINGS$MCP_API_KEY)}: + {" "} + {server.api_key ? "Set" : t(I18nKey.SETTINGS$MCP_API_KEY_NOT_SET)} +
+ )} +
+ ); +} + +interface StdioServerDisplayProps { + server: MCPStdioServer; +} + +function StdioServerDisplay({ server }: StdioServerDisplayProps) { + const { t } = useTranslation(); + + return ( +
+
+ {t(I18nKey.SETTINGS$MCP_NAME)}:{" "} + {server.name} +
+
+ {t(I18nKey.SETTINGS$MCP_COMMAND)}:{" "} + {server.command} +
+ {server.args && server.args.length > 0 && ( +
+ {t(I18nKey.SETTINGS$MCP_ARGS)}:{" "} + {server.args.join(" ")} +
+ )} + {server.env && Object.keys(server.env).length > 0 && ( +
+ {t(I18nKey.SETTINGS$MCP_ENV)}:{" "} + {Object.entries(server.env) + .map(([key, value]) => `${key}=${value}`) + .join(", ")} +
+ )} +
+ ); +} + +export function MCPConfigViewer({ mcpConfig }: MCPConfigViewerProps) { + const { t } = useTranslation(); + + if ( + !mcpConfig || + (mcpConfig.sse_servers.length === 0 && mcpConfig.stdio_servers.length === 0) + ) { + return null; + } + + return ( +
+
+

+ {t(I18nKey.SETTINGS$MCP_CONFIGURATION)} +

+ e.stopPropagation()} + > + {t(I18nKey.SETTINGS$MCP_LEARN_MORE)} + +
+ +
+
+ {mcpConfig.sse_servers.length > 0 && ( +
+

+ {t(I18nKey.SETTINGS$MCP_SSE_SERVERS)}{" "} + + ({mcpConfig.sse_servers.length}) + +

+ {mcpConfig.sse_servers.map((server, index) => ( + + ))} +
+ )} + + {mcpConfig.stdio_servers.length > 0 && ( +
+

+ {t(I18nKey.SETTINGS$MCP_STDIO_SERVERS)}{" "} + + ({mcpConfig.stdio_servers.length}) + +

+ {mcpConfig.stdio_servers.map((server, index) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/features/settings/mcp-settings/mcp-json-editor.tsx b/frontend/src/components/features/settings/mcp-settings/mcp-json-editor.tsx new file mode 100644 index 0000000000..cfc84e545f --- /dev/null +++ b/frontend/src/components/features/settings/mcp-settings/mcp-json-editor.tsx @@ -0,0 +1,97 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { MCPConfig } from "#/types/settings"; +import { I18nKey } from "#/i18n/declaration"; +import { BrandButton } from "../brand-button"; + +interface MCPJsonEditorProps { + mcpConfig?: MCPConfig; + onChange: (config: MCPConfig) => void; +} + +export function MCPJsonEditor({ mcpConfig, onChange }: MCPJsonEditorProps) { + const { t } = useTranslation(); + const [configText, setConfigText] = useState(() => + mcpConfig + ? JSON.stringify(mcpConfig, null, 2) + : t(I18nKey.SETTINGS$MCP_DEFAULT_CONFIG), + ); + const [error, setError] = useState(null); + + const handleTextChange = (e: React.ChangeEvent) => { + 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 ( +
+
+ {t(I18nKey.SETTINGS$MCP_CONFIG_DESCRIPTION)} +
+