chore: pin extensions catalog dependency (#1451)

* chore: pin extensions catalog dependency

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: update extensions catalog pin

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: repin extensions catalog package

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: consume raw integration catalog entries

Co-authored-by: openhands <openhands@all-hands.dev>

* refactor: rely on extensions Linear catalog data

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: repin extensions without aggregate catalog

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: repin extensions generated catalog comment

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: repin extensions catalog

Co-authored-by: openhands <openhands@all-hands.dev>

* refactor: remove marketplace runtime filtering

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: render MCP logos from catalog metadata

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: repin extensions catalog dependency

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: repin extensions catalog metadata

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: repin extensions release workflow

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: use released extensions package

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: add img onError handler and clarify isAutomationAvailable intent

- mcp-logo-badge: add onError handler to hide broken images when the
  simpleicons CDN is unreachable, so the badge background still shows
  rather than a broken-image icon
- recommended-automations-section: document why isAutomationAvailable
  uses length > 0 (intentional: hides cards whose required integrations
  are not in the catalog or are empty) vs the old .every() behaviour
  which returned true for empty arrays

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: satisfy logo fallback lint

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: resolve @openhands/extensions@0.6.0 from npm registry instead of GitHub archive

Now that 0.6.0 is published on npm, update the lockfile resolved URL from
the GitHub tarball to the canonical registry artifact. The integrity hash
now matches the npm release provenance.

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: remove dead marketplace backend prop

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: neubig <398875+neubig@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Graham Neubig
2026-06-23 20:11:49 +00:00
committed by GitHub
co-authored by openhands neubig
parent 8d8ae5f8a0
commit 1bf2f95800
11 changed files with 45 additions and 159 deletions
@@ -128,11 +128,9 @@ describe("InstallServerModal", () => {
// without relying on the catalog choosing to mark one this way.
const entry: MarketplaceEntry = {
id: "synthetic-required",
kind: "mcp",
name: "Synthetic",
description: "Synthetic catalog entry used in tests.",
iconBg: "#000000",
defaultConnectionOptionId: "api",
connectionOptions: [
{
id: "api",
@@ -171,11 +169,9 @@ describe("InstallServerModal", () => {
it("allows submitting an shttp template with no key when apiKeyOptional is true", async () => {
const entry: MarketplaceEntry = {
id: "synthetic-optional",
kind: "mcp",
name: "Synthetic Optional",
description: "Synthetic entry that allows empty api_key.",
iconBg: "#000000",
defaultConnectionOptionId: "api",
connectionOptions: [
{
id: "api",
@@ -301,11 +297,9 @@ describe("InstallServerModal", () => {
const entry: MarketplaceEntry = {
id: "synthetic-test-fail",
kind: "mcp",
name: "Failing Server",
description: "Always fails the connection test.",
iconBg: "#000000",
defaultConnectionOptionId: "api",
connectionOptions: [
{
id: "api",
@@ -384,11 +378,9 @@ describe("InstallServerModal", () => {
const entry: MarketplaceEntry = {
id: "synthetic-test-pass",
kind: "mcp",
name: "Passing Server",
description: "Always passes the connection test.",
iconBg: "#000000",
defaultConnectionOptionId: "api",
connectionOptions: [
{
id: "api",
@@ -425,11 +417,9 @@ describe("InstallServerModal", () => {
const entry: MarketplaceEntry = {
id: "synthetic-pending",
kind: "mcp",
name: "Pending Server",
description: "Connection test never resolves.",
iconBg: "#000000",
defaultConnectionOptionId: "api",
connectionOptions: [
{
id: "api",
@@ -468,11 +458,9 @@ describe("InstallServerModal", () => {
// types without depending on the live integration catalog.
const STDIO_ENTRY = {
id: "synthetic-stdio",
kind: "mcp",
name: "Synthetic Stdio Server",
description: "Stdio server used to test the save-as-secret feature.",
iconBg: "#000000",
defaultConnectionOptionId: "stdio",
connectionOptions: [
{
id: "stdio",
@@ -515,11 +503,9 @@ describe("InstallServerModal", () => {
const SHTTP_ENTRY = {
id: "synthetic-shttp-secret",
kind: "mcp",
name: "Synthetic Hosted Server",
description: "Hosted server used to test credential secret saving.",
iconBg: "#000000",
defaultConnectionOptionId: "api",
connectionOptions: [
{
id: "api",
@@ -1,6 +1,5 @@
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 {
getDefaultMcpTransport,
@@ -13,7 +12,7 @@ describe("OpenHands extensions catalogs", () => {
const github = INTEGRATION_CATALOG.find((entry) => entry.id === "github");
expect(getDefaultMcpTransport(github!)?.kind).toBe("shttp");
expect(INTEGRATION_LOGOS.github).toBeTruthy();
expect(github?.logoUrl).toBe("https://cdn.simpleicons.org/github/FFFFFF");
});
it("patches Slack to the maintained docs and npm package", () => {
@@ -34,15 +33,10 @@ describe("OpenHands extensions catalogs", () => {
);
});
it("patches Linear to the streamable HTTP /mcp endpoint with bearer auth", () => {
// Arrange: upstream still ships the removed /sse SSE transport; the
// marketplace catalog must serve the patched entry instead.
it("loads Linear streamable HTTP /mcp endpoint with bearer auth", () => {
const catalog = getMcpMarketplaceCatalog(INTEGRATION_CATALOG);
// Act
const linear = catalog.find((entry) => entry.id === "linear")!;
// Assert
expect(getDefaultMcpTransport(linear)).toEqual({
kind: "shttp",
url: "https://mcp.linear.app/mcp",
@@ -53,22 +47,9 @@ describe("OpenHands extensions catalogs", () => {
(option) => option.transport?.kind === "shttp",
);
expect(mcpOption?.auth.strategy).toBe("bearer");
});
it("does not mutate the imported catalog when patching Linear", () => {
// Arrange/Act: run the patch, then inspect the raw imported entry.
getMcpMarketplaceCatalog(INTEGRATION_CATALOG);
const raw = INTEGRATION_CATALOG.find((entry) => entry.id === "linear");
// Assert: the shared JSON module still carries the upstream values.
const rawOption = raw?.connectionOptions.find(
(option) => option.transport?.kind === "sse",
);
expect(rawOption?.transport).toEqual({
kind: "sse",
url: "https://mcp.linear.app/sse",
apiKeyOptional: true,
});
expect(
linear.connectionOptions.some((option) => option.transport?.kind === "sse"),
).toBe(false);
});
it("drops deprecated MCP entries that no longer have maintained replacements", () => {
@@ -6,7 +6,6 @@ import {
getInstallableMcpConnectionOption,
getMcpMarketplaceCatalog,
installedServerMatchesQuery,
isMarketplaceEntryAvailable,
marketplaceEntryMatchesQuery,
} from "#/utils/mcp-marketplace-utils";
import { INTEGRATION_CATALOG as MCP_MARKETPLACE } from "@openhands/extensions/integrations";
@@ -104,7 +103,6 @@ describe("getInstallableMcpConnectionOption", () => {
>[0] = {
...slackEntry,
id: "oauth-only",
defaultConnectionOptionId: "oauth",
connectionOptions: [
{
id: "oauth",
@@ -126,7 +124,6 @@ describe("getInstallableMcpConnectionOption", () => {
>[0] = {
...slackEntry,
id: "no-mcp",
defaultConnectionOptionId: undefined,
connectionOptions: [],
};
const option = getInstallableMcpConnectionOption(noOptionsEntry);
@@ -134,18 +131,6 @@ describe("getInstallableMcpConnectionOption", () => {
});
});
describe("isMarketplaceEntryAvailable", () => {
it("treats unset availability as 'all'", () => {
expect(isMarketplaceEntryAvailable(slackEntry, "local")).toBe(true);
expect(isMarketplaceEntryAvailable(slackEntry, "cloud")).toBe(true);
});
it("hides local-only entries on cloud", () => {
expect(isMarketplaceEntryAvailable(filesystemEntry, "local")).toBe(true);
expect(isMarketplaceEntryAvailable(filesystemEntry, "cloud")).toBe(false);
});
});
describe("marketplaceEntryMatchesQuery", () => {
it("matches by name (case-insensitive)", () => {
expect(marketplaceEntryMatchesQuery(slackEntry, "slack")).toBe(true);
+4 -5
View File
@@ -12,7 +12,7 @@
"@heroui/react": "2.8.10",
"@microlink/react-json-view": "1.31.20",
"@monaco-editor/react": "4.7.0",
"@openhands/extensions": "0.5.0",
"@openhands/extensions": "0.6.0",
"@openhands/typescript-client": "1.25.0",
"@react-router/node": "7.17.0",
"@react-router/serve": "7.17.0",
@@ -652,7 +652,6 @@
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -3468,9 +3467,9 @@
"license": "MIT"
},
"node_modules/@openhands/extensions": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@openhands/extensions/-/extensions-0.5.0.tgz",
"integrity": "sha512-k5JR2JJiipg9yCqEwDU67x458PKdSBp9tt0xbzncqtjgC+eKlsQQZ8eSayXPqJPCo9cv7dRZfxX7WW259fLu7g==",
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@openhands/extensions/-/extensions-0.6.0.tgz",
"integrity": "sha512-9wgLTH6c3dC+aufS+reX2pFcyamLOTALOW36o+8MFfXy1fUDh1SBiif/Abn+O5PjFwLYMI8WV4I3/+Jvs7jf0Q==",
"license": "MIT",
"engines": {
"node": ">=18.20.0"
+1 -1
View File
@@ -23,7 +23,7 @@
"@heroui/react": "2.8.10",
"@microlink/react-json-view": "1.31.20",
"@monaco-editor/react": "4.7.0",
"@openhands/extensions": "0.5.0",
"@openhands/extensions": "0.6.0",
"@openhands/typescript-client": "1.25.0",
"@react-router/node": "7.17.0",
"@react-router/serve": "7.17.0",
@@ -21,7 +21,6 @@ import {
findInstalledEntryMatch,
getMarketplaceEntryById,
getMcpMarketplaceCatalog,
isMarketplaceEntryAvailable,
} from "#/utils/mcp-marketplace-utils";
import { cn } from "#/utils/utils";
import {
@@ -98,13 +97,16 @@ function automationMatchesQuery(
return haystack.includes(query);
}
function isAutomationAvailable(
automation: RecommendedAutomation,
backendKind: "local" | "cloud",
) {
return getRequiredEntries(automation).every((entry) =>
isMarketplaceEntryAvailable(entry, backendKind),
);
/**
* Returns true only when at least one of the automation's required integration
* IDs resolves to a known marketplace entry. An empty result means none of
* the required integrations are in our catalog (or the array itself is empty),
* so there is nothing for the user to set up — hide the card.
* NOTE: intentionally no local/cloud backend availability filter; every entry
* with a catalog match is shown regardless of runtimeAvailability.
*/
function isAutomationAvailable(automation: RecommendedAutomation) {
return getRequiredEntries(automation).length > 0;
}
function buildRecommendedAutomationPills(
@@ -222,7 +224,7 @@ function AutomationCardGrid({
}
export function RecommendedAutomationsSection({
backendKind,
backendKind: _backendKind,
installedServers,
query = "",
onSelect,
@@ -233,7 +235,7 @@ export function RecommendedAutomationsSection({
const visibleAutomations = RECOMMENDED_AUTOMATIONS.filter((automation) => {
const requiredEntries = getRequiredEntries(automation);
return (
isAutomationAvailable(automation, backendKind) &&
isAutomationAvailable(automation) &&
automationMatchesQuery(automation, requiredEntries, query)
);
});
+15 -8
View File
@@ -1,14 +1,11 @@
import type { ReactNode } from "react";
import { Bot } from "lucide-react";
import type { IntegrationCatalogEntry } from "@openhands/extensions/integrations";
import {
INTEGRATION_FALLBACK_LOGO,
INTEGRATION_LOGOS,
} from "@openhands/extensions/integrations/logos";
import { cn } from "#/utils/utils";
type McpLogoEntry = Pick<
IntegrationCatalogEntry,
"id" | "name" | "iconBg" | "iconColor"
"id" | "name" | "iconBg" | "iconColor" | "logoUrl"
>;
export type { McpLogoEntry };
@@ -50,9 +47,19 @@ export function McpLogoBadge({
color: entry?.iconColor ?? "#FFFFFF",
}}
>
{entry
? (INTEGRATION_LOGOS[entry.id] ?? fallback ?? INTEGRATION_FALLBACK_LOGO)
: (fallback ?? INTEGRATION_FALLBACK_LOGO)}
{entry?.logoUrl ? (
<img
src={entry.logoUrl}
alt={`${entry.name} logo`}
className="h-full w-full object-contain p-[22%]"
onError={(e) => {
const image = e.currentTarget;
image.style.display = "none";
}}
/>
) : (
(fallback ?? <Bot className="h-5 w-5" strokeWidth={2.25} />)
)}
</span>
);
}
@@ -7,7 +7,6 @@ import {
import {
getMarketplaceEntriesByPopularity,
getMcpMarketplaceCatalog,
isMarketplaceEntryAvailable,
marketplaceEntryMatchesQuery,
} from "#/utils/mcp-marketplace-utils";
import { MarketplaceCard } from "./marketplace-card";
@@ -17,7 +16,6 @@ import {
} from "#/utils/extension-module-card-classes";
interface MarketplaceSectionProps {
backendKind: "local" | "cloud";
onSelect: (entry: MarketplaceEntry) => void;
onAdd: (entry: MarketplaceEntry) => void;
/** Empty string = no filter. */
@@ -25,7 +23,6 @@ interface MarketplaceSectionProps {
}
export function MarketplaceSection({
backendKind,
onSelect,
onAdd,
query = "",
@@ -34,11 +31,7 @@ export function MarketplaceSection({
const visibleEntries = getMarketplaceEntriesByPopularity(
getMcpMarketplaceCatalog(MCP_MARKETPLACE),
).filter(
(entry) =>
isMarketplaceEntryAvailable(entry, backendKind) &&
marketplaceEntryMatchesQuery(entry, query),
);
).filter((entry) => marketplaceEntryMatchesQuery(entry, query));
return (
<section
@@ -10,7 +10,10 @@ const STACK_CONTAINER_CLASS_NAME =
interface McpLogoStackBadgeProps {
entries: Array<
Pick<IntegrationCatalogEntry, "id" | "name" | "iconBg" | "iconColor">
Pick<
IntegrationCatalogEntry,
"id" | "name" | "iconBg" | "iconColor" | "logoUrl"
>
>;
className?: string;
testId?: string;
-4
View File
@@ -7,7 +7,6 @@ import { BrandButton } from "#/components/features/settings/brand-button";
import { ConfirmationModal } from "#/components/shared/modals/confirmation-modal";
import { useSettings } from "#/hooks/query/use-settings";
import { useDeleteMcpServer } from "#/hooks/mutation/use-delete-mcp-server";
import { useActiveBackend } from "#/contexts/active-backend-context";
import { parseMcpConfig } from "#/utils/mcp-config";
import {
displayErrorToast,
@@ -46,8 +45,6 @@ export default function MCPPage() {
const { data: settings, isLoading } = useSettings();
const { mutate: deleteMcpServer, isPending: isDeleting } =
useDeleteMcpServer();
const activeBackend = useActiveBackend();
const backendKind = activeBackend.backend.kind;
const [installEntry, setInstallEntry] =
React.useState<MarketplaceEntry | null>(null);
@@ -175,7 +172,6 @@ export default function MCPPage() {
{sectionFilter !== "installed" ? (
<MarketplaceSection
backendKind={backendKind}
onSelect={handleMarketplaceInstall}
onAdd={handleMarketplaceInstall}
query={searchQuery}
+3 -69
View File
@@ -34,11 +34,7 @@ export function getMcpConnectionOptions(
export function getDefaultMcpConnectionOption(
entry: MarketplaceEntry,
): McpMarketplaceConnectionOption | undefined {
const options = getMcpConnectionOptions(entry);
return (
options.find((option) => option.id === entry.defaultConnectionOptionId) ??
options[0]
);
return getMcpConnectionOptions(entry)[0];
}
function isLocallyInstallableMcpOption(
@@ -53,14 +49,7 @@ function isLocallyInstallableMcpOption(
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);
return getMcpConnectionOptions(entry).find(isLocallyInstallableMcpOption);
}
export function getDefaultMcpTransport(
@@ -69,56 +58,10 @@ export function getDefaultMcpTransport(
return getDefaultMcpConnectionOption(entry)?.transport;
}
const LINEAR_DEPRECATED_SSE_URL = "https://mcp.linear.app/sse";
const LINEAR_SHTTP_URL = "https://mcp.linear.app/mcp";
const LINEAR_DOCS_URL = "https://linear.app/docs/mcp";
/**
* Upstream @openhands/extensions still ships Linear's deprecated SSE
* transport (removed upstream on 2026-04-08; the /sse endpoint now
* rejects every call). Rewrite the entry to streamable HTTP at the
* /mcp replacement endpoint until the pinned dependency catches up.
*
* The /mcp endpoint authenticates via OAuth 2.1 or a Linear API key
* sent as "Authorization: Bearer <token>". This client has no
* interactive OAuth flow for MCP installs, so switch the auth
* strategy from "none" to "bearer" — the install modal then offers
* an (optional) API key field and the agent server forwards it as a
* Bearer header.
*
* Patches immutably — the imported catalog JSON is shared module
* state and must not be mutated.
*/
function patchLinearEntry(entry: MarketplaceEntry): MarketplaceEntry {
if (entry.id !== "linear") return entry;
return {
...entry,
docsUrl: LINEAR_DOCS_URL,
installHint:
"Authenticate with a Linear API key (Linear → Settings → Security & access) — sent as a Bearer token. Optional when the endpoint accepts your OAuth session.",
connectionOptions: entry.connectionOptions.map((option) =>
option.transport?.kind === "sse" &&
urlsMatch(option.transport.url, LINEAR_DEPRECATED_SSE_URL)
? {
...option,
auth: { ...option.auth, strategy: "bearer" as const },
transport: {
kind: "shttp" as const,
url: LINEAR_SHTTP_URL,
apiKeyOptional: option.transport.apiKeyOptional,
},
}
: option,
),
};
}
export function getMcpMarketplaceCatalog(
catalog: MarketplaceEntry[],
): MarketplaceEntry[] {
return catalog
.map(patchLinearEntry)
.filter((entry) => !!getDefaultMcpConnectionOption(entry));
return catalog.filter((entry) => !!getDefaultMcpConnectionOption(entry));
}
const tryUrl = (raw: string): URL | null => {
@@ -203,15 +146,6 @@ function transportMatchesServer(
return server.type === "stdio" && server.name === transport.serverName;
}
export function isMarketplaceEntryAvailable(
entry: MarketplaceEntry,
backendKind: "local" | "cloud",
): boolean {
if (!entry.runtimeAvailability || entry.runtimeAvailability === "all")
return true;
return entry.runtimeAvailability === backendKind;
}
function normalize(query: string): string {
return query.trim().toLowerCase();
}