element", () => {
+ render(
+ ,
+ );
+ const codeEl = screen.getByText("MY_KEY");
+ expect(codeEl.tagName.toLowerCase()).toBe("code");
+ });
+
+ it("renders the checkbox unchecked when checked=false", () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole("checkbox")).not.toBeChecked();
+ });
+
+ it("renders the checkbox checked when checked=true", () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole("checkbox")).toBeChecked();
+ });
+
+ // ── accessibility ──────────────────────────────────────────────────────────
+
+ it("the info button has an aria-label describing its purpose", () => {
+ render(
+ ,
+ );
+ // t(key) => key in tests, so aria-label equals the raw i18n key.
+ const infoBtn = screen.getByRole("button");
+ expect(infoBtn).toHaveAttribute(
+ "aria-label",
+ "MCP$SAVE_AS_SECRET_TOOLTIP",
+ );
+ });
+
+ it("the visual track is hidden from the accessibility tree (aria-hidden)", () => {
+ const { container } = render(
+ ,
+ );
+ // The decorative that forms the visual slider track.
+ const track = container.querySelector("span[aria-hidden='true']");
+ expect(track).toBeInTheDocument();
+ });
+
+ // ── interaction ────────────────────────────────────────────────────────────
+
+ it("calls onToggle(true) when the unchecked checkbox is clicked", () => {
+ const onToggle = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole("checkbox"));
+ expect(onToggle).toHaveBeenCalledWith(true);
+ });
+
+ it("calls onToggle(false) when the checked checkbox is clicked", () => {
+ const onToggle = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole("checkbox"));
+ expect(onToggle).toHaveBeenCalledWith(false);
+ });
+
+ // ── tooltip ────────────────────────────────────────────────────────────────
+
+ it("passes tooltip text to StyledTooltip as its content prop", () => {
+ render(
+ ,
+ );
+ // The mock renders StyledTooltip's content prop into a .
+ // t(key) => key, so the rendered text is the raw i18n key.
+ expect(screen.getByTestId("styled-tooltip-content")).toHaveTextContent(
+ "MCP$SAVE_AS_SECRET_TOOLTIP",
+ );
+ });
+});
diff --git a/__tests__/hooks/mutation/use-save-fields-as-secrets.test.ts b/__tests__/hooks/mutation/use-save-fields-as-secrets.test.ts
new file mode 100644
index 000000000..59671fb0c
--- /dev/null
+++ b/__tests__/hooks/mutation/use-save-fields-as-secrets.test.ts
@@ -0,0 +1,178 @@
+import React from "react";
+import { renderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { SecretsService } from "#/api/secrets-service";
+import { useSaveFieldsAsSecrets } from "#/hooks/mutation/use-save-fields-as-secrets";
+import type { MarketplaceField } from "@openhands/extensions/integrations";
+import {
+ displayErrorToast,
+ displaySuccessToast,
+} from "#/utils/custom-toast-handlers";
+
+// Replace both toast functions with vi.fn() instances so we can assert on them.
+vi.mock("#/utils/custom-toast-handlers", () => ({
+ displaySuccessToast: vi.fn(),
+ displayErrorToast: vi.fn(),
+}));
+
+// Minimal field factory — the hook only needs `key` and `label`.
+const f = (key: string, label = key): MarketplaceField =>
+ ({ key, label }) as unknown as MarketplaceField;
+
+// The hook reads a QueryClient via useQueryClient(), so renderHook needs a
+// provider. We hand back the client too so tests can spy on invalidateQueries.
+let queryClient: QueryClient;
+const createWrapper = () => {
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return ({ children }: { children: React.ReactNode }) =>
+ React.createElement(QueryClientProvider, { client: queryClient }, children);
+};
+
+const renderSaveHook = () =>
+ renderHook(() => useSaveFieldsAsSecrets(), { wrapper: createWrapper() });
+
+describe("useSaveFieldsAsSecrets", () => {
+ beforeEach(() => {
+ vi.spyOn(SecretsService, "createSecret").mockResolvedValue();
+ vi.mocked(displaySuccessToast).mockClear();
+ vi.mocked(displayErrorToast).mockClear();
+ });
+
+ afterEach(() => vi.clearAllMocks());
+
+ // ── early-return guard ──────────────────────────────────────────────────────
+
+ it("does nothing when no fields are checked", () => {
+ const { result } = renderSaveHook();
+ result.current([f("KEY")], { KEY: "val" }, { KEY: false });
+ expect(SecretsService.createSecret).not.toHaveBeenCalled();
+ });
+
+ it("skips a checked field whose value is an empty string", () => {
+ const { result } = renderSaveHook();
+ result.current([f("KEY")], { KEY: "" }, { KEY: true });
+ expect(SecretsService.createSecret).not.toHaveBeenCalled();
+ });
+
+ it("skips a checked field whose value is whitespace-only", () => {
+ const { result } = renderSaveHook();
+ result.current([f("KEY")], { KEY: " " }, { KEY: true });
+ expect(SecretsService.createSecret).not.toHaveBeenCalled();
+ });
+
+ // ── createSecret call arguments ────────────────────────────────────────────
+
+ it("calls createSecret with the trimmed value and field label as description", () => {
+ const { result } = renderSaveHook();
+ result.current(
+ [f("API_KEY", "API Key")],
+ { API_KEY: " sk-secret " },
+ { API_KEY: true },
+ );
+ expect(SecretsService.createSecret).toHaveBeenCalledWith(
+ "API_KEY",
+ "sk-secret",
+ "API Key",
+ );
+ });
+
+ it("only calls createSecret for checked fields, skipping unchecked ones", () => {
+ const { result } = renderSaveHook();
+ result.current(
+ [f("CHECKED"), f("SKIPPED")],
+ { CHECKED: "val", SKIPPED: "val" },
+ { CHECKED: true, SKIPPED: false },
+ );
+ expect(SecretsService.createSecret).toHaveBeenCalledTimes(1);
+ expect(SecretsService.createSecret).toHaveBeenCalledWith(
+ "CHECKED",
+ "val",
+ "CHECKED",
+ );
+ });
+
+ // ── Promise.allSettled behaviour ────────────────────────────────────────────
+
+ it("calls createSecret for every checked field even if one of them rejects", () => {
+ vi.spyOn(SecretsService, "createSecret")
+ .mockRejectedValueOnce(new Error("conflict"))
+ .mockResolvedValue();
+ const { result } = renderSaveHook();
+ result.current(
+ [f("A"), f("B")],
+ { A: "val-a", B: "val-b" },
+ { A: true, B: true },
+ );
+ // Both are called synchronously inside the allSettled map.
+ expect(SecretsService.createSecret).toHaveBeenCalledTimes(2);
+ });
+
+ // ── toast behaviour ─────────────────────────────────────────────────────────
+
+ it("shows only a success toast when all fields save successfully", async () => {
+ const { result } = renderSaveHook();
+ result.current([f("KEY")], { KEY: "val" }, { KEY: true });
+ await waitFor(() =>
+ expect(vi.mocked(displaySuccessToast)).toHaveBeenCalledTimes(1),
+ );
+ expect(vi.mocked(displayErrorToast)).not.toHaveBeenCalled();
+ });
+
+ it("shows only an error toast when all fields fail to save", async () => {
+ vi.spyOn(SecretsService, "createSecret").mockRejectedValue(
+ new Error("fail"),
+ );
+ const { result } = renderSaveHook();
+ result.current([f("KEY")], { KEY: "val" }, { KEY: true });
+ await waitFor(() =>
+ expect(vi.mocked(displayErrorToast)).toHaveBeenCalledTimes(1),
+ );
+ expect(vi.mocked(displaySuccessToast)).not.toHaveBeenCalled();
+ });
+
+ it("shows both success and error toasts on partial failure", async () => {
+ vi.spyOn(SecretsService, "createSecret")
+ .mockResolvedValueOnce()
+ .mockRejectedValueOnce(new Error("fail"));
+ const { result } = renderSaveHook();
+ result.current(
+ [f("OK_KEY"), f("FAIL_KEY")],
+ { OK_KEY: "val", FAIL_KEY: "val" },
+ { OK_KEY: true, FAIL_KEY: true },
+ );
+ await waitFor(() => {
+ expect(vi.mocked(displaySuccessToast)).toHaveBeenCalledTimes(1);
+ expect(vi.mocked(displayErrorToast)).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ // ── cache invalidation ──────────────────────────────────────────────────────
+
+ it("invalidates the secrets query caches after a successful save", async () => {
+ const { result } = renderSaveHook();
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
+ result.current([f("KEY")], { KEY: "val" }, { KEY: true });
+ await waitFor(() => {
+ expect(invalidateSpy).toHaveBeenCalledWith({
+ queryKey: ["secrets-search"],
+ });
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["secrets"] });
+ });
+ });
+
+ it("does not invalidate the secrets query caches when every save fails", async () => {
+ vi.spyOn(SecretsService, "createSecret").mockRejectedValue(
+ new Error("fail"),
+ );
+ const { result } = renderSaveHook();
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
+ result.current([f("KEY")], { KEY: "val" }, { KEY: true });
+ await waitFor(() =>
+ expect(vi.mocked(displayErrorToast)).toHaveBeenCalledTimes(1),
+ );
+ expect(invalidateSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/features/mcp-page/index.ts b/src/components/features/mcp-page/index.ts
index c64f0a148..d696d7657 100644
--- a/src/components/features/mcp-page/index.ts
+++ b/src/components/features/mcp-page/index.ts
@@ -3,6 +3,7 @@ export { InstalledServerCard } from "./installed-server-card";
export { MarketplaceSection } from "./marketplace-section";
export { MarketplaceCard } from "./marketplace-card";
export { InstallServerModal } from "./install-server-modal";
+export { SaveAsSecretToggle } from "./save-as-secret-toggle";
export { CustomServerEditor } from "./custom-server-editor";
export { McpToolbar } from "./mcp-toolbar";
export type { McpSectionFilter } from "./mcp-section-filter";
diff --git a/src/components/features/mcp-page/install-server-modal.tsx b/src/components/features/mcp-page/install-server-modal.tsx
index 6122f4057..af8c62d3f 100644
--- a/src/components/features/mcp-page/install-server-modal.tsx
+++ b/src/components/features/mcp-page/install-server-modal.tsx
@@ -7,6 +7,7 @@ import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalCloseButton } from "#/components/shared/modals/modal-close-button";
import { BrandButton } from "#/components/features/settings/brand-button";
import { SettingsInput } from "#/components/features/settings/settings-input";
+import { SaveAsSecretToggle } from "#/components/features/mcp-page/save-as-secret-toggle";
import { I18nKey } from "#/i18n/declaration";
import type { IntegrationCatalogEntry as MarketplaceEntry } from "@openhands/extensions/integrations";
import { McpLogoBadge } from "#/components/features/mcp-logo-badge";
@@ -19,6 +20,7 @@ import {
type McpMarketplaceConnectionOption,
} from "#/utils/mcp-marketplace-utils";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
+import { useSaveFieldsAsSecrets } from "#/hooks/mutation/use-save-fields-as-secrets";
import { modalTitleLgClassName } from "#/utils/modal-classes";
interface InstallServerModalProps {
@@ -30,6 +32,7 @@ interface InstallServerModalProps {
interface FieldState {
values: Record;
errors: Record;
+ savedAsSecret: Record;
}
function optionNeedsCredentialField(
@@ -50,11 +53,14 @@ function isCredentialOptional(option: McpMarketplaceConnectionOption): boolean {
function makeInitialState(entry: MarketplaceEntry): FieldState {
const values: Record = {};
+ const savedAsSecret: Record = {};
const option = getInstallableMcpConnectionOption(entry);
const template = option?.transport;
if (template?.kind === "stdio") {
for (const field of template.envFields ?? []) {
values[field.key] = "";
+ // Pre-check password fields; non-password fields default to off.
+ savedAsSecret[field.key] = field.type === "password";
}
for (const field of template.argFields ?? []) {
values[field.key] = "";
@@ -62,7 +68,7 @@ function makeInitialState(entry: MarketplaceEntry): FieldState {
} else if (optionNeedsCredentialField(option)) {
values.api_key = "";
}
- return { values, errors: {} };
+ return { values, errors: {}, savedAsSecret };
}
// The marketplace install modal is intentionally add-only: clicking
@@ -79,10 +85,16 @@ export function InstallServerModal({
const { t } = useTranslation("openhands");
const { mutate: addMcpServer, isPending: isAdding } = useAddMcpServer();
const { mutate: testMcpServer, isPending: isTesting } = useTestMcpServer();
+ const saveFieldsAsSecrets = useSaveFieldsAsSecrets();
const [state, setState] = React.useState(() =>
makeInitialState(entry),
);
+ // Always holds the latest state so async callbacks (onSuccess) never read
+ // stale closure values, even under React concurrent-mode scheduling.
+ const stateRef = React.useRef(state);
+ stateRef.current = state;
+
const [globalError, setGlobalError] = React.useState(null);
const option = getInstallableMcpConnectionOption(entry);
const template = option?.transport;
@@ -91,12 +103,20 @@ export function InstallServerModal({
const setValue = (key: string, value: string) => {
setState((prev) => ({
+ ...prev,
values: { ...prev.values, [key]: value },
errors: { ...prev.errors, [key]: null },
}));
setGlobalError(null);
};
+ const toggleSecret = (key: string, value: boolean) => {
+ setState((prev) => ({
+ ...prev,
+ savedAsSecret: { ...prev.savedAsSecret, [key]: value },
+ }));
+ };
+
const makeTestErrorMessage = (failure: MCPTestFailure): string => {
switch (failure.error_kind) {
case "timeout":
@@ -121,6 +141,18 @@ export function InstallServerModal({
displaySuccessToast(t(I18nKey.MCP$INSTALL_SUCCESS));
onSuccess?.(entry);
onClose();
+
+ // Save checked envFields as secrets in the background so the
+ // Automation Server can access them without a separate manual step.
+ // Runs after onClose so failures don't block the modal from closing.
+ // Uses stateRef.current to avoid reading a stale closure snapshot.
+ if (template?.kind === "stdio") {
+ saveFieldsAsSecrets(
+ template.envFields ?? [],
+ stateRef.current.values,
+ stateRef.current.savedAsSecret,
+ );
+ }
},
onError: (err: unknown) => {
const message = retrieveAxiosErrorMessage(err as AxiosError);
@@ -294,8 +326,17 @@ export function InstallServerModal({
{state.errors[field.key] && (
{state.errors[field.key]}
)}
+ {field.key in state.savedAsSecret && (
+ toggleSecret(field.key, v)}
+ />
+ )}
))}
+ {/* argFields are CLI arguments, not credentials — they don't need
+ a "save as secret" toggle and are excluded from savedAsSecret. */}
{(stdio.argFields ?? []).map((field) => (
void;
+}
+
+export function SaveAsSecretToggle({
+ fieldKey,
+ checked,
+ onToggle,
+}: SaveAsSecretToggleProps) {
+ const { t } = useTranslation("openhands");
+
+ return (
+
+ );
+}
diff --git a/src/hooks/mutation/use-save-fields-as-secrets.ts b/src/hooks/mutation/use-save-fields-as-secrets.ts
new file mode 100644
index 000000000..6afb9f227
--- /dev/null
+++ b/src/hooks/mutation/use-save-fields-as-secrets.ts
@@ -0,0 +1,79 @@
+import { useCallback } from "react";
+import { useTranslation } from "react-i18next";
+import { useQueryClient } from "@tanstack/react-query";
+import type { MarketplaceField } from "@openhands/extensions/integrations";
+import { SecretsService } from "#/api/secrets-service";
+import { I18nKey } from "#/i18n/declaration";
+import {
+ displayErrorToast,
+ displaySuccessToast,
+} from "#/utils/custom-toast-handlers";
+
+/** Truncates a list of key names for display in toasts. */
+function formatKeyList(keys: string[]): string {
+ const MAX = 3;
+ if (keys.length <= MAX) return keys.join(", ");
+ return `${keys.slice(0, MAX).join(", ")} … (+${keys.length - MAX})`;
+}
+
+/**
+ * Returns a stable, fire-and-forget function that upserts checked envFields
+ * into the Secrets store. MCP server config and the Secrets store are
+ * separate — this bridges the gap so Automation Server can access credentials
+ * without a separate manual step. Internally, `SecretsService.createSecret`
+ * is an upsert, so existing secrets with the same name are overwritten safely.
+ */
+export function useSaveFieldsAsSecrets() {
+ const { t } = useTranslation("openhands");
+ const queryClient = useQueryClient();
+
+ return useCallback(
+ (
+ envFields: MarketplaceField[],
+ values: Record,
+ savedAsSecret: Record,
+ ): void => {
+ const fieldsToSave = envFields.filter(
+ (field) => savedAsSecret[field.key] && (values[field.key] ?? "").trim(),
+ );
+ if (fieldsToSave.length === 0) return;
+
+ Promise.allSettled(
+ fieldsToSave.map((field) =>
+ SecretsService.createSecret(
+ field.key,
+ values[field.key].trim(),
+ field.label,
+ ),
+ ),
+ ).then((results) => {
+ const saved = fieldsToSave
+ .filter((_, i) => results[i].status === "fulfilled")
+ .map((f) => f.key);
+ const failed = fieldsToSave
+ .filter((_, i) => results[i].status === "rejected")
+ .map((f) => f.key);
+
+ if (saved.length > 0) {
+ // Refresh any cached secrets lists so a newly-saved secret shows up
+ // in Settings → Secrets immediately. Without this, the 5-minute
+ // staleTime on the secrets query (use-get-secrets.ts) can keep a
+ // previously-loaded list stale and hide the new secret. Mirrors the
+ // invalidation done by secret-form.tsx and secrets-settings.tsx.
+ queryClient.invalidateQueries({ queryKey: ["secrets-search"] });
+ queryClient.invalidateQueries({ queryKey: ["secrets"] });
+
+ displaySuccessToast(
+ t(I18nKey.MCP$SECRETS_SAVED, { keys: formatKeyList(saved) }),
+ );
+ }
+ if (failed.length > 0) {
+ displayErrorToast(
+ t(I18nKey.MCP$SECRETS_SAVE_FAILED, { keys: formatKeyList(failed) }),
+ );
+ }
+ });
+ },
+ [t, queryClient],
+ );
+}
diff --git a/src/i18n/translation.json b/src/i18n/translation.json
index 633ace719..2882a009e 100644
--- a/src/i18n/translation.json
+++ b/src/i18n/translation.json
@@ -13837,6 +13837,74 @@
"uk": "Сервер MCP збережено.",
"ca": "Servidor MCP desat."
},
+ "MCP$SAVE_AS_SECRET_TOOLTIP": {
+ "en": "MCP credentials aren't shared with automations. Save as a secret to make this value available to automations.",
+ "ja": "MCPの認証情報はオートメーションと共有されません。この値をオートメーションで使用できるようにするには、シークレットとして保存してください。",
+ "zh-CN": "MCP 凭据不与自动化共享。将其保存为密钥以使该值对自动化可用。",
+ "zh-TW": "MCP 憑證不與自動化共享。將其儲存為密鑰以使該值對自動化可用。",
+ "ko-KR": "MCP 자격 증명은 자동화와 공유되지 않습니다. 이 값을 자동화에서 사용할 수 있도록 시크릿으로 저장하세요.",
+ "no": "MCP-legitimasjon deles ikke med automatiseringer. Lagre som en hemmelighet for å gjøre denne verdien tilgjengelig for automatiseringer.",
+ "it": "Le credenziali MCP non sono condivise con le automazioni. Salva come segreto per rendere questo valore disponibile alle automazioni.",
+ "pt": "As credenciais MCP não são compartilhadas com automações. Salve como segredo para disponibilizar este valor às automações.",
+ "es": "Las credenciales MCP no se comparten con las automatizaciones. Guarda como secreto para que este valor esté disponible para las automatizaciones.",
+ "ar": "بيانات اعتماد MCP لا تُشارك مع الأتمتة. احفظ كسر لإتاحة هذه القيمة للأتمتة.",
+ "fr": "Les identifiants MCP ne sont pas partagés avec les automatisations. Enregistrez comme secret pour rendre cette valeur disponible aux automatisations.",
+ "tr": "MCP kimlik bilgileri otomasyonlarla paylaşılmaz. Bu değeri otomasyonlar için kullanılabilir hale getirmek üzere gizli olarak kaydedin.",
+ "de": "MCP-Anmeldedaten werden nicht mit Automatisierungen geteilt. Als Secret speichern, um diesen Wert für Automatisierungen verfügbar zu machen.",
+ "uk": "Облікові дані MCP не передаються автоматизаціям. Збережіть як секрет, щоб зробити це значення доступним для автоматизацій.",
+ "ca": "Les credencials MCP no es comparteixen amb les automatitzacions. Desa com a secret per fer aquest valor disponible per a les automatitzacions."
+ },
+ "MCP$ALSO_SAVE_AS_SECRET": {
+ "en": "Also save as secret",
+ "ja": "シークレットとしても保存",
+ "zh-CN": "同时保存为密钥",
+ "zh-TW": "同時儲存為密鑰",
+ "ko-KR": "시크릿으로도 저장",
+ "no": "Lagre også som hemmelighet",
+ "it": "Salva anche come segreto",
+ "pt": "Salvar também como segredo",
+ "es": "Guardar también como secreto",
+ "ar": "حفظ أيضاً كسر",
+ "fr": "Enregistrer aussi comme secret",
+ "tr": "Gizli olarak da kaydet",
+ "de": "Auch als Secret speichern",
+ "uk": "Також зберегти як секрет",
+ "ca": "Desar també com a secret"
+ },
+ "MCP$SECRETS_SAVED": {
+ "en": "Saved to secrets: {{keys}}",
+ "ja": "シークレットを保存しました: {{keys}}",
+ "zh-CN": "密钥已保存:{{keys}}",
+ "zh-TW": "密鑰已儲存:{{keys}}",
+ "ko-KR": "시크릿이 저장되었습니다: {{keys}}",
+ "no": "Hemmelighet lagret: {{keys}}",
+ "it": "Segreto salvato: {{keys}}",
+ "pt": "Segredo salvo: {{keys}}",
+ "es": "Secreto guardado: {{keys}}",
+ "ar": "تم حفظ السر: {{keys}}",
+ "fr": "Secret enregistré : {{keys}}",
+ "tr": "Gizli kaydedildi: {{keys}}",
+ "de": "Secret gespeichert: {{keys}}",
+ "uk": "Секрет збережено: {{keys}}",
+ "ca": "Secret desat: {{keys}}"
+ },
+ "MCP$SECRETS_SAVE_FAILED": {
+ "en": "Failed to save secret: {{keys}}",
+ "ja": "シークレットの保存に失敗しました: {{keys}}",
+ "zh-CN": "密钥保存失败:{{keys}}",
+ "zh-TW": "密鑰儲存失敗:{{keys}}",
+ "ko-KR": "시크릿 저장에 실패했습니다: {{keys}}",
+ "no": "Kunne ikke lagre hemmelighet: {{keys}}",
+ "it": "Impossibile salvare il segreto: {{keys}}",
+ "pt": "Falha ao salvar segredo: {{keys}}",
+ "es": "Error al guardar el secreto: {{keys}}",
+ "ar": "فشل حفظ السر: {{keys}}",
+ "fr": "Échec de l'enregistrement du secret : {{keys}}",
+ "tr": "Gizli kaydedilemedi: {{keys}}",
+ "de": "Fehler beim Speichern des Secrets: {{keys}}",
+ "uk": "Не вдалося зберегти секрет: {{keys}}",
+ "ca": "Error en desar el secret: {{keys}}"
+ },
"MCP$REMOVE_SUCCESS": {
"en": "MCP server removed.",
"ja": "MCP サーバーを削除しました。",