Download src/components/features/mcp-page/custom-server-editor.tsx from SaylorTwift/openhands: direct link, hf CLI and curl.
- Browser
- Download file 9.68 kB
-
https://huggingface.co/SaylorTwift/openhands/resolve/main/src/components/features/mcp-page/custom-server-editor.tsx
- Command line
-
hf download hf://SaylorTwift/openhands/src/components/features/mcp-page/custom-server-editor.tsx
-
curl -L -o custom-server-editor.tsx https://huggingface.co/SaylorTwift/openhands/resolve/main/src/components/features/mcp-page/custom-server-editor.tsx
9.68 kB
| import React from "react"; | |
| import { AxiosError } from "axios"; | |
| import { useTranslation } from "react-i18next"; | |
| import { I18nKey } from "#/i18n/declaration"; | |
| import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; | |
| import { ModalCloseButton } from "#/components/shared/modals/modal-close-button"; | |
| import { ConfirmationModal } from "#/components/shared/modals/confirmation-modal"; | |
| import { | |
| MCPServerForm, | |
| type TestMessage, | |
| } from "#/components/features/settings/mcp-settings/mcp-server-form"; | |
| import { seedMcpServerHealth } from "#/api/mcp-health/probe-mcp-server-health"; | |
| import { useAddMcpServer } from "#/hooks/mutation/use-add-mcp-server"; | |
| import { useUpdateMcpServer } from "#/hooks/mutation/use-update-mcp-server"; | |
| import { useDeleteMcpServer } from "#/hooks/mutation/use-delete-mcp-server"; | |
| import { useTestMcpServer } from "#/hooks/mutation/use-test-mcp-server"; | |
| import { useActiveBackend } from "#/contexts/active-backend-context"; | |
| import { ExtendedMCPTestResponse, MCPServerConfig } from "#/types/mcp-server"; | |
| import { | |
| displayErrorToast, | |
| displaySuccessToast, | |
| } from "#/utils/custom-toast-handlers"; | |
| import { makeMcpTestErrorMessage } from "#/utils/mcp-test-error-message"; | |
| import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"; | |
| import { cn } from "#/utils/utils"; | |
| import { modalTitleLgClassName } from "#/utils/modal-classes"; | |
| import McpService from "#/api/mcp-service/mcp-service.api"; | |
| import { MCP_RENAME_CREDENTIAL_ERROR } from "#/utils/mcp-config"; | |
| interface CustomServerEditorProps { | |
| server: MCPServerConfig; | |
| existingServers: MCPServerConfig[]; | |
| onClose: () => void; | |
| } | |
| /** | |
| * Modal wrapper around `MCPServerForm` so users can hand-author | |
| * arbitrary stdio / SSE / SHTTP entries without reaching for raw JSON. | |
| * An empty `server.id` means "Add new". | |
| */ | |
| export function CustomServerEditor({ | |
| server, | |
| existingServers, | |
| onClose, | |
| }: CustomServerEditorProps) { | |
| const { t } = useTranslation("openhands"); | |
| const { mutate: addMcpServer, isPending: isAdding } = useAddMcpServer(); | |
| const { mutate: updateMcpServer, isPending: isUpdating } = | |
| useUpdateMcpServer(); | |
| const { mutate: deleteMcpServer, isPending: isDeleting } = | |
| useDeleteMcpServer(); | |
| const { | |
| mutate: testServer, | |
| isPending: isTesting, | |
| data: testResult, | |
| reset: resetTest, | |
| } = useTestMcpServer(); | |
| const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); | |
| const [oauthTestResult, setOauthTestResult] = | |
| React.useState<ExtendedMCPTestResponse | null>(null); | |
| const [isOauthTesting, setIsOauthTesting] = React.useState(false); | |
| // The MCP connectivity-test endpoint only exists on the local agent-server. | |
| // For cloud backends `McpService.testServer` short-circuits with a synthetic | |
| // success so the save still completes; we hide the manual "Test connection" | |
| // button here so cloud users aren't shown a misleading "0 tools" result. | |
| const { backend } = useActiveBackend(); | |
| const isCloudBackend = backend.kind === "cloud"; | |
| const isEditing = !!server.id; | |
| const isPending = isAdding || isUpdating || isDeleting; | |
| const isDismissBlocked = | |
| isPending || isTesting || isOauthTesting || showDeleteConfirm; | |
| const testMessage: TestMessage | null = React.useMemo(() => { | |
| const result = oauthTestResult ?? testResult; | |
| if (!result) return null; | |
| if (result.ok) { | |
| return { | |
| ok: true, | |
| text: t(I18nKey.MCP$TEST_SUCCESS, { count: result.tools.length }), | |
| }; | |
| } | |
| return { | |
| ok: false, | |
| text: makeMcpTestErrorMessage(t, result.error_kind, result.error), | |
| }; | |
| }, [oauthTestResult, testResult, t]); | |
| // A save always follows a fresh successful probe of the exact config being | |
| // saved, so publish that result to the card's health entry. On cloud | |
| // backends the probe is synthetic — never present it as a health verdict. | |
| const seedSavedServerHealth = ( | |
| serverToSave: MCPServerConfig, | |
| result: ExtendedMCPTestResponse, | |
| ) => { | |
| if (isCloudBackend) return; | |
| // When editing, the server's own entry must be overwritten, so only the | |
| // OTHER servers guard against a same-key collision. | |
| const otherServers = isEditing | |
| ? existingServers.filter((existing) => existing.id !== server.id) | |
| : existingServers; | |
| seedMcpServerHealth(serverToSave, result, otherServers); | |
| }; | |
| // Shared error handler so both add and update surface backend errors | |
| // as a toast instead of failing silently — previously these calls | |
| // had no `onError` and the modal closed even on a 4xx/5xx, leaving | |
| // the user to discover the failure on the next page load. | |
| const handleError = (err: unknown) => { | |
| if (err instanceof Error && err.message === MCP_RENAME_CREDENTIAL_ERROR) { | |
| displayErrorToast(err.message); | |
| return; | |
| } | |
| const message = retrieveAxiosErrorMessage(err as AxiosError); | |
| displayErrorToast(message || t(I18nKey.ERROR$GENERIC)); | |
| }; | |
| const handleSubmit = (payload: MCPServerConfig) => { | |
| resetTest(); | |
| setOauthTestResult(null); | |
| if (payload.auth?.strategy === "oauth2") { | |
| setIsOauthTesting(true); | |
| void McpService.authorizeOAuth(payload) | |
| .then((result) => { | |
| setOauthTestResult(result); | |
| if (!result.ok) return; | |
| const serverToSave = result.oauth_state | |
| ? { | |
| ...payload, | |
| auth: { ...payload.auth!, state: result.oauth_state }, | |
| } | |
| : payload; | |
| const onSaved = () => { | |
| seedSavedServerHealth(serverToSave, result); | |
| onClose(); | |
| }; | |
| if (isEditing) { | |
| updateMcpServer( | |
| { serverId: server.id, server: serverToSave }, | |
| { onSuccess: onSaved, onError: handleError }, | |
| ); | |
| } else { | |
| addMcpServer(serverToSave, { | |
| onSuccess: onSaved, | |
| onError: handleError, | |
| }); | |
| } | |
| }) | |
| .catch(handleError) | |
| .finally(() => setIsOauthTesting(false)); | |
| return; | |
| } | |
| testServer(payload, { | |
| onSuccess: (result) => { | |
| if (!result.ok) { | |
| // Test failed — modal stays open, error shown via testMessage. | |
| return; | |
| } | |
| const serverToSave = | |
| result.oauth_state && payload.auth?.strategy === "oauth2" | |
| ? { | |
| ...payload, | |
| auth: { ...payload.auth, state: result.oauth_state }, | |
| } | |
| : payload; | |
| const onSaved = () => { | |
| seedSavedServerHealth(serverToSave, result); | |
| onClose(); | |
| }; | |
| if (isEditing) { | |
| updateMcpServer( | |
| { serverId: server.id, server: serverToSave }, | |
| { onSuccess: onSaved, onError: handleError }, | |
| ); | |
| } else { | |
| addMcpServer(serverToSave, { | |
| onSuccess: onSaved, | |
| onError: handleError, | |
| }); | |
| } | |
| }, | |
| onError: handleError, | |
| }); | |
| }; | |
| const handleTestClick = (payload: MCPServerConfig) => { | |
| setOauthTestResult(null); | |
| if (payload.auth?.strategy === "oauth2" && !isCloudBackend) { | |
| setIsOauthTesting(true); | |
| void McpService.authorizeOAuth(payload) | |
| .then(setOauthTestResult) | |
| .catch(handleError) | |
| .finally(() => setIsOauthTesting(false)); | |
| return; | |
| } | |
| testServer(payload); | |
| }; | |
| const handleConfirmDelete = () => { | |
| deleteMcpServer(server, { | |
| onSuccess: () => { | |
| displaySuccessToast(t(I18nKey.MCP$REMOVE_SUCCESS)); | |
| setShowDeleteConfirm(false); | |
| onClose(); | |
| }, | |
| onError: (err) => { | |
| handleError(err); | |
| setShowDeleteConfirm(false); | |
| }, | |
| }); | |
| }; | |
| return ( | |
| <> | |
| <ModalBackdrop | |
| // Block backdrop-click / Escape from dismissing the modal while | |
| // a mutation is in flight — closing mid-request would orphan | |
| // the request and leave the user with no error feedback. | |
| onClose={isDismissBlocked ? undefined : onClose} | |
| closeOnEscape={!isDismissBlocked} | |
| aria-label={ | |
| isEditing | |
| ? t(I18nKey.MCP$EDIT_CUSTOM_TITLE) | |
| : t(I18nKey.MCP$ADD_CUSTOM_TITLE) | |
| } | |
| > | |
| <div | |
| data-testid="mcp-custom-editor" | |
| className="relative bg-base-secondary p-6 rounded-xl border border-[var(--oh-border)] w-[520px] max-w-[90vw] max-h-[90vh] overflow-y-auto custom-scrollbar" | |
| > | |
| <ModalCloseButton | |
| onClose={onClose} | |
| testId="mcp-custom-editor-close" | |
| disabled={isDismissBlocked} | |
| /> | |
| <h2 className={cn("mb-4 pr-6", modalTitleLgClassName)}> | |
| {isEditing | |
| ? t(I18nKey.MCP$EDIT_CUSTOM_TITLE) | |
| : t(I18nKey.MCP$ADD_CUSTOM_TITLE)} | |
| </h2> | |
| <MCPServerForm | |
| mode={isEditing ? "edit" : "add"} | |
| server={isEditing ? server : undefined} | |
| existingServers={existingServers} | |
| onSubmit={handleSubmit} | |
| onCancel={onClose} | |
| onDelete={isEditing ? () => setShowDeleteConfirm(true) : undefined} | |
| isActionDisabled={isPending} | |
| onTest={isCloudBackend ? undefined : handleTestClick} | |
| isTestPending={isTesting || isOauthTesting} | |
| testMessage={isCloudBackend ? null : testMessage} | |
| /> | |
| </div> | |
| </ModalBackdrop> | |
| {showDeleteConfirm ? ( | |
| <ConfirmationModal | |
| text={t(I18nKey.SETTINGS$MCP_CONFIRM_DELETE)} | |
| onCancel={() => setShowDeleteConfirm(false)} | |
| onConfirm={handleConfirmDelete} | |
| isConfirming={isDeleting} | |
| /> | |
| ) : null} | |
| </> | |
| ); | |
| } | |