Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 38 additions & 22 deletions packages/plugins-ui/src/mcp-preset-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,14 +210,23 @@ export function McpPresetCardsSection({
new Map(),
);
const [loadError, setLoadError] = useState<string | null>(null);
// Whether the presets fetch has resolved at least once — distinct from
// "zero presets": this catalog is the same ~10 curated apps for every
// tenant, so an empty `presets` array before this flips true is a
// loading gap, never a real "nothing to connect" state (CL-6472). The
// section used to `return null` whenever presets were empty regardless
// of why, which let a load-in-progress render as if the whole catalog
// had vanished — this component must never go quiet like that again.
const [loaded, setLoaded] = useState(false);

function reload() {
listMcpPresets(tenantId)
.then((data) => {
setPresets(data);
setLoadError(null);
})
.catch((cause: unknown) => setLoadError(messageOf(cause)));
.catch((cause: unknown) => setLoadError(messageOf(cause)))
.finally(() => setLoaded(true));
}

useEffect(() => {
Expand All @@ -233,8 +242,6 @@ export function McpPresetCardsSection({
.includes(needle),
);

if (visiblePresets.length === 0 && loadError === null) return null;

return (
<section className="flex flex-col gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Expand All @@ -244,25 +251,34 @@ export function McpPresetCardsSection({
<p className="text-sm text-destructive" role="alert">
{loadError}
</p>
) : null}
<div className="border border-border [&>*:last-child]:border-b-0">
{visiblePresets.map((preset) => (
<McpPresetCard
key={preset.slug}
tenantId={tenantId}
preset={preset}
toolCount={toolCounts.get(preset.slug)}
onChanged={(toolCount) => {
if (toolCount !== undefined) {
setToolCounts((prev) =>
new Map(prev).set(preset.slug, toolCount),
);
}
reload();
}}
/>
))}
</div>
) : !loaded ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : visiblePresets.length === 0 ? (
<p className="text-sm text-muted-foreground">
{presets.length === 0
? "No apps to connect right now."
: `No app matches "${query.trim()}".`}
</p>
) : (
<div className="border border-border [&>*:last-child]:border-b-0">
{visiblePresets.map((preset) => (
<McpPresetCard
key={preset.slug}
tenantId={tenantId}
preset={preset}
toolCount={toolCounts.get(preset.slug)}
onChanged={(toolCount) => {
if (toolCount !== undefined) {
setToolCounts((prev) =>
new Map(prev).set(preset.slug, toolCount),
);
}
reload();
}}
/>
))}
</div>
)}
</section>
);
}
53 changes: 53 additions & 0 deletions packages/plugins-ui/test/mcp-preset-cards.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";

import { MCP_PRESETS } from "@workbench/connections/mcp-presets";

import { McpPresetCardsSection } from "../src/mcp-preset-cards";

const realFetch = globalThis.fetch;
Expand Down Expand Up @@ -267,4 +269,55 @@ describe("McpPresetCardsSection", () => {
expect(deleteCall).not.toBeUndefined();
expect(deleteCall?.url).toBe("/api/tenants/tenant_test/mcp-servers/exa");
});

// CL-6472: a fresh bench with zero connections still owns the same
// curated catalog — the route returns all 10 presets regardless, so
// every one of them must reach the page.
test("every preset the route returns reaches the page on a fresh bench", async () => {
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
data: MCP_PRESETS.map((preset) => ({
slug: preset.slug,
displayName: preset.displayName,
description: preset.description,
url: preset.url,
connectionMode: preset.connectionMode,
docsUrl: preset.docsUrl,
...(preset.icon === undefined ? {} : { icon: preset.icon }),
...(preset.tokenSteps === undefined
? {}
: { tokenSteps: preset.tokenSteps }),
connected: false,
})),
}),
)) as unknown as typeof fetch;

const container = mountSection();
await settle();

expect(container.querySelectorAll("[data-plugin-slug]")).toHaveLength(
MCP_PRESETS.length,
);
for (const preset of MCP_PRESETS) {
expect(container.textContent).toContain(preset.displayName);
}
});

// CL-6472: this section must never silently disappear. Before this
// fix it `return null`ed for any load that resolved with zero presets
// (including one still in flight), which is indistinguishable from the
// whole "Connect apps" catalog vanishing.
test("never renders nothing — a failed load surfaces an error, not silence", async () => {
globalThis.fetch = (async () =>
new Response("Internal Server Error", {
status: 500,
})) as unknown as typeof fetch;

const container = mountSection();
await settle();

expect(container.textContent).toContain("Connect apps");
expect(container.querySelector('[role="alert"]')).not.toBeNull();
});
});
46 changes: 46 additions & 0 deletions packages/plugins-ui/test/plugins-gallery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { Root } from "react-dom/client";

import type { ConnectorDescriptor } from "@workbench/connections/registry";
import type { ResolvedPlugin } from "@workbench/connections/plugins";
import { MCP_PRESETS } from "@workbench/connections/mcp-presets";

import { PluginsGallery } from "../src/plugins-gallery";
import type { SkillCardData } from "../src/skill-card";
Expand Down Expand Up @@ -269,4 +270,49 @@ describe("PluginsGallery", () => {
}
expect(container.textContent).not.toContain("Add MCP server");
});

// CL-6472: the owner's live repro found the presets route returns all 10
// curated MCP presets on a fresh bench with zero connections, but the
// gallery showed none of them. Reproduce that exact fetch shape here so
// a regression in how the gallery composes `McpPresetCardsSection`
// (as opposed to a bug in that component alone) gets caught too.
test("every MCP preset the route returns reaches the gallery on a fresh bench (CL-6472)", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => {
const url = String(input);
if (url.includes("/mcp-servers/presets")) {
return new Response(
JSON.stringify({
data: MCP_PRESETS.map((preset) => ({
slug: preset.slug,
displayName: preset.displayName,
description: preset.description,
url: preset.url,
connectionMode: preset.connectionMode,
docsUrl: preset.docsUrl,
...(preset.icon === undefined ? {} : { icon: preset.icon }),
...(preset.tokenSteps === undefined
? {}
: { tokenSteps: preset.tokenSteps }),
connected: false,
})),
}),
);
}
return new Response(JSON.stringify({ data: [] }));
}) as unknown as typeof fetch;

try {
const { container } = renderGallery([]);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});

for (const preset of MCP_PRESETS) {
expect(container.textContent).toContain(preset.displayName);
}
} finally {
globalThis.fetch = originalFetch;
}
});
});
Loading