diff --git a/.pr/mcp-evidence/mcp-catalog-all-43-entries.png b/.pr/mcp-evidence/mcp-catalog-all-43-entries.png new file mode 100644 index 0000000000..614890df91 Binary files /dev/null and b/.pr/mcp-evidence/mcp-catalog-all-43-entries.png differ diff --git a/.pr/mcp-evidence/mcp-marketplace-all-entries.png b/.pr/mcp-evidence/mcp-marketplace-all-entries.png new file mode 100644 index 0000000000..faa8eae6b5 Binary files /dev/null and b/.pr/mcp-evidence/mcp-marketplace-all-entries.png differ diff --git a/.pr/mcp-evidence/pr-757-everything-installed-e1c24fb.png b/.pr/mcp-evidence/pr-757-everything-installed-e1c24fb.png new file mode 100644 index 0000000000..6efa5c4dbd Binary files /dev/null and b/.pr/mcp-evidence/pr-757-everything-installed-e1c24fb.png differ diff --git a/AGENTS.md b/AGENTS.md index 7810816da9..8f9d4fe4ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -293,7 +293,11 @@ import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; const { t } = useTranslation("openhands"); -return ; +return ( + +); // WRONG -- ships English to every locale; flagged by i18next/no-literal-string return ; @@ -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`. diff --git a/__tests__/components/automations/recommended-automations.test.tsx b/__tests__/components/automations/recommended-automations.test.tsx index 59fa4e613e..7891833ac3 100644 --- a/__tests__/components/automations/recommended-automations.test.tsx +++ b/__tests__/components/automations/recommended-automations.test.tsx @@ -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")); diff --git a/__tests__/components/features/mcp-page/install-server-modal.test.tsx b/__tests__/components/features/mcp-page/install-server-modal.test.tsx index a23d9db434..a5d29f6045 100644 --- a/__tests__/components/features/mcp-page/install-server-modal.test.tsx +++ b/__tests__/components/features/mcp-page/install-server-modal.test.tsx @@ -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(); + renderWith(); 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 }; }; 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(); + + 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) + .agent_settings_diff as { + mcp_config: { mcpServers: Record }; + }; + 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(); + const slack = MCP_MARKETPLACE.find((e) => e.id === "slack")!; + renderWith(); 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(); + const slack = MCP_MARKETPLACE.find((e) => e.id === "slack")!; + renderWith(); 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 }, }, ], }; diff --git a/__tests__/components/features/mcp-page/mcp-logo-stack-badge.test.tsx b/__tests__/components/features/mcp-page/mcp-logo-stack-badge.test.tsx index 52b80bd73e..e906722e30 100644 --- a/__tests__/components/features/mcp-page/mcp-logo-stack-badge.test.tsx +++ b/__tests__/components/features/mcp-page/mcp-logo-stack-badge.test.tsx @@ -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; } diff --git a/__tests__/constants/extensions-catalogs.test.ts b/__tests__/constants/extensions-catalogs.test.ts index 967e3431df..3233e40b45 100644 --- a/__tests__/constants/extensions-catalogs.test.ts +++ b/__tests__/constants/extensions-catalogs.test.ts @@ -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); } }); diff --git a/__tests__/routes/mcp-page.test.tsx b/__tests__/routes/mcp-page.test.tsx index d98d917eee..c144c7a60c 100644 --- a/__tests__/routes/mcp-page.test.tsx +++ b/__tests__/routes/mcp-page.test.tsx @@ -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 }; }; - // 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", diff --git a/__tests__/utils/mcp-marketplace-utils.test.ts b/__tests__/utils/mcp-marketplace-utils.test.ts index 2fabacfb18..5cfa5b835b 100644 --- a/__tests__/utils/mcp-marketplace-utils.test.ts +++ b/__tests__/utils/mcp-marketplace-utils.test.ts @@ -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[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[0]["connectionOptions"][number], + ], + }; + const option = getInstallableMcpConnectionOption(oauthOnlyEntry); + expect(option).toBeUndefined(); + }); + + it("returns undefined when the entry has no MCP connection options", () => { + const noOptionsEntry: Parameters[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"); }); }); diff --git a/package-lock.json b/package-lock.json index 6861e54baa..0e6dbbd5cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/src/components/features/automations/recommended-automations-launcher.tsx b/src/components/features/automations/recommended-automations-launcher.tsx index 79239b6cb5..5ab90710b4 100644 --- a/src/components/features/automations/recommended-automations-launcher.tsx +++ b/src/components/features/automations/recommended-automations-launcher.tsx @@ -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], ); diff --git a/src/components/features/automations/recommended-automations-section.tsx b/src/components/features/automations/recommended-automations-section.tsx index fe3acd5334..a64833b6e2 100644 --- a/src/components/features/automations/recommended-automations-section.tsx +++ b/src/components/features/automations/recommended-automations-section.tsx @@ -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({
{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 (