)}
+ {credentialSecretName && (
+ toggleSecret("api_key", v)}
+ />
+ )}
) : null}
>
@@ -453,6 +508,7 @@ export function InstallServerModal({
variant="secondary"
onClick={onClose}
testId="mcp-install-cancel"
+ isDisabled={isPending}
>
{t(I18nKey.BUTTON$CANCEL)}
@@ -464,7 +520,7 @@ export function InstallServerModal({
>
{isTesting
? t(I18nKey.MCP$VERIFYING)
- : isAdding
+ : isAdding || isFinalizingInstall
? t(I18nKey.SETTINGS$SAVING)
: t(I18nKey.MCP$INSTALL_BUTTON)}
diff --git a/src/hooks/mutation/use-save-fields-as-secrets.ts b/src/hooks/mutation/use-save-fields-as-secrets.ts
index 6afb9f2275..976ef8bb65 100644
--- a/src/hooks/mutation/use-save-fields-as-secrets.ts
+++ b/src/hooks/mutation/use-save-fields-as-secrets.ts
@@ -17,11 +17,13 @@ function formatKeyList(keys: string[]): string {
}
/**
- * 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.
+ * Returns a stable function that upserts checked envFields into the Secrets
+ * store. Callers may ignore the returned promise for background saves or await
+ * it when later work depends on the secret being present. 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");
@@ -32,13 +34,13 @@ export function useSaveFieldsAsSecrets() {
envFields: MarketplaceField[],
values: Record,
savedAsSecret: Record,
- ): void => {
+ ): Promise => {
const fieldsToSave = envFields.filter(
(field) => savedAsSecret[field.key] && (values[field.key] ?? "").trim(),
);
- if (fieldsToSave.length === 0) return;
+ if (fieldsToSave.length === 0) return Promise.resolve();
- Promise.allSettled(
+ return Promise.allSettled(
fieldsToSave.map((field) =>
SecretsService.createSecret(
field.key,
diff --git a/src/utils/mcp-marketplace-utils.ts b/src/utils/mcp-marketplace-utils.ts
index 6533d0348f..9b7f7d7251 100644
--- a/src/utils/mcp-marketplace-utils.ts
+++ b/src/utils/mcp-marketplace-utils.ts
@@ -4,7 +4,6 @@ import type {
IntegrationConnectionOption,
IntegrationTransport,
} from "@openhands/extensions/integrations";
-import { getDeploymentMode } from "#/api/agent-server-adapter";
export type { MarketplaceEntry };
@@ -104,52 +103,11 @@ function patchLinearEntry(entry: MarketplaceEntry): MarketplaceEntry {
};
}
-/**
- * The upstream catalog ships the GitHub MCP server with `command: "docker"`
- * (`docker run … ghcr.io/github/github-mcp-server`). This requires Docker-
- * in-Docker when agent-canvas itself runs inside the Docker image, which
- * isn't available. The Docker image pre-installs the native Go binary at
- * `/usr/local/bin/github-mcp-server`, so we rewrite the transport to use
- * it directly.
- *
- * Only applied when `getDeploymentMode()` returns `"docker"`.
- */
-function patchGitHubEntry(entry: MarketplaceEntry): MarketplaceEntry {
- if (entry.id !== "github") return entry;
- if (getDeploymentMode() !== "docker") return entry;
- return {
- ...entry,
- installHint:
- "Requires a GitHub Personal Access Token (classic or fine-grained).",
- // The upstream @openhands/extensions catalog defines the GitHub entry
- // with `command: "docker"` (i.e. `docker run …`). We match on that
- // exact value to replace it with the pre-installed native binary.
- // If the upstream ever changes the command string, this patch becomes
- // a no-op and the original transport is preserved — the worst case is
- // the user falls back to the Docker-based transport (which still works
- // outside the Docker image).
- connectionOptions: entry.connectionOptions.map((option) =>
- option.transport?.kind === "stdio" &&
- option.transport.command === "docker"
- ? {
- ...option,
- transport: {
- ...option.transport,
- command: "github-mcp-server",
- args: ["stdio"],
- },
- }
- : option,
- ),
- };
-}
-
export function getMcpMarketplaceCatalog(
catalog: MarketplaceEntry[],
): MarketplaceEntry[] {
return catalog
.map(patchLinearEntry)
- .map(patchGitHubEntry)
.filter((entry) => !!getDefaultMcpConnectionOption(entry));
}
diff --git a/tests/e2e/mock-llm/mock-llm-mcp-github.spec.ts b/tests/e2e/mock-llm/mock-llm-mcp-github.spec.ts
index 48a57272f2..d67a69b17d 100644
--- a/tests/e2e/mock-llm/mock-llm-mcp-github.spec.ts
+++ b/tests/e2e/mock-llm/mock-llm-mcp-github.spec.ts
@@ -4,17 +4,12 @@
* This test exercises the full MCP install flow — navigating to the MCP page,
* finding the GitHub marketplace card, opening the install modal, filling in
* the PAT field, and submitting. The `POST /api/mcp/test` endpoint is
- * intercepted to return a mock success response so the test doesn't need a
- * real `github-mcp-server` binary or Docker daemon.
- *
- * Runs in both npm (`mock-llm.config.ts`) and Docker (`mock-llm-docker.config.ts`)
- * paths. In Docker mode, asserts that `patchGitHubEntry` rewrote the command to
- * the pre-installed native binary (`github-mcp-server stdio`); in npm mode,
- * asserts the original `docker run …` transport is shown.
+ * intercepted to return a mock success response so the test doesn't need to
+ * contact GitHub's hosted MCP endpoint.
*
* Verifies:
* 1. The MCP page renders with the GitHub marketplace card visible
- * 2. Clicking the card opens the install modal with the correct command
+ * 2. Clicking the card opens the install modal with the hosted endpoint
* 3. Filling in the PAT and submitting succeeds (with mocked test endpoint)
* 4. After install the GitHub server appears in the installed list
* 5. The installed server can be deleted via the UI
@@ -32,19 +27,7 @@ import {
} from "./utils/mock-llm-helpers";
const FAKE_PAT = "github_pat_test_1234567890abcdef";
-
-/**
- * When running inside the Docker image (`--mode docker` in runtime services
- * info), `patchGitHubEntry` rewrites the catalog command from `docker run …`
- * to the pre-installed native binary. The Docker Playwright config sets
- * `MOCK_LLM_DOCKER_IMAGE`, so we can use its presence to know which command
- * value to expect.
- */
-const IS_DOCKER_E2E = !!process.env.MOCK_LLM_DOCKER_IMAGE;
-/** Pattern to match inside the read-only command field value. */
-const EXPECTED_COMMAND_PATTERN = IS_DOCKER_E2E
- ? /github-mcp-server\s+stdio/
- : /docker/;
+const GITHUB_HOSTED_MCP_URL = "https://api.githubcopilot.com/mcp/";
test.describe.configure({ mode: "serial" });
@@ -104,20 +87,15 @@ test.describe("MCP GitHub server install flow", () => {
// Verify the modal is for the GitHub entry
await expect(modal).toHaveAttribute("data-marketplace-id", "github");
- // The modal should show the command field (read-only).
- // In Docker mode the field shows the patched native binary command;
- // in the npm path it shows the original `docker run …` transport.
- const commandField = page.getByTestId(
- "mcp-install-field-command-readonly",
- );
- await expect(commandField).toBeVisible();
- await expect(commandField).toHaveValue(EXPECTED_COMMAND_PATTERN);
+ // The modal should show the hosted streamable HTTP endpoint.
+ const urlField = page.getByTestId("mcp-install-field-url");
+ await expect(urlField).toBeVisible();
+ await expect(urlField).toHaveValue(GITHUB_HOSTED_MCP_URL);
- // The PAT field should be present and empty
- const patField = page.getByTestId(
- "mcp-install-field-GITHUB_PERSONAL_ACCESS_TOKEN",
- );
+ // The PAT field should be present and empty.
+ const patField = page.getByTestId("mcp-install-field-api_key");
await expect(patField).toBeVisible();
+ await expect(patField).toHaveValue("");
});
test("step 3: full install flow — fill PAT, submit, verify installed", async ({
@@ -128,8 +106,8 @@ test.describe("MCP GitHub server install flow", () => {
await routeSessionApiKey(page);
- // Intercept the MCP test endpoint to return success — we don't have
- // the real github-mcp-server binary in the test environment.
+ // Intercept the MCP test endpoint to return success; the test environment
+ // should not contact GitHub's hosted MCP endpoint.
await page.route("**/api/mcp/test", async (route) => {
await route.fulfill({
status: 200,
@@ -148,9 +126,7 @@ test.describe("MCP GitHub server install flow", () => {
await expect(modal).toBeVisible({ timeout: 5_000 });
// Fill in the PAT — SettingsInput puts data-testid on the directly
- const patInput = page.getByTestId(
- "mcp-install-field-GITHUB_PERSONAL_ACCESS_TOKEN",
- );
+ const patInput = page.getByTestId("mcp-install-field-api_key");
await patInput.fill(FAKE_PAT);
// Click install
@@ -168,30 +144,22 @@ test.describe("MCP GitHub server install flow", () => {
await expect(serverItems.first()).toBeVisible();
// Verify via the settings API that the server was actually persisted
- const settingsResp = await page.request.get(
- `${BACKEND_URL}/api/settings`,
- {
- headers: { "X-Session-API-Key": SESSION_API_KEY },
- },
- );
+ const settingsResp = await page.request.get(`${BACKEND_URL}/api/settings`, {
+ headers: { "X-Session-API-Key": SESSION_API_KEY },
+ });
expect(settingsResp.ok()).toBe(true);
const settings = await settingsResp.json();
const mcpConfig = settings?.agent_settings?.mcp_config;
expect(mcpConfig).toBeTruthy();
- // The GitHub server should be stored as a stdio server named "github"
- // with the PAT in its env
- const mcpServers = mcpConfig?.mcpServers ?? mcpConfig?.stdio_servers;
+ // The GitHub server should be stored as a hosted streamable HTTP server
+ // with the PAT saved as its auth credential.
+ const mcpServers = mcpConfig?.mcpServers ?? mcpConfig?.shttp_servers;
expect(mcpServers).toBeTruthy();
-
- // Check that there's a server named "github" somewhere in the config
- const hasGithub =
- mcpServers?.github != null ||
- (Array.isArray(mcpServers) &&
- mcpServers.some(
- (s: Record) => s.name === "github",
- ));
- expect(hasGithub).toBe(true);
+ expect(mcpServers?.shttp).toMatchObject({
+ url: GITHUB_HOSTED_MCP_URL,
+ auth: FAKE_PAT,
+ });
});
test("step 4: installed GitHub server can be deleted", async ({ page }) => {
@@ -207,12 +175,9 @@ test.describe("MCP GitHub server install flow", () => {
agent_settings_diff: {
mcp_config: {
mcpServers: {
- github: {
- command: "github-mcp-server",
- args: ["stdio"],
- env: {
- GITHUB_PERSONAL_ACCESS_TOKEN: FAKE_PAT,
- },
+ shttp: {
+ url: GITHUB_HOSTED_MCP_URL,
+ auth: FAKE_PAT,
},
},
},
@@ -247,17 +212,14 @@ test.describe("MCP GitHub server install flow", () => {
await confirmButton.click();
// After deletion the installed list should show the empty state
- await expect(
- page.getByTestId("mcp-installed-empty"),
- ).toBeVisible({ timeout: 10_000 });
+ await expect(page.getByTestId("mcp-installed-empty")).toBeVisible({
+ timeout: 10_000,
+ });
// Verify via the settings API that the server was removed
- const settingsResp = await page.request.get(
- `${BACKEND_URL}/api/settings`,
- {
- headers: { "X-Session-API-Key": SESSION_API_KEY },
- },
- );
+ const settingsResp = await page.request.get(`${BACKEND_URL}/api/settings`, {
+ headers: { "X-Session-API-Key": SESSION_API_KEY },
+ });
expect(settingsResp.ok()).toBe(true);
const settings = await settingsResp.json();
const mcpConfig = settings?.agent_settings?.mcp_config;