diff --git a/apps/web/src/pages/plugins-page.tsx b/apps/web/src/pages/plugins-page.tsx index 31979f871..d3f897abf 100644 --- a/apps/web/src/pages/plugins-page.tsx +++ b/apps/web/src/pages/plugins-page.tsx @@ -23,6 +23,8 @@ 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 { MCP_PRESETS } from "@workbench/connections/mcp-presets"; import { Plus, SquaresFour, Warning } from "@corbits/icons"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -31,6 +33,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 { @@ -85,8 +88,15 @@ 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(); const openPluginPanel = useCallback((plugin: ResolvedPlugin) => { setOpenPlugin(plugin); setConnectDeepLinkNotFound(false); @@ -175,6 +185,42 @@ 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 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 params = new URLSearchParams(window.location.search); + const connectId = params.get("connect"); + if (connectId === null) return; + 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); + } + }, [requestPluginsConnect]); + function openSkill(name: string) { navigate(`${SKILLS_PATH_PREFIX}/${encodeURIComponent(name)}`); } @@ -288,6 +334,8 @@ export function PluginsRoute({ activeTab={activeTab} onTabChange={setActiveTab} query={galleryQuery} + autoConnectPresetSlug={autoConnectPresetSlug} + onAutoConnectPresetHandled={() => setAutoConnectPresetSlug(null)} /> { container.remove(); container = null; } + window.history.replaceState(null, "", "/plugins"); }); const membership = { @@ -84,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")) @@ -660,4 +666,94 @@ 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(""); + }); + + // 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(""); + }); }); 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.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; } 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/packages/plugins-ui/src/mcp-preset-cards.tsx b/packages/plugins-ui/src/mcp-preset-cards.tsx index 80e47b84f..7433267e3 100644 --- a/packages/plugins-ui/src/mcp-preset-cards.tsx +++ b/packages/plugins-ui/src/mcp-preset-cards.tsx @@ -233,9 +233,18 @@ function McpPresetCard({ export function McpPresetCardsSection({ tenantId, query = "", + autoConnectSlug = null, + onAutoConnectHandled, }: { readonly tenantId: string; readonly query?: string; + /** A preset slug named by a `/plugins?connect=mcp:` 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

- +