diff --git a/packages/connections/src/mcp-oauth-routes.test.ts b/packages/connections/src/mcp-oauth-routes.test.ts index 58b26f532..4515d86e5 100644 --- a/packages/connections/src/mcp-oauth-routes.test.ts +++ b/packages/connections/src/mcp-oauth-routes.test.ts @@ -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", @@ -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 () => { @@ -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`, + ); } }); diff --git a/packages/connections/src/mcp-oauth-routes.ts b/packages/connections/src/mcp-oauth-routes.ts index 39ae0514c..ce5a29328 100644 --- a/packages/connections/src/mcp-oauth-routes.ts +++ b/packages/connections/src/mcp-oauth-routes.ts @@ -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 @@ -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"); @@ -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, ); } diff --git a/packages/plugins-ui/src/index.ts b/packages/plugins-ui/src/index.ts index 3c0781b36..04c7a9fc2 100644 --- a/packages/plugins-ui/src/index.ts +++ b/packages/plugins-ui/src/index.ts @@ -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, diff --git a/packages/plugins-ui/src/mcp-preset-cards.tsx b/packages/plugins-ui/src/mcp-preset-cards.tsx index 99a88d825..80e47b84f 100644 --- a/packages/plugins-ui/src/mcp-preset-cards.tsx +++ b/packages/plugins-ui/src/mcp-preset-cards.tsx @@ -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> = { + 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, @@ -32,7 +59,9 @@ function McpPresetCard({ readonly onChanged: (toolCount?: number) => void; }) { const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(() => + mcpOauthReturnError(preset.slug), + ); const [tokenFieldOpen, setTokenFieldOpen] = useState(false); const [token, setToken] = useState(""); @@ -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)); } diff --git a/packages/plugins-ui/src/mcp-servers-section.tsx b/packages/plugins-ui/src/mcp-servers-section.tsx index d6f6827c2..f1f385e9a 100644 --- a/packages/plugins-ui/src/mcp-servers-section.tsx +++ b/packages/plugins-ui/src/mcp-servers-section.tsx @@ -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); @@ -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)); } diff --git a/packages/plugins-ui/src/plugin-connect-panel.tsx b/packages/plugins-ui/src/plugin-connect-panel.tsx index 68320ab20..13b112b4d 100644 --- a/packages/plugins-ui/src/plugin-connect-panel.tsx +++ b/packages/plugins-ui/src/plugin-connect-panel.tsx @@ -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"; @@ -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)); } diff --git a/packages/plugins-ui/src/plugins-gallery.tsx b/packages/plugins-ui/src/plugins-gallery.tsx index 8b9e5ad7d..1650f80ec 100644 --- a/packages/plugins-ui/src/plugins-gallery.tsx +++ b/packages/plugins-ui/src/plugins-gallery.tsx @@ -236,14 +236,7 @@ export function PluginsGallery({
{active === "plugins" ? ( <> -
-

Plugins

-

- 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. -

-
+

Plugins

{ globalThis.fetch = realFetch; + window.history.replaceState(null, "", "https://workbench.test/"); for (const root of mountedRoots) act(() => root.unmount()); mountedRoots = []; }); @@ -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( diff --git a/packages/plugins-ui/test/plugin-connect-panel.test.tsx b/packages/plugins-ui/test/plugin-connect-panel.test.tsx index 2f866faca..993541c3c 100644 --- a/packages/plugins-ui/test/plugin-connect-panel.test.tsx +++ b/packages/plugins-ui/test/plugin-connect-panel.test.tsx @@ -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[] = []; @@ -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 { diff --git a/packages/plugins-ui/test/plugins-gallery.test.tsx b/packages/plugins-ui/test/plugins-gallery.test.tsx index e47d7dd9e..88c1c6826 100644 --- a/packages/plugins-ui/test/plugins-gallery.test.tsx +++ b/packages/plugins-ui/test/plugins-gallery.test.tsx @@ -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)", () => {