openhands / src /components /features /mcp-page /custom-server-editor.tsx
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
9b906ea verified
Raw History Blame Contribute Delete
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}
</>
);
}