mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 14:58:39 +08:00
Use unified extensions integrations catalog (#757)
* Use unified integrations catalog * chore: pin extensions to merged integrations commit * docs: clarify local MCP install selection * fix: revert @openhands/extensions to working commit for Docker build The extensions package at b8c1869e does not export ./integrations under the 'production' Vite condition. Revert to the previously-verified commit c4d8d972 which passes the Docker build. Co-authored-by: openhands <openhands@all-hands.dev> * fix: update test entries to connectionOptions format post-merge Three test entries in install-server-modal.test.tsx still used the pre-catalog template: field that no longer exists on IntegrationCatalogEntry. Convert them to the connectionOptions: [...] format that matches the current extensions package type. Co-authored-by: openhands <openhands@all-hands.dev> * test: add MCP install evidence screenshot Captured from the real Agent Canvas dev stack running this branch on port 12000 after installing the no-credential Everything (demo) MCP integration through the UI. Co-authored-by: openhands <openhands@all-hands.dev> * test: mock MCP preflight in snapshots Ensure MCP page snapshot flows do not hit a real agent-server /api/mcp/test endpoint when adding marketplace or custom MCP servers in mocked Playwright runs. Co-authored-by: openhands <openhands@all-hands.dev> * test: relax home snapshot wait Allow the home-screen assertion to wait as long as the surrounding snapshot setup under CI load. Co-authored-by: openhands <openhands@all-hands.dev> * evidence: full MCP marketplace screenshot showing all 43 catalog entries * evidence: catalog verification showing all 43 MCP entries from INTEGRATION_CATALOG --------- Co-authored-by: neubig <398875+neubig@users.noreply.github.com> Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
committed by
GitHub
co-authored by
openhands
neubig
parent
95b6d7a23e
commit
6cb2f05aef
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 385 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
@@ -293,7 +293,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
const { t } = useTranslation("openhands");
|
||||
return <button aria-label={t(I18nKey.CHAT$DISMISS_LABEL)}>{t(I18nKey.CHAT$DISMISS)}</button>;
|
||||
return (
|
||||
<button aria-label={t(I18nKey.CHAT$DISMISS_LABEL)}>
|
||||
{t(I18nKey.CHAT$DISMISS)}
|
||||
</button>
|
||||
);
|
||||
|
||||
// WRONG -- ships English to every locale; flagged by i18next/no-literal-string
|
||||
return <button aria-label="Dismiss">Dismiss</button>;
|
||||
@@ -490,7 +494,7 @@ When adding code that needs a new string, decide up front which rule it falls un
|
||||
|
||||
- Custom secrets are NOT auto-attached by the agent-server. `POST /api/conversations` only persists what the client sends in `request.secrets`; the persisted secrets store (`/api/settings/secrets`) is never read at conversation-start. `buildStartConversationRequestWithEncryptedSettings` enumerates `SecretsService.getSecrets()` and turns each entry into a `LookupSecret` whose `url` points back at `/api/settings/secrets/{name}` and whose `headers` carry `X-Session-API-Key` for auth. Pre-1.21.x agent-server SDKs would silently drop that header during validation when `secrets_encrypted=true` (the cipher in the validation context tried to `cipher.decrypt(plaintext_session_key)`, failed, and the validator removed the header — the conversation runtime then got 401s for every saved secret). The SDK fix preserves plaintext header values when decryption fails; if you still see saved secrets unavailable inside a conversation, verify the running agent-server bundles a `LookupSecret._validate_secrets` that falls back to plaintext on decrypt failure.
|
||||
|
||||
- MCP page layout: MCP is a **top-level** nav entry at `/mcp` (rendered by `src/routes/mcp.tsx`), shown right below "Skills" in `src/components/features/sidebar/sidebar.tsx`. The legacy `/settings/mcp` route still works as a redirect via `src/routes/mcp-settings-redirect.tsx`, and `src/routes/mcp-settings.tsx` re-exports the new page so the published `MCPSettings` library symbol (in `src/components/settings/index.ts`) keeps the same shape. Marketplace catalog data and MCP logo mappings live in `@openhands/extensions/mcps`; the Slack catalog entry there should point at `https://github.com/zencoderai/slack-mcp-server` and use `@zencoderai/slack-mcp-server`. Deprecated marketplace entries removed upstream (for example GitLab / Google Maps / Postgres / Puppeteer / SQLite) should disappear from the marketplace grid. The Installed section still needs to render and search arbitrary non-catalog custom servers via the raw server `name` / `command` fallback in `src/utils/mcp-marketplace-utils.ts` + `InstalledServerCard`. Tavily is a regular stdio MCP entry (`tavily-mcp` + `TAVILY_API_KEY`), not a special built-in sentinel anymore. Components are colocated under `src/components/features/mcp-page/` and reuse the existing `MCPServerForm` for the "Add custom server" / edit flow.
|
||||
- MCP page layout: MCP is a **top-level** nav entry at `/mcp` (rendered by `src/routes/mcp.tsx`), shown right below "Skills" in `src/components/features/sidebar/sidebar.tsx`. The legacy `/settings/mcp` route still works as a redirect via `src/routes/mcp-settings-redirect.tsx`, and `src/routes/mcp-settings.tsx` re-exports the new page so the published `MCPSettings` library symbol (in `src/components/settings/index.ts`) keeps the same shape. Marketplace catalog data and MCP logo mappings live in the MCP-capable entries from `@openhands/extensions/integrations`; the Slack API catalog option should point at `https://github.com/zencoderai/slack-mcp-server` and use `@zencoderai/slack-mcp-server`. Deprecated marketplace entries removed upstream (for example GitLab / Google Maps / Postgres / Puppeteer / SQLite) should disappear from the marketplace grid. The Installed section still needs to render and search arbitrary non-catalog custom servers via the raw server `name` / `command` fallback in `src/utils/mcp-marketplace-utils.ts` + `InstalledServerCard`. Tavily is a regular stdio MCP entry (`tavily-mcp` + `TAVILY_API_KEY`), not a special built-in sentinel anymore. Components are colocated under `src/components/features/mcp-page/` and reuse the existing `MCPServerForm` for the "Add custom server" / edit flow.
|
||||
|
||||
- Library packaging notes:
|
||||
- Public npm entrypoints now come from `src/index.ts` → `src/lib/index.ts`, with domain barrels under `src/components/{conversation,terminal,browser,files,settings,sidebar}/index.ts`.
|
||||
|
||||
@@ -292,10 +292,10 @@ describe("recommended automations", () => {
|
||||
);
|
||||
expect(plusBadge.tagName).toBe("SPAN");
|
||||
expect(plusBadge).toHaveAttribute("aria-hidden", "true");
|
||||
expect(plusBadge.className).toContain("hover:bg-[var(--oh-interactive-hover)]");
|
||||
expect(
|
||||
plusBadge.querySelector('[role="switch"]'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(plusBadge.className).toContain(
|
||||
"hover:bg-[var(--oh-interactive-hover)]",
|
||||
);
|
||||
expect(plusBadge.querySelector('[role="switch"]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selects a recommendation directly from its card", () => {
|
||||
@@ -327,9 +327,16 @@ describe("recommended automations", () => {
|
||||
|
||||
const modal = await screen.findByTestId("mcp-install-modal");
|
||||
expect(modal).toHaveAttribute("data-marketplace-id", "github");
|
||||
expect(
|
||||
screen.getByTestId("mcp-install-field-command-readonly"),
|
||||
).toHaveValue(
|
||||
"docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server",
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId("mcp-install-field-GITHUB_PERSONAL_ACCESS_TOKEN"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("mcp-install-field-url")).toBeNull();
|
||||
expect(screen.queryByTestId("mcp-install-field-api_key")).toBeNull();
|
||||
expect(mockCreateConversationMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -398,9 +405,7 @@ describe("recommended automations", () => {
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByTestId("mcp-install-field-GITHUB_PERSONAL_ACCESS_TOKEN"),
|
||||
{
|
||||
target: { value: "github-token" },
|
||||
},
|
||||
{ target: { value: "github-token" } },
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("mcp-install-submit"));
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
||||
import { InstallServerModal } from "#/components/features/mcp-page/install-server-modal";
|
||||
import {
|
||||
INTEGRATION_CATALOG as INTEGRATION_MARKETPLACE,
|
||||
INTEGRATION_CATALOG as MCP_MARKETPLACE,
|
||||
type IntegrationCatalogEntry as MarketplaceEntry,
|
||||
} from "@openhands/extensions/integrations";
|
||||
|
||||
@@ -38,18 +38,14 @@ describe("InstallServerModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("requires Tavily API key and posts a stdio mcp_config diff", async () => {
|
||||
// Tavily is a stdio-only integration with a single envField.
|
||||
// Slack now defaults to OAuth/shttp, so we test stdio installs with Tavily.
|
||||
const tavily = INTEGRATION_MARKETPLACE.find(
|
||||
(e: MarketplaceEntry) => e.id === "tavily",
|
||||
)!;
|
||||
it("uses Slack's API fallback when the default option is OAuth", async () => {
|
||||
const slack = MCP_MARKETPLACE.find((e) => e.id === "slack")!;
|
||||
const saveSpy = vi
|
||||
.spyOn(SettingsService, "saveSettings")
|
||||
.mockResolvedValue(true);
|
||||
|
||||
const onClose = vi.fn();
|
||||
renderWith(<InstallServerModal entry={tavily} onClose={onClose} />);
|
||||
renderWith(<InstallServerModal entry={slack} onClose={onClose} />);
|
||||
|
||||
await screen.findByTestId("mcp-install-modal");
|
||||
|
||||
@@ -59,8 +55,11 @@ describe("InstallServerModal", () => {
|
||||
expect(saveSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId("mcp-install-field-TAVILY_API_KEY"), {
|
||||
target: { value: "tvly-test-key" },
|
||||
fireEvent.change(screen.getByTestId("mcp-install-field-SLACK_BOT_TOKEN"), {
|
||||
target: { value: "xoxb-abc" },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId("mcp-install-field-SLACK_TEAM_ID"), {
|
||||
target: { value: "T01" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("mcp-install-submit"));
|
||||
|
||||
@@ -71,10 +70,51 @@ describe("InstallServerModal", () => {
|
||||
mcp_config: { mcpServers: Record<string, unknown> };
|
||||
};
|
||||
expect(sentMcpConfig.mcp_config.mcpServers).toMatchObject({
|
||||
slack: {
|
||||
command: "npx",
|
||||
args: ["-y", "@zencoderai/slack-mcp-server"],
|
||||
env: { SLACK_BOT_TOKEN: "xoxb-abc", SLACK_TEAM_ID: "T01" },
|
||||
},
|
||||
});
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("installs Tavily as a stdio MCP server with TAVILY_API_KEY env", async () => {
|
||||
// Tavily was previously a fake `kind: "tavily-builtin"` template
|
||||
// that called saveSettings({ search_api_key }) — but that field
|
||||
// was dropped on the floor in both local and cloud save paths, so
|
||||
// installing Tavily silently did nothing. It's now a regular
|
||||
// stdio MCP entry (`npx -y tavily-mcp` + TAVILY_API_KEY) that
|
||||
// goes through the same mcp_config write as every other entry.
|
||||
const tavily = MCP_MARKETPLACE.find((e) => e.id === "tavily")!;
|
||||
const saveSpy = vi
|
||||
.spyOn(SettingsService, "saveSettings")
|
||||
.mockResolvedValue(true);
|
||||
|
||||
const onClose = vi.fn();
|
||||
renderWith(<InstallServerModal entry={tavily} onClose={onClose} />);
|
||||
|
||||
await screen.findByTestId("mcp-install-modal");
|
||||
|
||||
// Submit with no key fails the required-field check.
|
||||
fireEvent.click(screen.getByTestId("mcp-install-submit"));
|
||||
await waitFor(() => expect(saveSpy).not.toHaveBeenCalled());
|
||||
|
||||
fireEvent.change(screen.getByTestId("mcp-install-field-TAVILY_API_KEY"), {
|
||||
target: { value: "tvly-secret" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("mcp-install-submit"));
|
||||
|
||||
await waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1));
|
||||
const sent = (saveSpy.mock.calls[0][0] as Record<string, unknown>)
|
||||
.agent_settings_diff as {
|
||||
mcp_config: { mcpServers: Record<string, unknown> };
|
||||
};
|
||||
expect(sent.mcp_config.mcpServers).toMatchObject({
|
||||
tavily: {
|
||||
command: "npx",
|
||||
args: ["-y", "tavily-mcp"],
|
||||
env: { TAVILY_API_KEY: "tvly-test-key" },
|
||||
env: { TAVILY_API_KEY: "tvly-secret" },
|
||||
},
|
||||
});
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
@@ -90,6 +130,7 @@ describe("InstallServerModal", () => {
|
||||
name: "Synthetic",
|
||||
description: "Synthetic catalog entry used in tests.",
|
||||
iconBg: "#000000",
|
||||
defaultConnectionOptionId: "api",
|
||||
connectionOptions: [
|
||||
{
|
||||
id: "api",
|
||||
@@ -132,6 +173,7 @@ describe("InstallServerModal", () => {
|
||||
name: "Synthetic Optional",
|
||||
description: "Synthetic entry that allows empty api_key.",
|
||||
iconBg: "#000000",
|
||||
defaultConnectionOptionId: "api",
|
||||
connectionOptions: [
|
||||
{
|
||||
id: "api",
|
||||
@@ -141,7 +183,7 @@ describe("InstallServerModal", () => {
|
||||
url: "https://example.com/mcp",
|
||||
apiKeyOptional: true,
|
||||
},
|
||||
auth: { strategy: "api_key" },
|
||||
auth: { strategy: "api_key", apiKeyOptional: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -166,10 +208,8 @@ describe("InstallServerModal", () => {
|
||||
|
||||
it("closes from the top-right close button", async () => {
|
||||
const onClose = vi.fn();
|
||||
const tavily = INTEGRATION_MARKETPLACE.find(
|
||||
(e: MarketplaceEntry) => e.id === "tavily",
|
||||
)!;
|
||||
renderWith(<InstallServerModal entry={tavily} onClose={onClose} />);
|
||||
const slack = MCP_MARKETPLACE.find((e) => e.id === "slack")!;
|
||||
renderWith(<InstallServerModal entry={slack} onClose={onClose} />);
|
||||
await screen.findByTestId("mcp-install-modal");
|
||||
|
||||
fireEvent.click(screen.getByTestId("mcp-install-modal-close"));
|
||||
@@ -178,10 +218,8 @@ describe("InstallServerModal", () => {
|
||||
|
||||
it("places Cancel before Install in the footer so the dominant action is the last focusable button", async () => {
|
||||
// Arrange: render with any marketplace entry so the footer is mounted.
|
||||
const tavily = INTEGRATION_MARKETPLACE.find(
|
||||
(e: MarketplaceEntry) => e.id === "tavily",
|
||||
)!;
|
||||
renderWith(<InstallServerModal entry={tavily} onClose={vi.fn()} />);
|
||||
const slack = MCP_MARKETPLACE.find((e) => e.id === "slack")!;
|
||||
renderWith(<InstallServerModal entry={slack} onClose={vi.fn()} />);
|
||||
await screen.findByTestId("mcp-install-modal");
|
||||
|
||||
// Act: locate both footer buttons.
|
||||
@@ -212,6 +250,7 @@ describe("InstallServerModal", () => {
|
||||
name: "Failing Server",
|
||||
description: "Always fails the connection test.",
|
||||
iconBg: "#000000",
|
||||
defaultConnectionOptionId: "api",
|
||||
connectionOptions: [
|
||||
{
|
||||
id: "api",
|
||||
@@ -221,7 +260,7 @@ describe("InstallServerModal", () => {
|
||||
url: "https://example.com/mcp",
|
||||
apiKeyOptional: true,
|
||||
},
|
||||
auth: { strategy: "api_key" },
|
||||
auth: { strategy: "api_key", apiKeyOptional: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -265,6 +304,7 @@ describe("InstallServerModal", () => {
|
||||
name: "Passing Server",
|
||||
description: "Always passes the connection test.",
|
||||
iconBg: "#000000",
|
||||
defaultConnectionOptionId: "api",
|
||||
connectionOptions: [
|
||||
{
|
||||
id: "api",
|
||||
@@ -274,7 +314,7 @@ describe("InstallServerModal", () => {
|
||||
url: "https://example.com/mcp",
|
||||
apiKeyOptional: true,
|
||||
},
|
||||
auth: { strategy: "api_key" },
|
||||
auth: { strategy: "api_key", apiKeyOptional: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -307,6 +347,7 @@ describe("InstallServerModal", () => {
|
||||
name: "Pending Server",
|
||||
description: "Connection test never resolves.",
|
||||
iconBg: "#000000",
|
||||
defaultConnectionOptionId: "api",
|
||||
connectionOptions: [
|
||||
{
|
||||
id: "api",
|
||||
@@ -316,7 +357,7 @@ describe("InstallServerModal", () => {
|
||||
url: "https://example.com/mcp",
|
||||
apiKeyOptional: true,
|
||||
},
|
||||
auth: { strategy: "api_key" },
|
||||
auth: { strategy: "api_key", apiKeyOptional: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
INTEGRATION_CATALOG,
|
||||
type IntegrationCatalogEntry,
|
||||
} from "@openhands/extensions/integrations";
|
||||
import { INTEGRATION_CATALOG } from "@openhands/extensions/integrations";
|
||||
import { McpLogoStackBadge } from "#/components/features/mcp-page/mcp-logo-stack-badge";
|
||||
|
||||
function entry(id: string) {
|
||||
const match = INTEGRATION_CATALOG.find(
|
||||
(item: IntegrationCatalogEntry) => item.id === id,
|
||||
);
|
||||
const match = INTEGRATION_CATALOG.find((item) => item.id === id);
|
||||
if (!match) {
|
||||
throw new Error(`Missing integration catalog entry: ${id}`);
|
||||
throw new Error(`Missing MCP catalog entry: ${id}`);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
@@ -1,76 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AUTOMATION_CATALOG } from "@openhands/extensions/automations";
|
||||
import { INTEGRATION_LOGOS } from "@openhands/extensions/integrations/logos";
|
||||
import { INTEGRATION_CATALOG } from "@openhands/extensions/integrations";
|
||||
import {
|
||||
INTEGRATION_LOGO_IDS,
|
||||
INTEGRATION_LOGOS,
|
||||
} from "@openhands/extensions/integrations/logos";
|
||||
import {
|
||||
INTEGRATION_CATALOG,
|
||||
type IntegrationCatalogEntry,
|
||||
} from "@openhands/extensions/integrations";
|
||||
import { getDefaultTemplate } from "#/utils/mcp-marketplace-utils";
|
||||
getDefaultMcpTransport,
|
||||
getMcpMarketplaceCatalog,
|
||||
} from "#/utils/mcp-marketplace-utils";
|
||||
|
||||
describe("OpenHands extensions catalogs", () => {
|
||||
it("hydrates the integration marketplace from @openhands/extensions", () => {
|
||||
it("hydrates the MCP marketplace from @openhands/extensions", () => {
|
||||
expect(INTEGRATION_CATALOG.length).toBeGreaterThan(0);
|
||||
|
||||
// Tavily has stdio as its default MCP transport
|
||||
const tavily = INTEGRATION_CATALOG.find(
|
||||
(entry: IntegrationCatalogEntry) => entry.id === "tavily",
|
||||
);
|
||||
const tavilyTemplate = tavily ? getDefaultTemplate(tavily) : undefined;
|
||||
expect(tavilyTemplate?.kind).toBe("stdio");
|
||||
expect(INTEGRATION_LOGOS.tavily).toBeTruthy();
|
||||
|
||||
// Not all integrations have logos (some may be HTTP-only), so we just
|
||||
// check that the logo collection is populated
|
||||
expect(INTEGRATION_LOGO_IDS.size).toBeGreaterThan(0);
|
||||
const github = INTEGRATION_CATALOG.find((entry) => entry.id === "github");
|
||||
expect(getDefaultMcpTransport(github!)?.kind).toBe("shttp");
|
||||
expect(INTEGRATION_LOGOS.github).toBeTruthy();
|
||||
});
|
||||
|
||||
it("includes Slack with OAuth as default and stdio fallback", () => {
|
||||
const slack = INTEGRATION_CATALOG.find(
|
||||
(entry: IntegrationCatalogEntry) => entry.id === "slack",
|
||||
it("patches Slack to the maintained docs and npm package", () => {
|
||||
const slack = INTEGRATION_CATALOG.find((entry) => entry.id === "slack");
|
||||
expect(slack?.docsUrl).toBe(
|
||||
"https://github.com/zencoderai/slack-mcp-server",
|
||||
);
|
||||
expect(slack).toBeDefined();
|
||||
// Slack's default is now OAuth/shttp
|
||||
const defaultTemplate = slack ? getDefaultTemplate(slack) : undefined;
|
||||
expect(defaultTemplate?.kind).toBe("shttp");
|
||||
|
||||
// But the stdio option with the maintained npm package is still available
|
||||
const stdioOption = slack?.connectionOptions.find((o) => o.id === "api");
|
||||
expect(stdioOption?.transport?.kind).toBe("stdio");
|
||||
if (stdioOption?.transport?.kind === "stdio") {
|
||||
expect(stdioOption.transport.args).toContain(
|
||||
"@zencoderai/slack-mcp-server",
|
||||
);
|
||||
const apiOption = slack?.connectionOptions.find(
|
||||
(option) => option.id === "api" && option.transport?.kind === "stdio",
|
||||
);
|
||||
expect(apiOption?.transport?.kind).toBe("stdio");
|
||||
if (apiOption?.transport?.kind !== "stdio") {
|
||||
throw new Error("Slack API option should be stdio");
|
||||
}
|
||||
expect(apiOption.transport.args).toContain("@zencoderai/slack-mcp-server");
|
||||
expect(apiOption.transport.args).not.toContain(
|
||||
"@modelcontextprotocol/server-slack",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes common integrations in the catalog", () => {
|
||||
it("drops deprecated MCP entries that no longer have maintained replacements", () => {
|
||||
const catalogIds = new Set(
|
||||
INTEGRATION_CATALOG.map((entry: IntegrationCatalogEntry) => entry.id),
|
||||
getMcpMarketplaceCatalog(INTEGRATION_CATALOG).map((entry) => entry.id),
|
||||
);
|
||||
|
||||
// These should all be present
|
||||
expect(catalogIds.has("github")).toBe(true);
|
||||
expect(catalogIds.has("slack")).toBe(true);
|
||||
expect(catalogIds.has("tavily")).toBe(true);
|
||||
expect(catalogIds.has("linear")).toBe(true);
|
||||
expect(catalogIds.has("notion")).toBe(true);
|
||||
expect(catalogIds.has("gitlab")).toBe(false);
|
||||
expect(catalogIds.has("google-maps")).toBe(false);
|
||||
expect(catalogIds.has("postgres")).toBe(false);
|
||||
expect(catalogIds.has("puppeteer")).toBe(false);
|
||||
expect(catalogIds.has("sqlite")).toBe(false);
|
||||
});
|
||||
|
||||
it("loads recommended automations from @openhands/extensions", () => {
|
||||
expect(AUTOMATION_CATALOG.length).toBeGreaterThan(0);
|
||||
|
||||
const knownIntegrationIds = new Set(
|
||||
INTEGRATION_CATALOG.map((entry: IntegrationCatalogEntry) => entry.id),
|
||||
);
|
||||
const knownMcpIds = new Set(INTEGRATION_CATALOG.map((entry) => entry.id));
|
||||
for (const automation of AUTOMATION_CATALOG) {
|
||||
expect(automation.requiredIntegrationIds.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
automation.requiredIntegrationIds.every((id: string) =>
|
||||
knownIntegrationIds.has(id),
|
||||
),
|
||||
automation.requiredIntegrationIds.every((id) => knownMcpIds.has(id)),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, within, fireEvent, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import MCPPage from "#/routes/mcp";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
@@ -88,12 +94,17 @@ describe("MCPPage", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mcp-install-modal")).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByTestId("mcp-install-field-command-readonly"),
|
||||
).toHaveValue("npx -y @zencoderai/slack-mcp-server");
|
||||
expect(
|
||||
screen.getByTestId("mcp-install-field-SLACK_BOT_TOKEN"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId("mcp-install-field-SLACK_TEAM_ID"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("mcp-install-field-url")).toBeNull();
|
||||
expect(screen.queryByTestId("mcp-install-field-api_key")).toBeNull();
|
||||
});
|
||||
|
||||
it("filters marketplace tiles by the search input", async () => {
|
||||
@@ -182,7 +193,9 @@ describe("MCPPage", () => {
|
||||
fireEvent.click(screen.getByTestId("mcp-section-filter-library"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("mcp-installed-empty")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("mcp-installed-empty"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId("mcp-marketplace-section")).toBeInTheDocument();
|
||||
});
|
||||
@@ -330,9 +343,10 @@ describe("MCPPage", () => {
|
||||
renderPage();
|
||||
|
||||
await screen.findByTestId("mcp-marketplace-card-tavily");
|
||||
expect(
|
||||
screen.getByTestId("mcp-marketplace-toggle-tavily"),
|
||||
).toHaveAttribute("aria-checked", "false");
|
||||
expect(screen.getByTestId("mcp-marketplace-toggle-tavily")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
expect(screen.getByTestId("mcp-installed-list")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -366,9 +380,10 @@ describe("MCPPage", () => {
|
||||
renderPage();
|
||||
|
||||
const tile = await screen.findByTestId("mcp-marketplace-card-slack");
|
||||
expect(
|
||||
screen.getByTestId("mcp-marketplace-toggle-slack"),
|
||||
).toHaveAttribute("aria-checked", "false");
|
||||
expect(screen.getByTestId("mcp-marketplace-toggle-slack")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
fireEvent.click(tile);
|
||||
|
||||
await screen.findByTestId("mcp-install-modal");
|
||||
@@ -390,9 +405,8 @@ describe("MCPPage", () => {
|
||||
.agent_settings_diff as {
|
||||
mcp_config: { mcpServers: Record<string, unknown> };
|
||||
};
|
||||
// The original Slack entry is preserved AND a second one is added
|
||||
// alongside it (suffix-collided name comes from the per-base
|
||||
// uniqueness logic in toSdkMcpConfig).
|
||||
// The original Slack stdio entry is preserved and the new stdio
|
||||
// install is suffixed rather than overwriting it.
|
||||
expect(Object.keys(sent.mcp_config.mcpServers).sort()).toEqual([
|
||||
"slack",
|
||||
"slack_1",
|
||||
|
||||
@@ -2,46 +2,45 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findCatalogEntryForServer,
|
||||
findInstalledMatch,
|
||||
getDefaultTemplate,
|
||||
getDefaultMcpTransport,
|
||||
getInstallableMcpConnectionOption,
|
||||
getMcpMarketplaceCatalog,
|
||||
installedServerMatchesQuery,
|
||||
isMarketplaceEntryAvailable,
|
||||
marketplaceEntryMatchesQuery,
|
||||
} from "#/utils/mcp-marketplace-utils";
|
||||
import {
|
||||
INTEGRATION_CATALOG as INTEGRATION_MARKETPLACE,
|
||||
type IntegrationCatalogEntry,
|
||||
} from "@openhands/extensions/integrations";
|
||||
import { INTEGRATION_CATALOG as MCP_MARKETPLACE } from "@openhands/extensions/integrations";
|
||||
|
||||
const tavilyEntry = INTEGRATION_MARKETPLACE.find(
|
||||
(e: IntegrationCatalogEntry) => e.id === "tavily",
|
||||
)!;
|
||||
const filesystemEntry = INTEGRATION_MARKETPLACE.find(
|
||||
(e: IntegrationCatalogEntry) => e.id === "filesystem",
|
||||
)!;
|
||||
// Atlassian has an SSE server as the default MCP option
|
||||
const atlassianEntry = INTEGRATION_MARKETPLACE.find(
|
||||
(e: IntegrationCatalogEntry) => e.id === "atlassian",
|
||||
)!;
|
||||
const mcpMarketplace = getMcpMarketplaceCatalog(MCP_MARKETPLACE);
|
||||
const slackEntry = mcpMarketplace.find((e) => e.id === "slack")!;
|
||||
const tavilyEntry = mcpMarketplace.find((e) => e.id === "tavily")!;
|
||||
const linearEntry = mcpMarketplace.find((e) => e.id === "linear")!;
|
||||
const filesystemEntry = mcpMarketplace.find((e) => e.id === "filesystem")!;
|
||||
|
||||
const tavilyTemplate = getDefaultTemplate(tavilyEntry)!;
|
||||
const atlassianTemplate = getDefaultTemplate(atlassianEntry)!;
|
||||
function optionTransport(entry: typeof slackEntry, optionId = "api") {
|
||||
const transport = entry.connectionOptions.find(
|
||||
(option) => option.id === optionId,
|
||||
)?.transport;
|
||||
if (!transport) throw new Error(`Missing ${optionId} transport`);
|
||||
return transport;
|
||||
}
|
||||
|
||||
describe("findInstalledMatch", () => {
|
||||
it("matches stdio servers by name", () => {
|
||||
const result = findInstalledMatch(tavilyTemplate, [
|
||||
const result = findInstalledMatch(optionTransport(slackEntry), [
|
||||
{
|
||||
id: "stdio-0",
|
||||
type: "stdio",
|
||||
name: "tavily",
|
||||
name: "slack",
|
||||
command: "npx",
|
||||
args: ["-y", "tavily-mcp"],
|
||||
args: ["-y", "@zencoderai/slack-mcp-server"],
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual(expect.objectContaining({ id: "stdio-0" }));
|
||||
});
|
||||
|
||||
it("does not match a different stdio name", () => {
|
||||
const result = findInstalledMatch(tavilyTemplate, [
|
||||
const result = findInstalledMatch(optionTransport(slackEntry), [
|
||||
{
|
||||
id: "stdio-0",
|
||||
type: "stdio",
|
||||
@@ -57,7 +56,7 @@ describe("findInstalledMatch", () => {
|
||||
// Tavily lives in the catalog as a stdio MCP entry (the previous
|
||||
// tavily-builtin / search_api_key flow never persisted anywhere
|
||||
// and silently dropped the key); confirm the now-uniform match.
|
||||
const result = findInstalledMatch(tavilyTemplate, [
|
||||
const result = findInstalledMatch(getDefaultMcpTransport(tavilyEntry)!, [
|
||||
{
|
||||
id: "stdio-0",
|
||||
type: "stdio",
|
||||
@@ -71,19 +70,18 @@ describe("findInstalledMatch", () => {
|
||||
});
|
||||
|
||||
it("matches SSE servers loosely on URL", () => {
|
||||
// Atlassian has SSE as its default MCP transport
|
||||
const result = findInstalledMatch(atlassianTemplate, [
|
||||
const result = findInstalledMatch(getDefaultMcpTransport(linearEntry)!, [
|
||||
{
|
||||
id: "sse-0",
|
||||
type: "sse",
|
||||
url: "https://mcp.atlassian.com/v1/sse/",
|
||||
url: "https://mcp.linear.app/sse/",
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual(expect.objectContaining({ id: "sse-0" }));
|
||||
});
|
||||
|
||||
it("returns null when servers carry malformed urls (defensive)", () => {
|
||||
const result = findInstalledMatch(atlassianTemplate, [
|
||||
const result = findInstalledMatch(getDefaultMcpTransport(linearEntry)!, [
|
||||
// Cast to any to simulate runtime data slipping past the type.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ id: "sse-0", type: "sse", url: undefined as any },
|
||||
@@ -92,10 +90,50 @@ describe("findInstalledMatch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getInstallableMcpConnectionOption", () => {
|
||||
it("prefers Slack's API fallback over the default OAuth option", () => {
|
||||
const option = getInstallableMcpConnectionOption(slackEntry);
|
||||
expect(option?.id).toBe("api");
|
||||
expect(option?.auth.strategy).toBe("api_key");
|
||||
expect(option?.transport.kind).toBe("stdio");
|
||||
});
|
||||
|
||||
it("returns undefined for an OAuth-only entry (no locally installable option)", () => {
|
||||
const oauthOnlyEntry: Parameters<typeof getInstallableMcpConnectionOption>[0] =
|
||||
{
|
||||
...slackEntry,
|
||||
id: "oauth-only",
|
||||
defaultConnectionOptionId: "oauth",
|
||||
connectionOptions: [
|
||||
{
|
||||
id: "oauth",
|
||||
provider: "mcp",
|
||||
auth: { strategy: "oauth2" },
|
||||
transport: { kind: "shttp", url: "https://example.com/mcp" },
|
||||
} as Parameters<typeof getInstallableMcpConnectionOption>[0]["connectionOptions"][number],
|
||||
],
|
||||
};
|
||||
const option = getInstallableMcpConnectionOption(oauthOnlyEntry);
|
||||
expect(option).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when the entry has no MCP connection options", () => {
|
||||
const noOptionsEntry: Parameters<typeof getInstallableMcpConnectionOption>[0] =
|
||||
{
|
||||
...slackEntry,
|
||||
id: "no-mcp",
|
||||
defaultConnectionOptionId: undefined,
|
||||
connectionOptions: [],
|
||||
};
|
||||
const option = getInstallableMcpConnectionOption(noOptionsEntry);
|
||||
expect(option).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMarketplaceEntryAvailable", () => {
|
||||
it("treats unset availability as 'all'", () => {
|
||||
expect(isMarketplaceEntryAvailable(tavilyEntry, "local")).toBe(true);
|
||||
expect(isMarketplaceEntryAvailable(tavilyEntry, "cloud")).toBe(true);
|
||||
expect(isMarketplaceEntryAvailable(slackEntry, "local")).toBe(true);
|
||||
expect(isMarketplaceEntryAvailable(slackEntry, "cloud")).toBe(true);
|
||||
});
|
||||
|
||||
it("hides local-only entries on cloud", () => {
|
||||
@@ -106,12 +144,12 @@ describe("isMarketplaceEntryAvailable", () => {
|
||||
|
||||
describe("marketplaceEntryMatchesQuery", () => {
|
||||
it("matches by name (case-insensitive)", () => {
|
||||
expect(marketplaceEntryMatchesQuery(tavilyEntry, "tavily")).toBe(true);
|
||||
expect(marketplaceEntryMatchesQuery(tavilyEntry, "TAVILY")).toBe(true);
|
||||
expect(marketplaceEntryMatchesQuery(slackEntry, "slack")).toBe(true);
|
||||
expect(marketplaceEntryMatchesQuery(slackEntry, "SLACK")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches by keyword", () => {
|
||||
expect(marketplaceEntryMatchesQuery(tavilyEntry, "search")).toBe(true);
|
||||
expect(marketplaceEntryMatchesQuery(slackEntry, "messaging")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches by substring of description", () => {
|
||||
@@ -119,35 +157,35 @@ describe("marketplaceEntryMatchesQuery", () => {
|
||||
});
|
||||
|
||||
it("returns true for empty/whitespace queries", () => {
|
||||
expect(marketplaceEntryMatchesQuery(tavilyEntry, "")).toBe(true);
|
||||
expect(marketplaceEntryMatchesQuery(tavilyEntry, " ")).toBe(true);
|
||||
expect(marketplaceEntryMatchesQuery(slackEntry, "")).toBe(true);
|
||||
expect(marketplaceEntryMatchesQuery(slackEntry, " ")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-matches", () => {
|
||||
expect(marketplaceEntryMatchesQuery(tavilyEntry, "zzzz-no-match")).toBe(
|
||||
expect(marketplaceEntryMatchesQuery(slackEntry, "zzzz-no-match")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installedServerMatchesQuery", () => {
|
||||
const tavilyServer = {
|
||||
const slackServer = {
|
||||
id: "stdio-0",
|
||||
type: "stdio" as const,
|
||||
name: "tavily",
|
||||
name: "slack",
|
||||
command: "npx",
|
||||
args: ["-y", "tavily-mcp"],
|
||||
args: ["-y", "@zencoderai/slack-mcp-server"],
|
||||
};
|
||||
|
||||
it("matches by stdio server name", () => {
|
||||
expect(installedServerMatchesQuery(tavilyServer, undefined, "tavily")).toBe(
|
||||
expect(installedServerMatchesQuery(slackServer, undefined, "slack")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("matches via the catalog entry's name even if server.name differs", () => {
|
||||
const renamed = { ...tavilyServer, name: "my-tavily-instance" };
|
||||
expect(installedServerMatchesQuery(renamed, tavilyEntry, "tavily")).toBe(
|
||||
const renamed = { ...slackServer, name: "my-slack-instance" };
|
||||
expect(installedServerMatchesQuery(renamed, slackEntry, "slack")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -156,31 +194,31 @@ describe("installedServerMatchesQuery", () => {
|
||||
const sseServer = {
|
||||
id: "sse-0",
|
||||
type: "sse" as const,
|
||||
url: "https://mcp.atlassian.com/v1/sse",
|
||||
url: "https://mcp.linear.app/sse",
|
||||
};
|
||||
expect(installedServerMatchesQuery(sseServer, undefined, "atlassian")).toBe(
|
||||
expect(installedServerMatchesQuery(sseServer, undefined, "linear")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("empty query always matches", () => {
|
||||
expect(installedServerMatchesQuery(tavilyServer, undefined, "")).toBe(true);
|
||||
expect(installedServerMatchesQuery(slackServer, undefined, "")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findCatalogEntryForServer", () => {
|
||||
it("finds the Tavily catalog entry for an installed Tavily stdio server", () => {
|
||||
it("finds the Slack catalog entry for an installed Slack stdio server", () => {
|
||||
const match = findCatalogEntryForServer(
|
||||
{
|
||||
id: "stdio-0",
|
||||
type: "stdio",
|
||||
name: "tavily",
|
||||
name: "slack",
|
||||
command: "npx",
|
||||
args: [],
|
||||
},
|
||||
INTEGRATION_MARKETPLACE,
|
||||
mcpMarketplace,
|
||||
);
|
||||
expect(match?.id).toBe("tavily");
|
||||
expect(match?.id).toBe("slack");
|
||||
});
|
||||
|
||||
it("returns undefined for unknown servers", () => {
|
||||
@@ -193,7 +231,7 @@ describe("findCatalogEntryForServer", () => {
|
||||
command: "npx",
|
||||
args: [],
|
||||
},
|
||||
INTEGRATION_MARKETPLACE,
|
||||
mcpMarketplace,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
@@ -203,15 +241,16 @@ describe("findCatalogEntryForServer", () => {
|
||||
// diverged from findInstalledMatch and caused installed cards to
|
||||
// render the generic icon while the marketplace tile said
|
||||
// "Installed".
|
||||
// Atlassian has SSE as its default MCP transport
|
||||
if (atlassianTemplate?.kind !== "sse") {
|
||||
throw new Error("Atlassian template should be SSE");
|
||||
const linear = mcpMarketplace.find((e) => e.id === "linear")!;
|
||||
const linearTransport = getDefaultMcpTransport(linear);
|
||||
if (linearTransport?.kind !== "sse") {
|
||||
throw new Error("Linear template should be SSE");
|
||||
}
|
||||
const normalizedUrl = atlassianTemplate.url.replace(/\/$/, "");
|
||||
const normalizedUrl = linearTransport.url.replace(/\/$/, "");
|
||||
const match = findCatalogEntryForServer(
|
||||
{ id: "sse-0", type: "sse", url: `${normalizedUrl}/` },
|
||||
INTEGRATION_MARKETPLACE,
|
||||
mcpMarketplace,
|
||||
);
|
||||
expect(match?.id).toBe("atlassian");
|
||||
expect(match?.id).toBe("linear");
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+1
-1
@@ -3441,7 +3441,7 @@
|
||||
},
|
||||
"node_modules/@openhands/extensions": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "git+ssh://git@github.com/OpenHands/extensions.git#e14f740c59b4bfd7369d4bb6aea5eeb33dd05909",
|
||||
"resolved": "git+https://github.com/OpenHands/extensions.git#e14f740c59b4bfd7369d4bb6aea5eeb33dd05909",
|
||||
"integrity": "sha512-PC0pmJD1AiNP9aDfdDfDPP1iF40m/QN3vyv/xPN9sigLBtCmNBugSIySWEGVIGydARYkVi+NHL2KorPtlAOFAg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -13,13 +13,13 @@ import type { RecommendedAutomation } from "@openhands/extensions/automations";
|
||||
import { parseMcpConfig } from "#/utils/mcp-config";
|
||||
import { flattenMcpConfig } from "#/utils/mcp-installed-servers";
|
||||
import {
|
||||
INTEGRATION_CATALOG as INTEGRATION_MARKETPLACE,
|
||||
INTEGRATION_CATALOG as MCP_MARKETPLACE,
|
||||
type IntegrationCatalogEntry as MarketplaceEntry,
|
||||
} from "@openhands/extensions/integrations";
|
||||
import {
|
||||
findInstalledMatch,
|
||||
getInstallableTemplate,
|
||||
findInstalledEntryMatch,
|
||||
getMarketplaceEntryById,
|
||||
getMcpMarketplaceCatalog,
|
||||
} from "#/utils/mcp-marketplace-utils";
|
||||
import { InstallServerModal } from "#/components/features/mcp-page/install-server-modal";
|
||||
import { RecommendedAutomationsSection } from "./recommended-automations-section";
|
||||
@@ -30,8 +30,9 @@ interface RecommendedAutomationsLauncherProps {
|
||||
}
|
||||
|
||||
function getRequiredEntries(automation: RecommendedAutomation) {
|
||||
const mcpMarketplace = getMcpMarketplaceCatalog(MCP_MARKETPLACE);
|
||||
return automation.requiredIntegrationIds
|
||||
.map((id) => getMarketplaceEntryById(id, INTEGRATION_MARKETPLACE))
|
||||
.map((id) => getMarketplaceEntryById(id, mcpMarketplace))
|
||||
.filter((entry): entry is MarketplaceEntry => !!entry);
|
||||
}
|
||||
|
||||
@@ -154,10 +155,9 @@ export function RecommendedAutomationsLauncher({
|
||||
|
||||
const getMissingEntries = useCallback(
|
||||
(automation: RecommendedAutomation) =>
|
||||
getRequiredEntries(automation).filter((entry) => {
|
||||
const template = getInstallableTemplate(entry);
|
||||
return !template || !findInstalledMatch(template, installedMcpServers);
|
||||
}),
|
||||
getRequiredEntries(automation).filter(
|
||||
(entry) => !findInstalledEntryMatch(entry, installedMcpServers),
|
||||
),
|
||||
[installedMcpServers],
|
||||
);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type RecommendedAutomation,
|
||||
} from "@openhands/extensions/automations";
|
||||
import {
|
||||
INTEGRATION_CATALOG as INTEGRATION_MARKETPLACE,
|
||||
INTEGRATION_CATALOG as MCP_MARKETPLACE,
|
||||
type IntegrationCatalogEntry as MarketplaceEntry,
|
||||
} from "@openhands/extensions/integrations";
|
||||
import { McpLogoStackBadge } from "#/components/features/mcp-page/mcp-logo-stack-badge";
|
||||
@@ -18,9 +18,9 @@ import {
|
||||
import { CirclePlusBadge } from "#/components/shared/buttons/circle-plus-check-toggle";
|
||||
import { MCPServerConfig } from "#/types/mcp-server";
|
||||
import {
|
||||
findInstalledMatch,
|
||||
getInstallableTemplate,
|
||||
findInstalledEntryMatch,
|
||||
getMarketplaceEntryById,
|
||||
getMcpMarketplaceCatalog,
|
||||
isMarketplaceEntryAvailable,
|
||||
} from "#/utils/mcp-marketplace-utils";
|
||||
import { cn } from "#/utils/utils";
|
||||
@@ -57,8 +57,9 @@ export function getAutomationsByPopularity(
|
||||
const RECOMMENDED_AUTOMATIONS = getAutomationsByPopularity(AUTOMATION_CATALOG);
|
||||
|
||||
function getRequiredEntries(automation: RecommendedAutomation) {
|
||||
const mcpMarketplace = getMcpMarketplaceCatalog(MCP_MARKETPLACE);
|
||||
return automation.requiredIntegrationIds
|
||||
.map((id) => getMarketplaceEntryById(id, INTEGRATION_MARKETPLACE))
|
||||
.map((id) => getMarketplaceEntryById(id, mcpMarketplace))
|
||||
.filter((entry): entry is MarketplaceEntry => !!entry);
|
||||
}
|
||||
|
||||
@@ -99,10 +100,7 @@ function buildRecommendedAutomationPills(
|
||||
translate: TFunction,
|
||||
): SkillCardPill[] {
|
||||
const pills: SkillCardPill[] = requiredEntries.map((entry) => {
|
||||
const template = getInstallableTemplate(entry);
|
||||
const installed = template
|
||||
? !!findInstalledMatch(template, installedServers)
|
||||
: false;
|
||||
const installed = !!findInstalledEntryMatch(entry, installedServers);
|
||||
|
||||
return {
|
||||
id: `mcp-${entry.id}`,
|
||||
@@ -182,12 +180,9 @@ export function RecommendedAutomationsSection({
|
||||
<div className={extensionModuleCardGridClassName}>
|
||||
{visibleAutomations.map((automation) => {
|
||||
const requiredEntries = getRequiredEntries(automation);
|
||||
const missingCount = requiredEntries.filter((entry) => {
|
||||
const template = getInstallableTemplate(entry);
|
||||
return (
|
||||
!template || !findInstalledMatch(template, installedServers)
|
||||
);
|
||||
}).length;
|
||||
const missingCount = requiredEntries.filter(
|
||||
(entry) => !findInstalledEntryMatch(entry, installedServers),
|
||||
).length;
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useId } from "react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AxiosError } from "axios";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { MCPTestFailure } from "@openhands/typescript-client";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalCloseButton } from "#/components/shared/modals/modal-close-button";
|
||||
@@ -13,8 +14,11 @@ import { MCPServerConfig } from "#/types/mcp-server";
|
||||
import { useAddMcpServer } from "#/hooks/mutation/use-add-mcp-server";
|
||||
import { useTestMcpServer } from "#/hooks/mutation/use-test-mcp-server";
|
||||
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
||||
import {
|
||||
getInstallableMcpConnectionOption,
|
||||
type McpMarketplaceConnectionOption,
|
||||
} from "#/utils/mcp-marketplace-utils";
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
import { getInstallableTemplate } from "#/utils/mcp-marketplace-utils";
|
||||
|
||||
interface InstallServerModalProps {
|
||||
entry: MarketplaceEntry;
|
||||
@@ -27,18 +31,34 @@ interface FieldState {
|
||||
errors: Record<string, string | null>;
|
||||
}
|
||||
|
||||
function optionNeedsCredentialField(
|
||||
option: McpMarketplaceConnectionOption | undefined,
|
||||
): boolean {
|
||||
if (option?.transport.kind !== "shttp" && option?.transport.kind !== "sse") {
|
||||
return false;
|
||||
}
|
||||
return ["api_key", "bearer", "basic"].includes(option.auth.strategy);
|
||||
}
|
||||
|
||||
function isCredentialOptional(option: McpMarketplaceConnectionOption): boolean {
|
||||
if (option.transport.kind === "stdio") {
|
||||
return option.auth.apiKeyOptional ?? false;
|
||||
}
|
||||
return option.auth.apiKeyOptional ?? option.transport.apiKeyOptional ?? false;
|
||||
}
|
||||
|
||||
function makeInitialState(entry: MarketplaceEntry): FieldState {
|
||||
const values: Record<string, string> = {};
|
||||
const template = getInstallableTemplate(entry);
|
||||
if (!template) return { values, errors: {} };
|
||||
if (template.kind === "stdio") {
|
||||
const option = getInstallableMcpConnectionOption(entry);
|
||||
const template = option?.transport;
|
||||
if (template?.kind === "stdio") {
|
||||
for (const field of template.envFields ?? []) {
|
||||
values[field.key] = "";
|
||||
}
|
||||
for (const field of template.argFields ?? []) {
|
||||
values[field.key] = "";
|
||||
}
|
||||
} else if (template.kind === "shttp" || template.kind === "sse") {
|
||||
} else if (optionNeedsCredentialField(option)) {
|
||||
values.api_key = "";
|
||||
}
|
||||
return { values, errors: {} };
|
||||
@@ -58,12 +78,13 @@ export function InstallServerModal({
|
||||
const { t } = useTranslation("openhands");
|
||||
const { mutate: addMcpServer, isPending: isAdding } = useAddMcpServer();
|
||||
const { mutate: testMcpServer, isPending: isTesting } = useTestMcpServer();
|
||||
const instanceId = useId();
|
||||
|
||||
const [state, setState] = React.useState<FieldState>(() =>
|
||||
makeInitialState(entry),
|
||||
);
|
||||
const [globalError, setGlobalError] = React.useState<string | null>(null);
|
||||
const option = getInstallableMcpConnectionOption(entry);
|
||||
const template = option?.transport;
|
||||
|
||||
const isPending = isTesting || isAdding;
|
||||
|
||||
@@ -113,8 +134,6 @@ export function InstallServerModal({
|
||||
});
|
||||
};
|
||||
|
||||
const template = getInstallableTemplate(entry);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Per-template submit handlers. Each is small and self-contained:
|
||||
// validate user input, build the payload, then hand off to
|
||||
@@ -123,11 +142,13 @@ export function InstallServerModal({
|
||||
const handleHttpServerSubmit = () => {
|
||||
// TS narrows this branch to shttp|sse; the equality guard is a
|
||||
// runtime/defensive belt to make the helper safe in isolation.
|
||||
if (!template || (template.kind !== "shttp" && template.kind !== "sse")) {
|
||||
if (template?.kind !== "shttp" && template?.kind !== "sse") {
|
||||
return;
|
||||
}
|
||||
if (!option) return;
|
||||
const apiKey = state.values.api_key?.trim() ?? "";
|
||||
if (!template.apiKeyOptional && !apiKey) {
|
||||
const needsCredential = optionNeedsCredentialField(option);
|
||||
if (needsCredential && !isCredentialOptional(option) && !apiKey) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
errors: { api_key: t(I18nKey.MCP$ERROR_FIELD_REQUIRED) },
|
||||
@@ -135,10 +156,10 @@ export function InstallServerModal({
|
||||
return;
|
||||
}
|
||||
const payload: MCPServerConfig = {
|
||||
id: `${template.kind}-${instanceId}`,
|
||||
id: `${template.kind}-${uuidv4()}`,
|
||||
type: template.kind,
|
||||
url: template.url,
|
||||
...(apiKey && { api_key: apiKey }),
|
||||
...(needsCredential && apiKey && { api_key: apiKey }),
|
||||
};
|
||||
submitServer(payload);
|
||||
};
|
||||
@@ -180,7 +201,7 @@ export function InstallServerModal({
|
||||
}
|
||||
|
||||
const payload: MCPServerConfig = {
|
||||
id: `stdio-${instanceId}`,
|
||||
id: `stdio-${uuidv4()}`,
|
||||
type: "stdio",
|
||||
name: stdio.serverName,
|
||||
command: stdio.command,
|
||||
@@ -200,9 +221,9 @@ export function InstallServerModal({
|
||||
};
|
||||
|
||||
const renderFields = () => {
|
||||
if (!template) return null;
|
||||
if (template.kind === "shttp" || template.kind === "sse") {
|
||||
const apiKeyOptional = template.apiKeyOptional ?? false;
|
||||
if (template?.kind === "shttp" || template?.kind === "sse") {
|
||||
const shouldRenderCredential = optionNeedsCredentialField(option);
|
||||
const apiKeyOptional = option ? isCredentialOptional(option) : false;
|
||||
return (
|
||||
<>
|
||||
<SettingsInput
|
||||
@@ -215,27 +236,30 @@ export function InstallServerModal({
|
||||
isDisabled
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<SettingsInput
|
||||
testId="mcp-install-field-api_key"
|
||||
name="api_key"
|
||||
type="password"
|
||||
label={t(I18nKey.SETTINGS$MCP_API_KEY)}
|
||||
value={state.values.api_key ?? ""}
|
||||
onChange={(v) => setValue("api_key", v)}
|
||||
placeholder={t(I18nKey.SETTINGS$MCP_API_KEY_PLACEHOLDER)}
|
||||
showOptionalTag={apiKeyOptional}
|
||||
required={!apiKeyOptional}
|
||||
className="w-full"
|
||||
/>
|
||||
{state.errors.api_key && (
|
||||
<p className="text-xs text-red-500">{state.errors.api_key}</p>
|
||||
)}
|
||||
</div>
|
||||
{shouldRenderCredential ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<SettingsInput
|
||||
testId="mcp-install-field-api_key"
|
||||
name="api_key"
|
||||
type="password"
|
||||
label={t(I18nKey.SETTINGS$MCP_API_KEY)}
|
||||
value={state.values.api_key ?? ""}
|
||||
onChange={(v) => setValue("api_key", v)}
|
||||
placeholder={t(I18nKey.SETTINGS$MCP_API_KEY_PLACEHOLDER)}
|
||||
showOptionalTag={apiKeyOptional}
|
||||
required={!apiKeyOptional}
|
||||
className="w-full"
|
||||
/>
|
||||
{state.errors.api_key && (
|
||||
<p className="text-xs text-red-500">{state.errors.api_key}</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (template?.kind !== "stdio") return null;
|
||||
const stdio = template;
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -5,8 +5,11 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import { McpLogoBadge } from "#/components/features/mcp-logo-badge";
|
||||
import { CirclePlusCheckToggle } from "#/components/shared/buttons/circle-plus-check-toggle";
|
||||
import { MCPServerConfig } from "#/types/mcp-server";
|
||||
import { INTEGRATION_CATALOG as INTEGRATION_MARKETPLACE } from "@openhands/extensions/integrations";
|
||||
import { findCatalogEntryForServer } from "#/utils/mcp-marketplace-utils";
|
||||
import { INTEGRATION_CATALOG as MCP_MARKETPLACE } from "@openhands/extensions/integrations";
|
||||
import {
|
||||
findCatalogEntryForServer,
|
||||
getMcpMarketplaceCatalog,
|
||||
} from "#/utils/mcp-marketplace-utils";
|
||||
import { cn } from "#/utils/utils";
|
||||
import {
|
||||
extensionModuleCardInteractiveClassName,
|
||||
@@ -52,7 +55,10 @@ export function InstalledServerCard({
|
||||
onDelete,
|
||||
}: InstalledServerCardProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const catalog = findCatalogEntryForServer(server, INTEGRATION_MARKETPLACE);
|
||||
const catalog = findCatalogEntryForServer(
|
||||
server,
|
||||
getMcpMarketplaceCatalog(MCP_MARKETPLACE),
|
||||
);
|
||||
|
||||
const title = catalog?.name ?? getServerTitle(server);
|
||||
const detailLine = getServerDetailLine(server);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import type { IntegrationCatalogEntry as MarketplaceEntry } from "@openhands/extensions/integrations";
|
||||
import { McpLogoBadge } from "#/components/features/mcp-logo-badge";
|
||||
import { getDefaultTemplate } from "#/utils/mcp-marketplace-utils";
|
||||
import { CirclePlusCheckToggle } from "#/components/shared/buttons/circle-plus-check-toggle";
|
||||
import { getDefaultMcpTransport } from "#/utils/mcp-marketplace-utils";
|
||||
import { cn } from "#/utils/utils";
|
||||
import {
|
||||
extensionModuleCardInteractiveClassName,
|
||||
@@ -20,10 +20,9 @@ export function MarketplaceCard({
|
||||
onClick,
|
||||
onAdd,
|
||||
}: MarketplaceCardProps) {
|
||||
const template = getDefaultTemplate(entry);
|
||||
const transport = getDefaultMcpTransport(entry);
|
||||
const transportLabel = (() => {
|
||||
if (!template) return "";
|
||||
switch (template.kind) {
|
||||
switch (transport?.kind) {
|
||||
case "stdio":
|
||||
return "STDIO";
|
||||
case "shttp":
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
INTEGRATION_CATALOG as INTEGRATION_MARKETPLACE,
|
||||
INTEGRATION_CATALOG as MCP_MARKETPLACE,
|
||||
type IntegrationCatalogEntry as MarketplaceEntry,
|
||||
} from "@openhands/extensions/integrations";
|
||||
import {
|
||||
getMarketplaceEntriesByPopularity,
|
||||
getMcpMarketplaceCatalog,
|
||||
isMarketplaceEntryAvailable,
|
||||
marketplaceEntryMatchesQuery,
|
||||
} from "#/utils/mcp-marketplace-utils";
|
||||
@@ -32,7 +33,7 @@ export function MarketplaceSection({
|
||||
const { t } = useTranslation("openhands");
|
||||
|
||||
const visibleEntries = getMarketplaceEntriesByPopularity(
|
||||
INTEGRATION_MARKETPLACE,
|
||||
getMcpMarketplaceCatalog(MCP_MARKETPLACE),
|
||||
).filter(
|
||||
(entry) =>
|
||||
isMarketplaceEntryAvailable(entry, backendKind) &&
|
||||
|
||||
@@ -883,6 +883,11 @@ export const SETTINGS_HANDLERS = [
|
||||
});
|
||||
}),
|
||||
|
||||
http.post("*/api/mcp/test", async () => {
|
||||
await delay();
|
||||
return HttpResponse.json({ ok: true, tools: ["mock_tool"] });
|
||||
}),
|
||||
|
||||
http.get("*/api/settings/agent-schema", async () => {
|
||||
await delay();
|
||||
return HttpResponse.json(MOCK_AGENT_SETTINGS_SCHEMA);
|
||||
|
||||
+4
-2
@@ -18,10 +18,11 @@ import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"
|
||||
import { settingsLikeMainScrollClassName } from "#/utils/settings-like-page-layout-classes";
|
||||
import {
|
||||
findCatalogEntryForServer,
|
||||
getMcpMarketplaceCatalog,
|
||||
installedServerMatchesQuery,
|
||||
} from "#/utils/mcp-marketplace-utils";
|
||||
import {
|
||||
INTEGRATION_CATALOG as INTEGRATION_MARKETPLACE,
|
||||
INTEGRATION_CATALOG as MCP_MARKETPLACE,
|
||||
type IntegrationCatalogEntry as MarketplaceEntry,
|
||||
} from "@openhands/extensions/integrations";
|
||||
import { MCPServerConfig } from "#/types/mcp-server";
|
||||
@@ -66,6 +67,7 @@ export default function MCPPage() {
|
||||
|
||||
const mcpConfig = parseMcpConfig(settings?.agent_settings?.mcp_config);
|
||||
const allServers = flattenMcpConfig(mcpConfig);
|
||||
const mcpMarketplace = getMcpMarketplaceCatalog(MCP_MARKETPLACE);
|
||||
|
||||
// Filter installed servers by the search query. We pair each server
|
||||
// with its catalog entry (if any) so the search can match friendly
|
||||
@@ -74,7 +76,7 @@ export default function MCPPage() {
|
||||
const filteredInstalledServers = allServers.filter((server) =>
|
||||
installedServerMatchesQuery(
|
||||
server,
|
||||
findCatalogEntryForServer(server, INTEGRATION_MARKETPLACE),
|
||||
findCatalogEntryForServer(server, mcpMarketplace),
|
||||
searchQuery,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,9 +1,70 @@
|
||||
import { MCPServerConfig } from "#/types/mcp-server";
|
||||
import type {
|
||||
IntegrationCatalogEntry as MarketplaceEntry,
|
||||
IntegrationTransport as MarketplaceTemplate,
|
||||
IntegrationConnectionOption,
|
||||
IntegrationTransport,
|
||||
} from "@openhands/extensions/integrations";
|
||||
|
||||
export type { MarketplaceEntry };
|
||||
|
||||
export type McpMarketplaceConnectionOption = IntegrationConnectionOption & {
|
||||
provider: "mcp";
|
||||
transport: IntegrationTransport;
|
||||
};
|
||||
|
||||
export function getMcpConnectionOptions(
|
||||
entry: MarketplaceEntry,
|
||||
): McpMarketplaceConnectionOption[] {
|
||||
return entry.connectionOptions.filter(
|
||||
(option): option is McpMarketplaceConnectionOption =>
|
||||
option.provider === "mcp" && !!option.transport,
|
||||
);
|
||||
}
|
||||
|
||||
export function getDefaultMcpConnectionOption(
|
||||
entry: MarketplaceEntry,
|
||||
): McpMarketplaceConnectionOption | undefined {
|
||||
const options = getMcpConnectionOptions(entry);
|
||||
return (
|
||||
options.find((option) => option.id === entry.defaultConnectionOptionId) ??
|
||||
options[0]
|
||||
);
|
||||
}
|
||||
|
||||
function isLocallyInstallableMcpOption(
|
||||
option: McpMarketplaceConnectionOption,
|
||||
): boolean {
|
||||
// The local install modal writes static MCP server config. OAuth options
|
||||
// describe hosted redirect flows, so prefer an API/stdio fallback when one
|
||||
// exists and leave OAuth as the default connection for hosted integrations.
|
||||
return option.auth.strategy !== "oauth2";
|
||||
}
|
||||
|
||||
export function getInstallableMcpConnectionOption(
|
||||
entry: MarketplaceEntry,
|
||||
): McpMarketplaceConnectionOption | undefined {
|
||||
const options = getMcpConnectionOptions(entry);
|
||||
const defaultOption = options.find(
|
||||
(option) => option.id === entry.defaultConnectionOptionId,
|
||||
);
|
||||
if (defaultOption && isLocallyInstallableMcpOption(defaultOption)) {
|
||||
return defaultOption;
|
||||
}
|
||||
return options.find(isLocallyInstallableMcpOption);
|
||||
}
|
||||
|
||||
export function getDefaultMcpTransport(
|
||||
entry: MarketplaceEntry,
|
||||
): IntegrationTransport | undefined {
|
||||
return getDefaultMcpConnectionOption(entry)?.transport;
|
||||
}
|
||||
|
||||
export function getMcpMarketplaceCatalog(
|
||||
catalog: MarketplaceEntry[],
|
||||
): MarketplaceEntry[] {
|
||||
return catalog.filter((entry) => !!getDefaultMcpConnectionOption(entry));
|
||||
}
|
||||
|
||||
const tryUrl = (raw: string): URL | null => {
|
||||
try {
|
||||
return new URL(raw);
|
||||
@@ -39,78 +100,51 @@ export function urlsMatch(a: unknown, b: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default transport template from an integration catalog entry.
|
||||
* Integrations may have multiple connection options; we use the default
|
||||
* one (or the first if no default is specified). Only MCP-backed options
|
||||
* have a `transport` field.
|
||||
*/
|
||||
export function getDefaultTemplate(
|
||||
entry: MarketplaceEntry,
|
||||
): MarketplaceTemplate | undefined {
|
||||
const option =
|
||||
entry.connectionOptions.find(
|
||||
(o) => o.id === entry.defaultConnectionOptionId,
|
||||
) ?? entry.connectionOptions[0];
|
||||
return option?.transport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stdio (API key-based) transport template from an integration entry.
|
||||
* Many integrations have multiple connection options (e.g., OAuth + stdio).
|
||||
* Since OAuth isn't implemented in the UI yet, the install modal should use
|
||||
* this function to get the stdio-based option that can be configured with
|
||||
* API keys/tokens.
|
||||
*
|
||||
* Falls back to getDefaultTemplate if no stdio option exists.
|
||||
*/
|
||||
export function getInstallableTemplate(
|
||||
entry: MarketplaceEntry,
|
||||
): MarketplaceTemplate | undefined {
|
||||
// First, try to find a stdio option (API key-based, what we can actually install)
|
||||
const stdioOption = entry.connectionOptions.find(
|
||||
(o) => o.transport?.kind === "stdio",
|
||||
);
|
||||
if (stdioOption?.transport) return stdioOption.transport;
|
||||
|
||||
// Fall back to the default template (could be shttp/sse with api_key)
|
||||
return getDefaultTemplate(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a marketplace template is already represented by one
|
||||
* of the installed MCP servers. Used to render an "Installed" badge on
|
||||
* the marketplace tile. Returns the first matching server, or null.
|
||||
*/
|
||||
export function findInstalledMatch(
|
||||
template: MarketplaceTemplate,
|
||||
transport: IntegrationTransport,
|
||||
servers: MCPServerConfig[],
|
||||
): MCPServerConfig | null {
|
||||
if (template.kind === "shttp") {
|
||||
const tplUrl = template.url;
|
||||
if (!tplUrl) return null;
|
||||
return (
|
||||
servers.find((server) => transportMatchesServer(transport, server)) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function findInstalledEntryMatch(
|
||||
entry: MarketplaceEntry,
|
||||
servers: MCPServerConfig[],
|
||||
): MCPServerConfig | null {
|
||||
for (const option of getMcpConnectionOptions(entry)) {
|
||||
const match = findInstalledMatch(option.transport, servers);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function transportMatchesServer(
|
||||
transport: IntegrationTransport,
|
||||
server: MCPServerConfig,
|
||||
): boolean {
|
||||
if (transport.kind === "shttp") {
|
||||
const tplUrl = transport.url;
|
||||
return (
|
||||
servers.find(
|
||||
(s) => s.type === "shttp" && !!s.url && urlsMatch(s.url, tplUrl),
|
||||
) ?? null
|
||||
server.type === "shttp" && !!server.url && urlsMatch(server.url, tplUrl)
|
||||
);
|
||||
}
|
||||
|
||||
if (template.kind === "sse") {
|
||||
const tplUrl = template.url;
|
||||
if (!tplUrl) return null;
|
||||
if (transport.kind === "sse") {
|
||||
const tplUrl = transport.url;
|
||||
return (
|
||||
servers.find(
|
||||
(s) => s.type === "sse" && !!s.url && urlsMatch(s.url, tplUrl),
|
||||
) ?? null
|
||||
server.type === "sse" && !!server.url && urlsMatch(server.url, tplUrl)
|
||||
);
|
||||
}
|
||||
|
||||
// stdio: match on the registered server name.
|
||||
return (
|
||||
servers.find((s) => s.type === "stdio" && s.name === template.serverName) ??
|
||||
null
|
||||
);
|
||||
return server.type === "stdio" && server.name === transport.serverName;
|
||||
}
|
||||
|
||||
export function isMarketplaceEntryAvailable(
|
||||
@@ -203,38 +237,17 @@ export function installedServerMatchesQuery(
|
||||
* Look up the catalog entry that best matches an installed server.
|
||||
* Mirrors the lookup used in `installed-server-card.tsx` for
|
||||
* rendering the friendly icon.
|
||||
*
|
||||
* Since an entry may have multiple connection options (e.g., OAuth + stdio),
|
||||
* we check ALL templates in the entry's connectionOptions, not just the default.
|
||||
*/
|
||||
export function findCatalogEntryForServer(
|
||||
server: MCPServerConfig,
|
||||
catalog: MarketplaceEntry[],
|
||||
): MarketplaceEntry | undefined {
|
||||
return catalog.find((entry) => {
|
||||
// Check all connection options, not just the default
|
||||
for (const option of entry.connectionOptions) {
|
||||
const tpl = option.transport;
|
||||
if (!tpl) continue;
|
||||
if (tpl.kind === "stdio") {
|
||||
if (server.type === "stdio" && server.name === tpl.serverName)
|
||||
return true;
|
||||
}
|
||||
// Reuse the same loose URL match as `findInstalledMatch` so a
|
||||
// server whose URL was normalized by the backend (trailing slash
|
||||
// stripped, query string dropped, etc.) still gets paired with
|
||||
// its catalog tile — otherwise the installed-servers list would
|
||||
// render the generic icon while the marketplace shows the
|
||||
// entry as installed, which is confusing.
|
||||
if (tpl.kind === "shttp") {
|
||||
if (server.type === "shttp" && urlsMatch(server.url, tpl.url))
|
||||
return true;
|
||||
}
|
||||
if (tpl.kind === "sse") {
|
||||
if (server.type === "sse" && urlsMatch(server.url, tpl.url))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
// Check every MCP option rather than only the default. Some unified
|
||||
// integration entries default to OAuth-hosted MCP while still exposing
|
||||
// an API/stdio option; existing installed servers should match either.
|
||||
return getMcpConnectionOptions(entry).some((option) =>
|
||||
transportMatchesServer(option.transport, server),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { seedLocalStorage } from "./support/seed-local-storage";
|
||||
/**
|
||||
* Visual snapshot tests for the MCP page (/mcp).
|
||||
*
|
||||
* The MCP marketplace catalog is imported from @openhands/extensions/mcps,
|
||||
* The MCP marketplace catalog is imported from @openhands/extensions/integrations,
|
||||
* so it never requires an API call. Installed servers are read from
|
||||
* settings.agent_settings.mcp_config (SDK format: { mcpServers: { ... } }).
|
||||
*
|
||||
@@ -28,13 +28,68 @@ async function dismissConsentModal(page: Page) {
|
||||
/**
|
||||
* Wire up the base routes every MCP page test needs.
|
||||
*
|
||||
* NOTE: Settings requests go to the same-origin Vite dev server where MSW
|
||||
* wins over page.route(). We dismiss the consent modal after navigation
|
||||
* instead of trying to suppress it here.
|
||||
* Settings are owned here rather than by the real local backend. MCP saves do a
|
||||
* pre-clear PATCH followed by the new config write, so the snapshot flow needs
|
||||
* a deterministic in-memory settings response.
|
||||
*/
|
||||
async function setupMocks(page: Page) {
|
||||
await seedLocalStorage(page);
|
||||
|
||||
let agentSettings: Record<string, unknown> = {};
|
||||
let conversationSettings: Record<string, unknown> = {};
|
||||
|
||||
await page.route("**/api/settings", async (route) => {
|
||||
const request = route.request();
|
||||
|
||||
if (request.method() === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
agent_settings: agentSettings,
|
||||
conversation_settings: conversationSettings,
|
||||
llm_api_key_is_set: true,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === "PATCH") {
|
||||
const body = request.postDataJSON() as {
|
||||
agent_settings_diff?: Record<string, unknown>;
|
||||
conversation_settings_diff?: Record<string, unknown>;
|
||||
};
|
||||
agentSettings = {
|
||||
...agentSettings,
|
||||
...(body.agent_settings_diff ?? {}),
|
||||
};
|
||||
conversationSettings = {
|
||||
...conversationSettings,
|
||||
...(body.conversation_settings_diff ?? {}),
|
||||
};
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
agent_settings: agentSettings,
|
||||
conversation_settings: conversationSettings,
|
||||
llm_api_key_is_set: true,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
await page.route("**/api/mcp/test", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true, tools: ["mock_tool"] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/conversations/search**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -141,13 +196,16 @@ test.describe("MCP Page Visual Snapshots", () => {
|
||||
await expect(mcpPage).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await test.step("step 1 – marketplace before install", async () => {
|
||||
await expect(
|
||||
page.getByTestId("mcp-marketplace-card-slack"),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
await expect(mcpPage).toHaveScreenshot("mcp-slack-install-1-marketplace.png", {
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0.01,
|
||||
await expect(page.getByTestId("mcp-marketplace-card-slack")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(mcpPage).toHaveScreenshot(
|
||||
"mcp-slack-install-1-marketplace.png",
|
||||
{
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0.01,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await test.step("step 2 – Slack install modal open", async () => {
|
||||
@@ -155,10 +213,13 @@ test.describe("MCP Page Visual Snapshots", () => {
|
||||
await expect(page.getByTestId("mcp-install-modal")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(rootLayout).toHaveScreenshot("mcp-slack-install-2-modal.png", {
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0.01,
|
||||
});
|
||||
await expect(rootLayout).toHaveScreenshot(
|
||||
"mcp-slack-install-2-modal.png",
|
||||
{
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0.01,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await test.step("step 3 – fill in bot token and team ID", async () => {
|
||||
@@ -168,10 +229,13 @@ test.describe("MCP Page Visual Snapshots", () => {
|
||||
await page
|
||||
.getByTestId("mcp-install-field-SLACK_TEAM_ID")
|
||||
.fill("T01ABC123");
|
||||
await expect(rootLayout).toHaveScreenshot("mcp-slack-install-3-filled.png", {
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0.01,
|
||||
});
|
||||
await expect(rootLayout).toHaveScreenshot(
|
||||
"mcp-slack-install-3-filled.png",
|
||||
{
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0.01,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await test.step("step 4 – submit and confirm Slack is installed", async () => {
|
||||
@@ -190,10 +254,13 @@ test.describe("MCP Page Visual Snapshots", () => {
|
||||
// Brief wait to let the toast and any animations settle
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
await expect(mcpPage).toHaveScreenshot("mcp-slack-install-4-installed.png", {
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0.01,
|
||||
});
|
||||
await expect(mcpPage).toHaveScreenshot(
|
||||
"mcp-slack-install-4-installed.png",
|
||||
{
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0.01,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ test.describe("UI Visual Snapshots", () => {
|
||||
await dismissConsentModal(page);
|
||||
|
||||
const homeScreen = page.getByTestId("home-screen");
|
||||
await expect(homeScreen).toBeVisible({ timeout: 15000 });
|
||||
await expect(homeScreen).toBeVisible({ timeout: 15_000 });
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const rootLayout = page.getByTestId("root-layout");
|
||||
|
||||
Reference in New Issue
Block a user