From 5bb1eadd50c982c5899cd8b2c3347febb715d8be Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 05:51:16 -0700 Subject: [PATCH 1/4] Add tests for the request_connection Plugins deep link CL-7141: prove `/plugins?connect=` opens that connector's panel and strips the param, ignores an unrecognized id, and confirm the MCP fallback message no longer promises a `/plugins?connect=mcp` link that lands on nothing. --- apps/web/test/plugins-page.test.tsx | 32 +++++++++++++++++++++ packages/connections-tools/src/tool.test.ts | 5 ++++ 2 files changed, 37 insertions(+) diff --git a/apps/web/test/plugins-page.test.tsx b/apps/web/test/plugins-page.test.tsx index e47281b92..b3731781f 100644 --- a/apps/web/test/plugins-page.test.tsx +++ b/apps/web/test/plugins-page.test.tsx @@ -36,6 +36,7 @@ afterEach(() => { container.remove(); container = null; } + window.history.replaceState(null, "", "/plugins"); }); const membership = { @@ -660,4 +661,35 @@ describe("PluginsRoute", () => { expect(el.textContent).not.toContain("Connected here"); }); + + // CL-7141: `request_connection`'s fallback link (`/plugins?connect=`) + // hands off through the same `requestPluginsConnect` path the shell + // banner's "Fix it" click uses. + test("a `?connect=` URL naming a known connector opens that connector's connect panel", async () => { + stubFetch(); + window.history.replaceState(null, "", "/plugins?connect=github"); + + const el = await mount(); + + const dialog = document.querySelector('[role="dialog"]'); + expect(dialog).not.toBeNull(); + expect(dialog?.textContent).toContain("GitHub"); + expect(window.location.search).toBe(""); + expect(el).not.toBeNull(); + }); + + // CL-7141: an id the registry doesn't recognize (typo, stale link) is + // ignored — no dialog, no "couldn't find that connection" notice. + test("a `?connect=` URL naming an unknown connector is ignored", async () => { + stubFetch(); + window.history.replaceState(null, "", "/plugins?connect=bogus"); + + const el = await mount(); + + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(el.textContent).not.toContain( + "Couldn't find that connection — pick it below.", + ); + expect(window.location.search).toBe(""); + }); }); diff --git a/packages/connections-tools/src/tool.test.ts b/packages/connections-tools/src/tool.test.ts index a3dd6b485..c0f706457 100644 --- a/packages/connections-tools/src/tool.test.ts +++ b/packages/connections-tools/src/tool.test.ts @@ -138,6 +138,11 @@ test("request_connection tells the agent to keep helping for a name this workspa expect(result.content).toMatch(/can't connect/i); expect(result.content).toMatch(/keep helping/i); expect(result.content).not.toMatch(/name and URL/); + // CL-7141: no fake `/plugins?connect=mcp` deep link — there is no + // generic add-custom-MCP-server card to land on, so the fallback + // points at the Plugins page's own connector list in plain prose. + expect(result.content).not.toContain("/plugins?connect=mcp"); + expect(result.content).toMatch(/plugins page/i); } finally { globalThis.fetch = originalFetch; } From 845aaf08b92fdb570ea72bbf5cfd616afff853ba Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 05:51:23 -0700 Subject: [PATCH 2/4] Wire up request_connection's Plugins deep link, drop the fake MCP one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Plugins page never read `?connect=` from request_connection's fallback link, so it landed on the gallery without opening the named connector. It now reads the param on mount, opens that connector's panel through the same path an in-app "Fix it" click uses, and strips the param so a reload can't replay it. The MCP-server fallback pointed at `/plugins?connect=mcp`, a card that was never built — there is no generic add-custom-MCP-server form by design. That copy now tells the human plainly to use the Plugins page's connector list instead of a link that goes nowhere. Fixes CL-7141. --- apps/web/src/pages/plugins-page.tsx | 20 +++++++++++++ packages/connections-tools/package.json | 2 +- packages/connections-tools/src/tool.ts | 37 +++++++++++-------------- workflows/assistant/src/index.ts | 2 +- 4 files changed, 38 insertions(+), 23 deletions(-) diff --git a/apps/web/src/pages/plugins-page.tsx b/apps/web/src/pages/plugins-page.tsx index 31979f871..51c4bcb59 100644 --- a/apps/web/src/pages/plugins-page.tsx +++ b/apps/web/src/pages/plugins-page.tsx @@ -23,6 +23,7 @@ import { } from "@corbits/plugins-ui"; import type { ResolvedPlugin } from "@workbench/connections/plugins"; import { listPluginsForTenant } from "@workbench/connections/plugins"; +import { CONNECTOR_REGISTRY } from "@workbench/connections/registry"; import { Plus, SquaresFour, Warning } from "@corbits/icons"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -31,6 +32,7 @@ import { SKILLS_PATH_PREFIX } from "../path-ids"; import { useClearPendingConnectProvider, usePendingConnectProvider, + useRequestPluginsConnect, } from "../shell/provider-health-context"; import { StageTopBar } from "../shell/stage-top-bar"; import { @@ -87,6 +89,7 @@ export function PluginsRoute({ const [connectDeepLinkNotFound, setConnectDeepLinkNotFound] = useState(false); const pendingConnectProvider = usePendingConnectProvider(); const clearPendingConnectProvider = useClearPendingConnectProvider(); + const requestPluginsConnect = useRequestPluginsConnect(); const openPluginPanel = useCallback((plugin: ResolvedPlugin) => { setOpenPlugin(plugin); setConnectDeepLinkNotFound(false); @@ -175,6 +178,23 @@ export function PluginsRoute({ openPluginPanel, ]); + // `request_connection`'s fallback link (CL-7141): `/plugins?connect=` + // hands the connector id off through the same `requestPluginsConnect` + // path the shell banner's "Fix it" click uses, then strips the param so + // a reload never replays a stale connect intent. An id this registry + // doesn't recognize (typo, stale link) is ignored rather than surfaced + // as a notice. + useEffect(() => { + const connectId = new URLSearchParams(window.location.search).get( + "connect", + ); + if (connectId === null) return; + window.history.replaceState(null, "", window.location.pathname); + if (CONNECTOR_REGISTRY[connectId] !== undefined) { + requestPluginsConnect(connectId); + } + }, [requestPluginsConnect]); + function openSkill(name: string) { navigate(`${SKILLS_PATH_PREFIX}/${encodeURIComponent(name)}`); } diff --git a/packages/connections-tools/package.json b/packages/connections-tools/package.json index 26ab52233..d9e77c9d9 100644 --- a/packages/connections-tools/package.json +++ b/packages/connections-tools/package.json @@ -2,7 +2,7 @@ "name": "@corbits/connections-tools", "private": true, "description": "The list_connections and request_connection tools as an @intx/agent tool bundle: an agent sees which third-party connections this workbench already has live and hands the human a deep link to connect one that isn't, reusing the existing connections plane and never inventing new state or completing OAuth itself", - "version": "0.0.5", + "version": "0.0.6", "license": "LGPL-2.1-or-later", "type": "module", "exports": { diff --git a/packages/connections-tools/src/tool.ts b/packages/connections-tools/src/tool.ts index 834a769d8..104e4b719 100644 --- a/packages/connections-tools/src/tool.ts +++ b/packages/connections-tools/src/tool.ts @@ -13,7 +13,7 @@ // `@corbits/mcp-tools`' own `mcp_list_servers` route) — a tenant-minted // `mcp:` connector has no fixed registry id, so `list_connections` // folds it into the connected list by name, and `request_connection` -// falls back to it (see `ADD_MCP_SERVER_DEEP_LINK`) before reporting an +// falls back to it (see `ADD_MCP_SERVER_GUIDANCE`) before reporting an // unknown connector. // // Approval: `list_connections` reads only, so it declares no `approval` @@ -80,27 +80,24 @@ function clientConfig(env: WorkflowConnectionEnv) { }; } -/** `/plugins?connect=` — a plain, honest deep link into - * the Plugins panel. - * - * [Intx/repo gap]: this query param is not yet read by any apps/web - * page. apps/web/src/shell/provider-health-context.tsx's - * `requestPluginsConnect` sets `pendingConnectProvider` only via - * in-app navigation calls (see apps/web/src/pages/chat-page.tsx's use - * of it), never from a URL param — there is no URL-based deep link - * today. So this link takes the human to the Plugins panel but does - * not yet auto-open the specific connector; wiring `?connect=` up is a - * small follow-up for whoever owns apps/web. */ +/** `/plugins?connect=` — a deep link into the Plugins + * panel that auto-opens the named connector's own connect card + * (CL-7141: `apps/web/src/pages/plugins-page.tsx` reads this `connect` + * query param on mount and hands it to the same + * `requestPluginsConnect` path an in-app "Fix it" click uses). */ function connectDeepLink(connectorId: string): string { return `/plugins?connect=${connectorId}`; } -/** `/plugins?connect=mcp` — the same deep-link shape `connectDeepLink` - * builds for a fixed `CONNECTOR_REGISTRY` id, pointed at Plugins' - * generic "Add MCP server" card instead of one connector's own card, - * since an MCP server has no fixed id to deep-link to (it is tenant- - * minted at connect time — see `mcp-server-routes.ts`'s header). */ -const ADD_MCP_SERVER_DEEP_LINK = "/plugins?connect=mcp"; +/** There is no fixed id — and no generic add-custom-MCP-server form at + * all — to deep-link to (CL-7141; see + * `packages/plugins-ui/src/mcp-servers-section.tsx`'s header for why: + * only curated presets are self-serve installable, an MCP server has + * no id before it's tenant-minted at connect time). So this stays + * plain prose pointed at the Plugins page's own connector list rather + * than a link that would land on nothing. */ +const ADD_MCP_SERVER_GUIDANCE = + "point them to the Plugins page in this workbench — every connector it can add lives there"; /** `/plugins?connect=mcp:` — a curated preset's own card * (CL-6152). Presets are still tenant-minted `mcp:` connections @@ -324,9 +321,7 @@ async function runRequestConnection( `This workspace can't connect "${parsed.connector}" yet. Tell the ` + `human plainly what you can still do without it, and keep helping ` + `with that now — never ask them to go set up servers or report ` + - `back. A custom MCP server can be added from ` + - `${ADD_MCP_SERVER_DEEP_LINK} if they ever want to wire one up ` + - `themselves.`, + `back. If they want to add it themselves, ${ADD_MCP_SERVER_GUIDANCE}.`, }; } diff --git a/workflows/assistant/src/index.ts b/workflows/assistant/src/index.ts index b683eb6d6..1640466d8 100644 --- a/workflows/assistant/src/index.ts +++ b/workflows/assistant/src/index.ts @@ -45,7 +45,7 @@ export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ { name: "@corbits/capability-tools", version: "0.0.3" }, { name: "@corbits/routines-tools", version: "0.0.5" }, { name: "@corbits/agent-directory-tools", version: "0.0.4" }, - { name: "@corbits/connections-tools", version: "0.0.5" }, + { name: "@corbits/connections-tools", version: "0.0.6" }, { name: "@corbits/catalog-tools", version: "0.0.1" }, { name: "@corbits/skills-tools", version: "0.0.6" }, { name: "@corbits/mcp-tools", version: "0.0.8" }, From 716803731b439c7c21971540ee759e68b0521f71 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 08:53:51 -0700 Subject: [PATCH 3/4] Add tests for the mcp: preset deep link and connect-param stripping Covers request_connection's fallback link for a curated MCP preset (/plugins?connect=mcp:), which the Plugins page's CONNECTOR_REGISTRY check can't match today, and the query-param-stripping bug where the connect handoff drops every other param on the URL. --- apps/web/test/plugins-page.test.tsx | 66 ++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/apps/web/test/plugins-page.test.tsx b/apps/web/test/plugins-page.test.tsx index b3731781f..f5e90c072 100644 --- a/apps/web/test/plugins-page.test.tsx +++ b/apps/web/test/plugins-page.test.tsx @@ -85,10 +85,15 @@ function fillField(id: string, value: string, textarea = false) { el.dispatchEvent(new Event("input", { bubbles: true })); } -function stubFetch(): void { +function stubFetch( + options: { readonly mcpPresets?: readonly Record[] } = {}, +): void { + const mcpPresets = options.mcpPresets ?? []; globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { const path = typeof input === "string" ? input : String(input); const method = (init?.method ?? "GET").toUpperCase(); + if (path.includes("/mcp-servers/presets")) + return Promise.resolve(json({ data: mcpPresets })); if (path.includes("/api/me/principals")) return Promise.resolve(json(membership)); if (path.includes("/api/workbench-tenancies/kinds")) @@ -692,4 +697,63 @@ describe("PluginsRoute", () => { ); expect(window.location.search).toBe(""); }); + + // CL-7141: `?connect=` strips only the `connect` param — any other + // query param this route was opened with must survive. + test("a `?connect=` URL keeps every other query param", async () => { + stubFetch(); + window.history.replaceState(null, "", "/plugins?foo=bar&connect=github"); + + await mount(); + + expect(window.location.search).toBe("?foo=bar"); + }); + + // CL-7141: `presetDeepLink` in `packages/connections-tools/src/tool.ts` + // emits `/plugins?connect=mcp:` for a curated MCP preset (Exa, + // Granola, Linear, ...) — this page resolves that against the preset + // catalog and focuses the matching card's own Connect button once it + // has loaded, rather than matching it against `CONNECTOR_REGISTRY` + // (which has no entry for a preset's `mcp:` id). + test("a `?connect=mcp:` URL naming a known preset focuses that preset's connect button", async () => { + stubFetch({ + mcpPresets: [ + { + slug: "exa", + displayName: "Exa", + description: "Search and research the live web.", + url: "https://mcp.exa.ai/mcp", + connectionMode: "keyless", + docsUrl: "https://docs.exa.ai/reference/exa-mcp", + connected: false, + }, + ], + }); + window.history.replaceState(null, "", "/plugins?connect=mcp:exa"); + + const el = await mount(); + + expect(document.querySelector('[role="dialog"]')).toBeNull(); + const connectButton = el.querySelector( + '[data-plugin-slug="exa"] button[aria-label="Connect Exa"]', + ); + expect(connectButton).not.toBeNull(); + expect(document.activeElement).toBe(connectButton); + expect(window.location.search).toBe(""); + }); + + // CL-7141: a preset slug the catalog doesn't recognize (typo, stale + // link) is ignored — no dialog, no notice, no thrown error. + test("a `?connect=mcp:` URL naming an unknown preset is ignored", async () => { + stubFetch(); + window.history.replaceState(null, "", "/plugins?connect=mcp:bogus"); + + const el = await mount(); + + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(el.textContent).not.toContain( + "Couldn't find that connection — pick it below.", + ); + expect(window.location.search).toBe(""); + }); }); From ff589e98fe8f89a168b161f9293ad2135c6dcc0a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 08:53:58 -0700 Subject: [PATCH 4/4] Resolve mcp: preset deep links and stop dropping query params The Plugins page's connect-param handler only checked CONNECTOR_REGISTRY, so request_connection's fallback link for a curated MCP preset (mcp:, no fixed registry id) never matched anything. It also replaced the URL with window.location.pathname, dropping every query param instead of just the one it consumed. The page now resolves an mcp: id against the preset catalog and focuses that preset's own connect button once it has loaded, and strips only the connect param, keeping the rest of the query string intact. Fixes CL-7141. --- apps/web/src/pages/plugins-page.tsx | 44 ++++++++++++++++---- packages/plugins-ui/src/mcp-preset-cards.tsx | 20 +++++++++ packages/plugins-ui/src/plugins-gallery.tsx | 16 ++++++- 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/apps/web/src/pages/plugins-page.tsx b/apps/web/src/pages/plugins-page.tsx index 51c4bcb59..d3f897abf 100644 --- a/apps/web/src/pages/plugins-page.tsx +++ b/apps/web/src/pages/plugins-page.tsx @@ -24,6 +24,7 @@ import { import type { ResolvedPlugin } from "@workbench/connections/plugins"; import { listPluginsForTenant } from "@workbench/connections/plugins"; import { CONNECTOR_REGISTRY } from "@workbench/connections/registry"; +import { MCP_PRESETS } from "@workbench/connections/mcp-presets"; import { Plus, SquaresFour, Warning } from "@corbits/icons"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -87,6 +88,12 @@ export function PluginsRoute({ // link silently no-oping, this renders a notice pointing back at the // gallery itself. const [connectDeepLinkNotFound, setConnectDeepLinkNotFound] = useState(false); + // A preset deep link's slug (CL-7141), passed to the MCP presets + // section so it can focus that preset's own card once its catalog + // has loaded — cleared as soon as the section has acted on it. + const [autoConnectPresetSlug, setAutoConnectPresetSlug] = useState< + string | null + >(null); const pendingConnectProvider = usePendingConnectProvider(); const clearPendingConnectProvider = useClearPendingConnectProvider(); const requestPluginsConnect = useRequestPluginsConnect(); @@ -180,16 +187,35 @@ export function PluginsRoute({ // `request_connection`'s fallback link (CL-7141): `/plugins?connect=` // hands the connector id off through the same `requestPluginsConnect` - // path the shell banner's "Fix it" click uses, then strips the param so - // a reload never replays a stale connect intent. An id this registry - // doesn't recognize (typo, stale link) is ignored rather than surfaced - // as a notice. + // path the shell banner's "Fix it" click uses, then strips only the + // `connect` param — any other query param this route is ever opened + // with (e.g. an in-flight `mcpOauth` return) must survive the rewrite. + // A curated MCP preset (Exa, Granola, Linear, ...) has no fixed + // `CONNECTOR_REGISTRY` id, so its own deep link is `mcp:` + // (`presetDeepLink` in `packages/connections-tools/src/tool.ts`) and + // is matched against the preset catalog instead. An id neither side + // recognizes (typo, stale link) is ignored rather than surfaced as a + // notice. useEffect(() => { - const connectId = new URLSearchParams(window.location.search).get( - "connect", - ); + const params = new URLSearchParams(window.location.search); + const connectId = params.get("connect"); if (connectId === null) return; - window.history.replaceState(null, "", window.location.pathname); + params.delete("connect"); + const rest = params.toString(); + window.history.replaceState( + null, + "", + rest === "" + ? window.location.pathname + : `${window.location.pathname}?${rest}`, + ); + if (connectId.startsWith("mcp:")) { + const slug = connectId.slice("mcp:".length); + if (MCP_PRESETS.some((preset) => preset.slug === slug)) { + setAutoConnectPresetSlug(slug); + } + return; + } if (CONNECTOR_REGISTRY[connectId] !== undefined) { requestPluginsConnect(connectId); } @@ -308,6 +334,8 @@ export function PluginsRoute({ activeTab={activeTab} onTabChange={setActiveTab} query={galleryQuery} + autoConnectPresetSlug={autoConnectPresetSlug} + onAutoConnectPresetHandled={() => setAutoConnectPresetSlug(null)} /> ` deep link + * (CL-7141) — once the catalog has loaded, that preset's row gets + * focused so a person lands on the right card without hunting for it. + * Never auto-fires the connect action itself: connecting still takes + * a person's own click, the same as every other card here. */ + readonly autoConnectSlug?: string | null; + readonly onAutoConnectHandled?: () => void; }) { const [presets, setPresets] = useState([]); const [toolCounts, setToolCounts] = useState>( @@ -265,6 +274,17 @@ export function McpPresetCardsSection({ reload(); }, [tenantId]); + useEffect(() => { + if (!loaded || autoConnectSlug === null) return; + if (presets.some((preset) => preset.slug === autoConnectSlug)) { + const row = document.querySelector( + `[data-plugin-slug="${autoConnectSlug}"] button`, + ); + (row as HTMLButtonElement | null)?.focus(); + } + onAutoConnectHandled?.(); + }, [loaded, autoConnectSlug, presets, onAutoConnectHandled]); + const needle = query.trim().toLowerCase(); const visiblePresets = presets.filter( (preset) => diff --git a/packages/plugins-ui/src/plugins-gallery.tsx b/packages/plugins-ui/src/plugins-gallery.tsx index 1650f80ec..cbd3e5d35 100644 --- a/packages/plugins-ui/src/plugins-gallery.tsx +++ b/packages/plugins-ui/src/plugins-gallery.tsx @@ -190,6 +190,8 @@ export function PluginsGallery({ activeTab, onTabChange, query, + autoConnectPresetSlug = null, + onAutoConnectPresetHandled, }: { readonly tenantId: string; readonly plugins: readonly ResolvedPlugin[]; @@ -199,6 +201,11 @@ export function PluginsGallery({ readonly activeTab: PluginsGalleryTab; readonly onTabChange: (tab: PluginsGalleryTab) => void; readonly query: string; + /** A curated MCP preset's slug named by a `/plugins?connect=mcp:` + * deep link (CL-7141) — passed through to the presets section so it can + * focus that preset's own card once its catalog has loaded. */ + readonly autoConnectPresetSlug?: string | null; + readonly onAutoConnectPresetHandled?: () => void; }) { // An inference-provider connector names no tool package it feeds // (`feedsTools: []`) — providers live only in Shared Settings' @@ -237,7 +244,14 @@ export function PluginsGallery({ {active === "plugins" ? ( <>

Plugins

- +