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
20 changes: 15 additions & 5 deletions packages/connections/src/mcp-oauth-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ describe("MCP OAuth connect flow", () => {
}
});

test("an unknown slug with no url override is a 404", async () => {
test("an unknown slug with no url override redirects as an error", async () => {
const hub = fakeHub();
const routes = createMcpOAuthRoutes({
hubUrl: "http://hub.test",
Expand All @@ -453,8 +453,13 @@ describe("MCP OAuth connect flow", () => {
apiCall: hub.apiCall,
});
const app = mountAs(routes);
const response = await app.request("/not-a-preset/start");
expect(response.status).toBe(404);
const response = await app.request("/not-a-preset/start", {
redirect: "manual",
});
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe(
"/plugins?mcpOauth=not-a-preset&outcome=error&code=not_found",
);
});

test("a preset that doesn't connect with OAuth is refused at start, not mid-dance", async () => {
Expand All @@ -470,8 +475,13 @@ describe("MCP OAuth connect flow", () => {
// github-mcp is a token preset (GitHub offers no dynamic client
// registration) and exa is keyless — neither has an OAuth dance.
for (const slug of ["github-mcp", "exa"]) {
const response = await app.request(`/${slug}/start`);
expect(response.status).toBe(400);
const response = await app.request(`/${slug}/start`, {
redirect: "manual",
});
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe(
`/plugins?mcpOauth=${slug}&outcome=error&code=bad_request`,
);
}
});

Expand Down
30 changes: 14 additions & 16 deletions packages/connections/src/mcp-oauth-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,6 @@ import {
sanitizeReturnPath,
} from "./oauth-routes";

const ErrorEnvelope = (code: string, message: string) => ({
error: { code, message },
});

const OAUTH_STATE_TTL_MS = 10 * 60 * 1000;

// One provider label for the whole MCP connect surface — the sealed
Expand Down Expand Up @@ -156,12 +152,13 @@ export function createMcpOAuthRoutes(
returnPathAllowlist,
);
if (target === undefined) {
return c.json(
ErrorEnvelope(
"not_found",
`Unknown MCP server preset: "${slugParam}"`,
),
404,
return c.redirect(
redirectPath(returnPath, {
mcpOauth: slugParam,
outcome: "error",
code: "not_found",
}),
302,
);
}
const queryUrl = c.req.query("url");
Expand All @@ -173,12 +170,13 @@ export function createMcpOAuthRoutes(
// A keyless or token preset has no OAuth dance to start — refuse
// here rather than failing mid-dance at the provider (GitHub's
// MCP server, for one, offers no dynamic client registration).
return c.json(
ErrorEnvelope(
"bad_request",
`${preset.displayName} doesn't connect with a sign-in here — connect it from Plugins instead.`,
),
400,
return c.redirect(
redirectPath(returnPath, {
mcpOauth: preset.slug,
outcome: "error",
code: "bad_request",
}),
302,
);
}

Expand Down
2 changes: 2 additions & 0 deletions packages/plugins-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export { PluginConnectPanel } from "./plugin-connect-panel";
export { McpServersSection } from "./mcp-servers-section";
export { McpPresetCardsSection } from "./mcp-preset-cards";

export { PLUGINS_STRINGS } from "./strings";

export {
McpServersApiError,
listMcpServers,
Expand Down
33 changes: 31 additions & 2 deletions packages/plugins-ui/src/mcp-preset-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,38 @@ import {
type McpPreset,
} from "./mcp-servers-api";
import { PluginLogo } from "./plugin-logo";
import { PLUGINS_STRINGS } from "./strings";

function messageOf(cause: unknown): string {
return cause instanceof Error ? cause.message : String(cause);
}

const MCP_OAUTH_ERROR_COPY: Readonly<Record<string, string>> = {
discovery_failed: "Couldn't reach that app's sign-in. Try connecting again.",
no_authorization_needed:
"That app didn't start a sign-in. Try connecting again.",
state_expired:
"The connection took too long or was already used. Try connecting again.",
state_mismatch: "The connection was interrupted. Try connecting again.",
exchange_failed: "That app didn't hand back a token. Try connecting again.",
connect_failed: "Couldn't finish connecting. Try connecting again.",
setup_failed:
"The sign-in worked, but storing the connection failed. Try connecting again.",
not_found: "That app isn't in this catalog.",
bad_request: "This app doesn't connect with a sign-in here.",
};

function mcpOauthReturnError(slug: string): string | null {
const params = new URLSearchParams(window.location.search);
if (params.get("mcpOauth") !== slug) return null;
if (params.get("outcome") !== "error") return null;
const code = params.get("code");
return (
(code !== null ? MCP_OAUTH_ERROR_COPY[code] : undefined) ??
"The connection did not finish. Try connecting again."
);
}

function McpPresetCard({
tenantId,
preset,
Expand All @@ -32,7 +59,9 @@ function McpPresetCard({
readonly onChanged: (toolCount?: number) => void;
}) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(() =>
mcpOauthReturnError(preset.slug),
);
const [tokenFieldOpen, setTokenFieldOpen] = useState(false);
const [token, setToken] = useState("");

Expand Down Expand Up @@ -72,7 +101,7 @@ function McpPresetCard({
toast(`${preset.displayName} disconnected.`);
onChanged();
})
.catch(() => setError("Couldn't disconnect — try again."))
.catch(() => setError(PLUGINS_STRINGS.disconnectError))
.finally(() => setBusy(false));
}

Expand Down
3 changes: 2 additions & 1 deletion packages/plugins-ui/src/mcp-servers-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
listMcpServers,
type McpServer,
} from "./mcp-servers-api";
import { PLUGINS_STRINGS } from "./strings";

function messageOf(cause: unknown): string {
return cause instanceof Error ? cause.message : String(cause);
Expand All @@ -43,7 +44,7 @@ function ConnectedMcpServerRow({
toast(`${server.name} disconnected.`);
onChanged();
})
.catch(() => setError("Couldn't disconnect — try again."))
.catch(() => setError(PLUGINS_STRINGS.disconnectError))
.finally(() => setBusy(false));
}

Expand Down
3 changes: 2 additions & 1 deletion packages/plugins-ui/src/plugin-connect-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type { ResolvedPlugin } from "@workbench/connections/plugins";
import { useEffect, useState } from "react";

import { pluginOutcome } from "./plugin-meta";
import { PLUGINS_STRINGS } from "./strings";

const PLUGINS_RETURN_PATH = "/plugins";

Expand Down Expand Up @@ -142,7 +143,7 @@ function ConnectedSummary({
toast(`${plugin.descriptor.displayName} disconnected.`);
onChanged();
})
.catch(() => setError("Couldn't disconnect — try again."))
.catch(() => setError(PLUGINS_STRINGS.disconnectError))
.finally(() => setBusy(false));
}

Expand Down
9 changes: 1 addition & 8 deletions packages/plugins-ui/src/plugins-gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,14 +236,7 @@ export function PluginsGallery({
<div className="flex flex-col gap-4 pt-3">
{active === "plugins" ? (
<>
<div>
<h1 className="text-xl font-extrabold">Plugins</h1>
<p className="mt-1 max-w-[65ch] text-sm text-muted-foreground">
A directory to scan, not tiles to admire — one dense row per
connector, grouped by what it does. Click a row's name for
the full page.
</p>
</div>
<h1 className="text-xl font-extrabold">Plugins</h1>
<McpPresetCardsSection tenantId={tenantId} query={query} />
<McpServersSection tenantId={tenantId} />
<PluginsTabPanel
Expand Down
6 changes: 6 additions & 0 deletions packages/plugins-ui/src/strings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Every user-facing word the plugins surface prints, in one place. Nothing
// in the plugins-ui/* components inlines its own copy; it imports from here.

export const PLUGINS_STRINGS = {
disconnectError: "Couldn't disconnect — try again.",
} as const;
87 changes: 87 additions & 0 deletions packages/plugins-ui/test/mcp-preset-cards.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const realFetch = globalThis.fetch;
let mountedRoots: Root[] = [];
afterEach(() => {
globalThis.fetch = realFetch;
window.history.replaceState(null, "", "https://workbench.test/");
for (const root of mountedRoots) act(() => root.unmount());
mountedRoots = [];
});
Expand Down Expand Up @@ -116,6 +117,92 @@ describe("McpPresetCardsSection", () => {
expect(connectGranola?.textContent?.trim()).toBe("Connect");
});

test("Connect Granola navigates to /start via location href, never fetch", async () => {
const calls: string[] = [];
globalThis.fetch = (async (url: string) => {
calls.push(String(url));
return new Response(JSON.stringify({ data: PRESETS }));
}) as unknown as typeof fetch;

const assigned: string[] = [];
const hrefDescriptor = Object.getOwnPropertyDescriptor(
window.location,
"href",
);
Object.defineProperty(window.location, "href", {
configurable: true,
get() {
return (
hrefDescriptor?.get?.call(window.location) ??
"https://workbench.test/"
);
},
set(value: string) {
assigned.push(value);
},
});

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

const granolaCard = container.querySelector(
'[data-plugin-slug="granola"]',
) as HTMLElement;
const connectButton = [...granolaCard.querySelectorAll("button")].find(
(button) => button.textContent?.includes("Connect"),
) as HTMLButtonElement;

await act(async () => {
connectButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 10));
});

expect(assigned).toEqual([
"/api/tenants/tenant_test/mcp-servers/oauth/granola/start",
]);
expect(calls.some((url) => url.includes("/start"))).toBe(false);
} finally {
if (hrefDescriptor !== undefined) {
Object.defineProperty(window.location, "href", hrefDescriptor);
} else {
delete (window.location as { href?: string }).href;
}
}
});

test("an OAuth error return surfaces a sentence on that preset row and leaves Connect as retry", async () => {
window.history.replaceState(
null,
"",
"/?mcpOauth=granola&outcome=error&code=discovery_failed",
);
globalThis.fetch = (async () =>
new Response(
JSON.stringify({ data: PRESETS }),
)) as unknown as typeof fetch;

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

const granolaCard = container.querySelector(
'[data-plugin-slug="granola"]',
) as HTMLElement;
expect(granolaCard.textContent).toContain(
"Couldn't reach that app's sign-in. Try connecting again.",
);
expect(
granolaCard.querySelector('[aria-label="Connect Granola"]'),
).not.toBeNull();

const exaCard = container.querySelector(
'[data-plugin-slug="exa"]',
) as HTMLElement;
expect(exaCard.textContent).not.toContain(
"Couldn't reach that app's sign-in. Try connecting again.",
);
});

test("Disconnect's accessible name includes the preset display name (CL-6794)", async () => {
globalThis.fetch = (async () =>
new Response(
Expand Down
3 changes: 2 additions & 1 deletion packages/plugins-ui/test/plugin-connect-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { ConnectorDescriptor } from "@workbench/connections/registry";
import type { ResolvedPlugin } from "@workbench/connections/plugins";

import { PluginConnectPanel } from "../src/plugin-connect-panel";
import { PLUGINS_STRINGS } from "../src/strings";

const realFetch = globalThis.fetch;
let mountedRoots: Root[] = [];
Expand Down Expand Up @@ -169,7 +170,7 @@ describe("PluginConnectPanel", () => {
});
await settle();

expect(container.textContent).toContain("Couldn't disconnect");
expect(container.textContent).toContain(PLUGINS_STRINGS.disconnectError);
});

function githubDescriptor(): ConnectorDescriptor {
Expand Down
5 changes: 1 addition & 4 deletions packages/plugins-ui/test/plugins-gallery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,14 +203,11 @@ describe("PluginsGallery", () => {
expect(container.textContent).toContain("Inherited");
});

test("the plugins tab renders the list heading and directory sub copy (CL-6467)", () => {
test("the plugins tab renders the list heading", () => {
const { container } = renderGallery();

const heading = container.querySelector("h1");
expect(heading?.textContent).toBe("Plugins");
expect(container.textContent).toContain(
"A directory to scan, not tiles to admire — one dense row per connector, grouped by what it does. Click a row's name for the full page.",
);
});

test("a plugin row's status caption is never hidden — it is a core column, not overflow (CL-6467)", () => {
Expand Down
Loading