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
48 changes: 48 additions & 0 deletions apps/web/src/pages/plugins-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -175,6 +185,42 @@ export function PluginsRoute({
openPluginPanel,
]);

// `request_connection`'s fallback link (CL-7141): `/plugins?connect=<id>`
// 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:<slug>`
// (`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)}`);
}
Expand Down Expand Up @@ -288,6 +334,8 @@ export function PluginsRoute({
activeTab={activeTab}
onTabChange={setActiveTab}
query={galleryQuery}
autoConnectPresetSlug={autoConnectPresetSlug}
onAutoConnectPresetHandled={() => setAutoConnectPresetSlug(null)}
/>
</PageShell>
<PluginConnectPanel
Expand Down
98 changes: 97 additions & 1 deletion apps/web/test/plugins-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ afterEach(() => {
container.remove();
container = null;
}
window.history.replaceState(null, "", "/plugins");
});

const membership = {
Expand Down Expand Up @@ -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<string, unknown>[] } = {},
): 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"))
Expand Down Expand Up @@ -660,4 +666,94 @@ describe("PluginsRoute", () => {

expect(el.textContent).not.toContain("Connected here");
});

// CL-7141: `request_connection`'s fallback link (`/plugins?connect=<id>`)
// hands off through the same `requestPluginsConnect` path the shell
// banner's "Fix it" click uses.
test("a `?connect=<id>` 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=<id>` 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=<id>` strips only the `connect` param — any other
// query param this route was opened with must survive.
test("a `?connect=<id>` 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:<slug>` 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:<slug>` id).
test("a `?connect=mcp:<slug>` 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:<slug>` 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("");
});
});
2 changes: 1 addition & 1 deletion packages/connections-tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
5 changes: 5 additions & 0 deletions packages/connections-tools/src/tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
37 changes: 16 additions & 21 deletions packages/connections-tools/src/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// `@corbits/mcp-tools`' own `mcp_list_servers` route) — a tenant-minted
// `mcp:<slug>` 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`
Expand Down Expand Up @@ -80,27 +80,24 @@ function clientConfig(env: WorkflowConnectionEnv) {
};
}

/** `/plugins?connect=<connectorId>` — 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=<connectorId>` — 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:<slug>` — a curated preset's own card
* (CL-6152). Presets are still tenant-minted `mcp:<slug>` connections
Expand Down Expand Up @@ -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}.`,
};
}

Expand Down
20 changes: 20 additions & 0 deletions packages/plugins-ui/src/mcp-preset-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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:<slug>` 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<readonly McpPreset[]>([]);
const [toolCounts, setToolCounts] = useState<ReadonlyMap<string, number>>(
Expand Down Expand Up @@ -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) =>
Expand Down
16 changes: 15 additions & 1 deletion packages/plugins-ui/src/plugins-gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ export function PluginsGallery({
activeTab,
onTabChange,
query,
autoConnectPresetSlug = null,
onAutoConnectPresetHandled,
}: {
readonly tenantId: string;
readonly plugins: readonly ResolvedPlugin[];
Expand All @@ -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:<slug>`
* 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'
Expand Down Expand Up @@ -237,7 +244,14 @@ export function PluginsGallery({
{active === "plugins" ? (
<>
<h1 className="text-xl font-extrabold">Plugins</h1>
<McpPresetCardsSection tenantId={tenantId} query={query} />
<McpPresetCardsSection
tenantId={tenantId}
query={query}
autoConnectSlug={autoConnectPresetSlug}
{...(onAutoConnectPresetHandled !== undefined
? { onAutoConnectHandled: onAutoConnectPresetHandled }
: {})}
/>
<McpServersSection tenantId={tenantId} />
<PluginsTabPanel
plugins={installablePlugins}
Expand Down
2 changes: 1 addition & 1 deletion workflows/assistant/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Loading