diff --git a/packages/chat-ui/src/strings.ts b/packages/chat-ui/src/strings.ts index c2b3419b2..9fd8e26e1 100644 --- a/packages/chat-ui/src/strings.ts +++ b/packages/chat-ui/src/strings.ts @@ -293,7 +293,6 @@ export const CHAT_STRINGS = { workbenchSettingsSectionGeneral: "General", workbenchSettingsSectionMembers: "Members", workbenchSettingsSectionAgents: "Agents", - workbenchSettingsSectionPlugins: "Plugins", workbenchSettingsSectionCapacity: "Capacity", workbenchSettingsSectionNotifications: "Notifications", workbenchSettingsSectionDanger: "Danger zone", diff --git a/packages/chat-ui/src/workbench-settings/mcp-servers-api.test.ts b/packages/chat-ui/src/workbench-settings/mcp-servers-api.test.ts deleted file mode 100644 index fc39ef7f3..000000000 --- a/packages/chat-ui/src/workbench-settings/mcp-servers-api.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { - McpServersApiError, - connectMcpServer, - listMcpServers, - mcpOAuthStartPath, - mcpOAuthStartPathForServer, -} from "./mcp-servers-api"; - -const originalFetch = globalThis.fetch; - -afterEach(() => { - globalThis.fetch = originalFetch; -}); - -function stubFetch(response: Response) { - globalThis.fetch = (() => - Promise.resolve(response)) as unknown as typeof fetch; -} - -describe("mcpOAuthStartPath", () => { - test("names a curated preset's fixed slug with no query string", () => { - expect(mcpOAuthStartPath("t1", "granola")).toBe( - "/api/tenants/t1/mcp-servers/oauth/granola/start", - ); - }); - - test("carries the full ad hoc url and name as query params", () => { - const path = mcpOAuthStartPath("t1", "acme", { - name: "Acme", - url: "https://acme.example.com/mcp", - }); - expect(path).toBe( - "/api/tenants/t1/mcp-servers/oauth/acme/start?url=https%3A%2F%2Facme.example.com%2Fmcp&name=Acme", - ); - }); -}); - -describe("mcpOAuthStartPathForServer", () => { - test("derives a path-safe slug from a hand-typed name", () => { - const path = mcpOAuthStartPathForServer( - "t1", - "Acme Corp!", - "https://acme.example.com/mcp", - ); - expect(path).toContain("/oauth/acme-corp/start?"); - expect(path).toContain("name=Acme+Corp%21"); - }); -}); - -describe("listMcpServers", () => { - test("resolves the connected server list", async () => { - stubFetch( - Response.json({ - data: [ - { slug: "acme", name: "Acme", url: "https://acme.example.com/mcp" }, - ], - }), - ); - const servers = await listMcpServers("t1"); - expect(servers).toEqual([ - { slug: "acme", name: "Acme", url: "https://acme.example.com/mcp" }, - ]); - }); - - test("surfaces an honest error message and code from the envelope", async () => { - stubFetch( - new Response( - JSON.stringify({ error: { message: "nope", code: "forbidden" } }), - { status: 403 }, - ), - ); - await expect(listMcpServers("t1")).rejects.toMatchObject({ - message: "nope", - code: "forbidden", - }); - }); -}); - -describe("connectMcpServer", () => { - test("returns the connected server on success", async () => { - stubFetch( - Response.json({ - slug: "acme", - name: "Acme", - url: "https://acme.example.com/mcp", - toolCount: 3, - }), - ); - const result = await connectMcpServer("t1", { - name: "Acme", - url: "https://acme.example.com/mcp", - token: undefined, - }); - expect(result.toolCount).toBe(3); - }); - - test("a probe failure surfaces the server's honest message, not a fake success", async () => { - stubFetch( - new Response( - JSON.stringify({ - error: { - message: "Could not connect to that MCP server.", - code: "connect_failed", - }, - }), - { status: 422 }, - ), - ); - await expect( - connectMcpServer("t1", { - name: "Bad", - url: "https://not-mcp.example.com", - token: undefined, - }), - ).rejects.toBeInstanceOf(McpServersApiError); - }); - - test("an OAuth-gated server surfaces the oauth_required code so the caller can redirect", async () => { - stubFetch( - new Response( - JSON.stringify({ - error: { message: "requires OAuth", code: "oauth_required" }, - }), - { status: 422 }, - ), - ); - try { - await connectMcpServer("t1", { - name: "Gated", - url: "https://gated.example.com/mcp", - token: undefined, - }); - throw new Error("expected connectMcpServer to reject"); - } catch (cause) { - expect(cause).toBeInstanceOf(McpServersApiError); - expect((cause as McpServersApiError).code).toBe("oauth_required"); - } - }); -}); diff --git a/packages/chat-ui/src/workbench-settings/mcp-servers-api.ts b/packages/chat-ui/src/workbench-settings/mcp-servers-api.ts deleted file mode 100644 index 31a9e904d..000000000 --- a/packages/chat-ui/src/workbench-settings/mcp-servers-api.ts +++ /dev/null @@ -1,226 +0,0 @@ -// The plugins directory's seam onto `@workbench/connections`' tenant-scoped -// MCP server routes (CL-6142/CL-6152/CL-6261): drop a full endpoint URL in -// and, once the server-side probe (`mcp-probe.ts`, via -// `mcp-server-routes.ts`) proves it's a real MCP server, it becomes a -// first-class connection — same shape whether it came from a hand-typed -// URL, a curated preset (`mcp-presets.ts`), or an OAuth+DCR round trip -// (`mcp-oauth-routes.ts`). `@corbits/plugins-ui` has its own copy of this -// client (`mcp-servers-api.ts`) against the exact same routes — chat-ui -// cannot import that package (settings-ui/plugins-ui depend on chat-ui, -// not the other way around), so this is its own small client, matching -// `./plugins-api.ts`'s own header comment on why that duplication exists. - -import { type } from "arktype"; - -export class McpServersApiError extends Error { - constructor( - message: string, - readonly status?: number, - readonly code?: string, - ) { - super(message); - } -} - -export type McpServer = { - readonly slug: string; - readonly name: string; - readonly url: string; -}; - -export type McpServerConnected = McpServer & { - readonly toolCount: number; -}; - -export type McpPresetRow = { - readonly slug: string; - readonly displayName: string; - readonly description: string; - readonly url: string; - readonly connectionMode: "oauth" | "keyless"; - readonly docsUrl: string; - readonly icon?: { readonly path: string; readonly hex: string }; - readonly connected: boolean; -}; - -const McpServerSchema = type({ - slug: "string", - name: "string", - url: "string", -}); - -const ListResult = type({ data: McpServerSchema.array() }); - -const ConnectResult = type({ - slug: "string", - name: "string", - url: "string", - toolCount: "number", -}); - -const McpPresetSchema = type({ - slug: "string", - displayName: "string", - description: "string", - url: "string", - connectionMode: "'oauth' | 'keyless'", - docsUrl: "string", - "icon?": { path: "string", hex: "string" }, - connected: "boolean", -}); - -const ListPresetsResult = type({ data: McpPresetSchema.array() }); - -const ErrorEnvelope = type({ - error: { message: "string", "code?": "string" }, -}); - -function mcpServersPath(tenantId: string): string { - return `/api/tenants/${tenantId}/mcp-servers`; -} - -/** A path-safe stand-in for a not-yet-connected ad hoc server's slug — the - * OAuth start route only uses this to name its state cookie and, when the - * authorization server never provides a nicer name, is re-derived from the - * real display name at connect time anyway (`mcp-oauth-routes.ts`'s own - * callback), so it never has to be the server's final slug. */ -function slugForOAuthStart(name: string): string { - const base = name - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - return base.length > 0 ? base : "server"; -} - -/** The OAuth+DCR connect flow's entry point — a curated preset's fixed - * slug, or an ad hoc `name`+`url` supplied by an authorized caller. - * Navigating the browser here - * (never a fetch) is deliberate: this redirects off-site to the server's - * own authorization page. */ -export function mcpOAuthStartPath( - tenantId: string, - slug: string, - adHoc?: { readonly name: string; readonly url: string }, -): string { - const base = `${mcpServersPath(tenantId)}/oauth/${encodeURIComponent(slug)}/start`; - if (adHoc === undefined) return base; - const query = new URLSearchParams({ url: adHoc.url, name: adHoc.name }); - return `${base}?${query.toString()}`; -} - -export function mcpOAuthStartPathForServer( - tenantId: string, - name: string, - url: string, -): string { - return mcpOAuthStartPath(tenantId, slugForOAuthStart(name), { name, url }); -} - -async function readError( - response: Response, - verb: string, -): Promise<{ readonly message: string; readonly code?: string }> { - const body: unknown = await response.json().catch(() => undefined); - const envelope = ErrorEnvelope(body); - if (envelope instanceof type.errors) { - return { - message: `The server answered ${response.status} while ${verb}.`, - }; - } - return envelope.error.code === undefined - ? { message: envelope.error.message } - : { message: envelope.error.message, code: envelope.error.code }; -} - -async function throwFor(response: Response, verb: string): Promise { - const { message, code } = await readError(response, verb); - throw new McpServersApiError(message, response.status, code); -} - -export async function listMcpServers( - tenantId: string, -): Promise { - const response = await fetch(mcpServersPath(tenantId)); - if (!response.ok) await throwFor(response, "loading MCP servers"); - const body: unknown = await response.json().catch(() => undefined); - const parsed = ListResult(body); - if (parsed instanceof type.errors) { - throw new McpServersApiError( - `Unexpected response shape while loading MCP servers: ${parsed.summary}`, - ); - } - return parsed.data; -} - -export async function listMcpPresets( - tenantId: string, -): Promise { - const response = await fetch(`${mcpServersPath(tenantId)}/presets`); - if (!response.ok) await throwFor(response, "loading MCP server presets"); - const body: unknown = await response.json().catch(() => undefined); - const parsed = ListPresetsResult(body); - if (parsed instanceof type.errors) { - throw new McpServersApiError( - `Unexpected response shape while loading MCP server presets: ${parsed.summary}`, - ); - } - return parsed.data; -} - -export async function connectMcpServer( - tenantId: string, - input: { - readonly name: string; - readonly url: string; - readonly token: string | undefined; - }, -): Promise { - const response = await fetch(mcpServersPath(tenantId), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(input), - }); - if (!response.ok) await throwFor(response, "connecting that MCP server"); - const body: unknown = await response.json().catch(() => undefined); - const parsed = ConnectResult(body); - if (parsed instanceof type.errors) { - throw new McpServersApiError( - `Unexpected response shape while connecting that MCP server: ${parsed.summary}`, - ); - } - return parsed; -} - -export async function connectMcpPreset( - tenantId: string, - presetSlug: string, - token: string | undefined, -): Promise { - const response = await fetch(mcpServersPath(tenantId), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ presetSlug, token }), - }); - if (!response.ok) await throwFor(response, "connecting that MCP server"); - const body: unknown = await response.json().catch(() => undefined); - const parsed = ConnectResult(body); - if (parsed instanceof type.errors) { - throw new McpServersApiError( - `Unexpected response shape while connecting that MCP server: ${parsed.summary}`, - ); - } - return parsed; -} - -export async function disconnectMcpServer( - tenantId: string, - slug: string, -): Promise { - const response = await fetch( - `${mcpServersPath(tenantId)}/${encodeURIComponent(slug)}`, - { method: "DELETE" }, - ); - if (!response.ok && response.status !== 204) { - await throwFor(response, "disconnecting that MCP server"); - } -} diff --git a/packages/chat-ui/src/workbench-settings/model.test.ts b/packages/chat-ui/src/workbench-settings/model.test.ts index 0404973bf..366130ec6 100644 --- a/packages/chat-ui/src/workbench-settings/model.test.ts +++ b/packages/chat-ui/src/workbench-settings/model.test.ts @@ -20,12 +20,11 @@ describe("isWorkbenchSettingsSectionId", () => { }); describe("workbenchSettingsSections", () => { - test("workbenches expose the full settings surface: General/Agents/Plugins in Shared, Notifications in Personal", () => { + test("workbenches expose the full settings surface: General/Agents in Shared, Notifications in Personal", () => { expect(workbenchSettingsSections("workbench").map((s) => s.id)).toEqual([ "general", "members", "agents", - "plugins", "notifications", "danger", ]); @@ -35,7 +34,6 @@ describe("workbenchSettingsSections", () => { expect(workbenchSettingsSections("chat").map((s) => s.id)).toEqual([ "general", "agents", - "plugins", "notifications", ]); }); @@ -43,7 +41,6 @@ describe("workbenchSettingsSections", () => { test("a DM chat additionally trims Agents — no agent participant, nothing to invite", () => { expect(workbenchSettingsSections("chat", true).map((s) => s.id)).toEqual([ "general", - "plugins", "notifications", ]); }); @@ -52,7 +49,6 @@ describe("workbenchSettingsSections", () => { expect(workbenchSettingsSections("chat", false).map((s) => s.id)).toEqual([ "general", "agents", - "plugins", "notifications", ]); }); @@ -60,14 +56,7 @@ describe("workbenchSettingsSections", () => { test("isDm is ignored for a workbench — Agents stays regardless", () => { expect( workbenchSettingsSections("workbench", true).map((s) => s.id), - ).toEqual([ - "general", - "members", - "agents", - "plugins", - "notifications", - "danger", - ]); + ).toEqual(["general", "members", "agents", "notifications", "danger"]); }); test("Myra/Keys & plugins/Inference are gone as distinct nav ids", () => { @@ -77,6 +66,15 @@ describe("workbenchSettingsSections", () => { expect(ids).not.toContain("inference"); }); + test("Plugins is global-only now — no workbench-scoped nav id", () => { + expect( + workbenchSettingsSections("workbench").map((s) => s.id), + ).not.toContain("plugins"); + expect( + workbenchSettingsSections("chat", true).map((s) => s.id), + ).not.toContain("plugins"); + }); + test("Capacity is absent by default — this server has no isolated capacity to offer", () => { expect(workbenchSettingsSections("chat").map((s) => s.id)).not.toContain( "capacity", @@ -93,29 +91,18 @@ describe("workbenchSettingsSections", () => { "general", "members", "agents", - "plugins", "notifications", "capacity", "danger", ]); }); - test("Plugins is always present, regardless of workbench kind", () => { - expect(workbenchSettingsSections("workbench").map((s) => s.id)).toContain( - "plugins", - ); - expect(workbenchSettingsSections("chat", true).map((s) => s.id)).toContain( - "plugins", - ); - }); - test("groups sections Shared / Personal / Danger for the nav", () => { const groups = workbenchSettingsSections("workbench").map((s) => s.group); expect(groups).toEqual([ "shared", "shared", "shared", - "shared", "personal", "danger", ]); diff --git a/packages/chat-ui/src/workbench-settings/model.ts b/packages/chat-ui/src/workbench-settings/model.ts index 5240feac9..6b5be9890 100644 --- a/packages/chat-ui/src/workbench-settings/model.ts +++ b/packages/chat-ui/src/workbench-settings/model.ts @@ -6,36 +6,27 @@ // capabilities, and history are edited — a click-through master-detail, // not a separate "Myra" nav item duplicating the same editor for one // hardcoded agent. Keys & plugins and Inference are gone as distinct -// sections: plugin/tool connections move to `plugins` (workbench-scoped, -// connections only — no inference-provider keys, which live in Shared -// Settings now); inference provider+model assignment moves onto the -// Agents detail view, per agent, fed from the tenant-wide provider pool. +// sections: inference provider+model assignment moves onto the Agents +// detail view, per agent, fed from the tenant-wide provider pool. +// +// Plugins are global-only for now (owner ruling): the workbench-scoped +// `plugins` section that used to live here is removed — connect/manage +// plugins from the bench-level Plugins page instead. Per-workbench +// credential rows written by that old section are left in place (nothing +// reads or writes them now); see the Plugins page for the surviving +// surface. import { CHAT_STRINGS } from "../strings"; export type WorkbenchSettingsSectionId = - | "general" - | "members" - | "agents" - | "plugins" - | "capacity" - | "notifications" - | "danger"; + "general" | "members" | "agents" | "capacity" | "notifications" | "danger"; /** Every `WorkbenchSettingsSectionId`, for validating a section id read * off a URL — a route parser needs this list to narrow untrusted input * without an unchecked cast; `sectionsForWorkbenchKind` below is a * per-kind subset that doesn't fit that job. */ export const WORKBENCH_SETTINGS_SECTION_IDS: readonly WorkbenchSettingsSectionId[] = - [ - "general", - "members", - "agents", - "plugins", - "capacity", - "notifications", - "danger", - ]; + ["general", "members", "agents", "capacity", "notifications", "danger"]; export function isWorkbenchSettingsSectionId( value: string, @@ -97,18 +88,11 @@ export function workbenchSettingsSections( group: "shared", }); } - sections.push( - { - id: "plugins", - label: CHAT_STRINGS.workbenchSettingsSectionPlugins, - group: "shared", - }, - { - id: "notifications", - label: CHAT_STRINGS.workbenchSettingsSectionNotifications, - group: "personal", - }, - ); + sections.push({ + id: "notifications", + label: CHAT_STRINGS.workbenchSettingsSectionNotifications, + group: "personal", + }); if (hasCapacity) { sections.push({ id: "capacity", diff --git a/packages/chat-ui/src/workbench-settings/plugins-api.ts b/packages/chat-ui/src/workbench-settings/plugins-api.ts deleted file mode 100644 index a7ac1b4ca..000000000 --- a/packages/chat-ui/src/workbench-settings/plugins-api.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Plugins section seam onto the connector-registry routes -// (`/api/tenants/:tenantId/connections/:connectorId/*`, -// `@workbench/connections`'s route factory) and the native credential -// delete route. Same shape as `@corbits/settings-ui`'s own -// connections-api.ts — chat-ui cannot import that package (settings-ui -// depends on chat-ui, not the other way around), so this is its own -// small client against the same routes rather than a shared one. - -import { type } from "arktype"; -import { UnauthenticatedError } from "@corbits/api-query"; - -export class PluginsApiError extends Error { - constructor( - message: string, - readonly status?: number, - ) { - super(message); - } -} - -const CompleteResult = type({ - credentialId: "string", - status: "'active'", - "modelGuidance?": "string", -}); - -async function request( - path: string, - schema: (data: unknown) => T | type.errors, - verb: string, - init?: RequestInit, -): Promise { - let response: Response; - try { - response = await fetch(path, { - ...init, - headers: { "content-type": "application/json", ...init?.headers }, - }); - } catch (cause) { - throw new PluginsApiError( - cause instanceof Error ? cause.message : String(cause), - ); - } - if (response.status === 401) { - throw new UnauthenticatedError(); - } - if (!response.ok) { - const body: unknown = await response.json().catch(() => undefined); - const envelope = type({ error: { message: "string" } })(body); - throw new PluginsApiError( - envelope instanceof type.errors - ? `The server answered ${response.status} while ${verb}.` - : envelope.error.message, - response.status, - ); - } - if (response.status === 204) return undefined as T; - const body: unknown = await response.json().catch(() => undefined); - const parsed = schema(body); - if (parsed instanceof type.errors) { - throw new PluginsApiError( - `Unexpected response shape while ${verb}: ${parsed.summary}`, - ); - } - return parsed; -} - -/** - * The one connect action (CL-6377): the server proves the pasted key - * against the connector's own probe and only stores it once that probe - * accepts — there is no separate client-driven "test" round-trip before - * this call. A rejected probe 422s with the probe's own message, which - * throws `PluginsApiError` (status 422); the caller renders that inline - * as the normal connect-failed state. - */ -export function completeConnectorCredential( - tenantId: string, - connectorId: string, - apiKey: string, -): Promise<{ - credentialId: string; - status: "active"; - modelGuidance?: string; -}> { - return request( - `/api/tenants/${tenantId}/connections/${connectorId}/complete`, - CompleteResult, - "saving that connection", - { method: "POST", body: JSON.stringify({ apiKey }) }, - ); -} - -export function removeWorkbenchCredential( - tenantId: string, - credentialId: string, -): Promise { - return request( - `/api/tenants/${tenantId}/credentials/${credentialId}`, - (data) => data as void, - "removing that connection", - { method: "DELETE" }, - ); -} diff --git a/packages/chat-ui/src/workbench-settings/plugins-section.test.ts b/packages/chat-ui/src/workbench-settings/plugins-section.test.ts deleted file mode 100644 index e62e30b27..000000000 --- a/packages/chat-ui/src/workbench-settings/plugins-section.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -// CL-6215: the Plugins section is a marketplace-style directory — every -// registered tool connector, active (connected here or inherited) first, -// available below — rather than a grid of status cards. These are the pure -// pieces the directory's render depends on: which group a resolved plugin -// falls into, and which of it matches a search query. - -import { describe, expect, test } from "bun:test"; - -import type { ResolvedPlugin } from "@workbench/connections/plugins"; -import { splitPluginDirectory } from "./plugins-section"; - -function plugin(overrides: Record = {}): ResolvedPlugin { - return { - descriptor: { - id: "anthropic", - displayName: "Anthropic", - feedsTools: ["@corbits/some-tools"], - }, - status: "not_connected", - provenance: null, - credentialId: null, - credentialName: null, - ...overrides, - } as unknown as ResolvedPlugin; -} - -describe("splitPluginDirectory", () => { - test("a connected-here plugin is active", () => { - const { active, available } = splitPluginDirectory([ - plugin({ status: "connected", provenance: "this-workbench" }), - ]); - expect(active).toHaveLength(1); - expect(available).toHaveLength(0); - }); - - test("an inherited plugin is active, not merely available", () => { - const { active, available } = splitPluginDirectory([ - plugin({ status: "connected", provenance: "inherited" }), - ]); - expect(active).toHaveLength(1); - expect(available).toHaveLength(0); - }); - - test("a needs-attention plugin stays active — it's a broken connection, not a bare listing", () => { - const { active, available } = splitPluginDirectory([ - plugin({ status: "needs_attention", provenance: "this-workbench" }), - ]); - expect(active).toHaveLength(1); - expect(available).toHaveLength(0); - }); - - test("nothing connected anywhere is available, not active", () => { - const { active, available } = splitPluginDirectory([ - plugin({ status: "not_connected", provenance: null }), - ]); - expect(active).toHaveLength(0); - expect(available).toHaveLength(1); - }); -}); diff --git a/packages/chat-ui/src/workbench-settings/plugins-section.tsx b/packages/chat-ui/src/workbench-settings/plugins-section.tsx deleted file mode 100644 index f87c263b9..000000000 --- a/packages/chat-ui/src/workbench-settings/plugins-section.tsx +++ /dev/null @@ -1,579 +0,0 @@ -// The workbench Plugins section (CL-6215, following CL-6099 workstream 1; -// extended by CL-6261/CL-6256): a marketplace-style directory, not a grid -// of cards — one row per registered TOOL/plugin connector (Granola, Exa, -// Linear, GitHub, ScrapeCreators, ...), with provenance ("Inherited" vs -// owned here) straight from `@workbench/connections/plugins`'s -// `listPluginsForTenant` — the same chain-aware resolver the global -// Connections settings section and the Plugins gallery both already read, -// so this view can never disagree with theirs about what "inherited" -// means. A connected-here plugin can be removed; an inherited one can be -// overridden by connecting this workbench's own key, which shadows the -// ancestor's from that point on. "Active" (connected here or inherited) -// lists first; "Available" (nothing connected anywhere) lists below — -// plugins can be added at any time, so the page always shows the full -// catalog, not just what's already wired up. -// -// `listPluginsForTenant`'s registry also carries the inference-provider -// connectors (Anthropic, OpenAI, Groq, Ollama, Opencode Zen, ...) — those -// now live only in Shared Settings' Connections section, never here. -// `feedsTools` is the one field that tells the two apart: every tool/plugin -// connector names at least one tool package it feeds; every inference -// provider names none (see `packages/connections/src/registry.ts`). -// -// CL-6261 adds "any MCP server, dynamically": a person can paste a full -// MCP endpoint URL and, once the server-side probe (`mcp-probe.ts`) proves -// it's real — detecting either OAuth+DCR support or plain API-key/open -// access — it becomes a row here exactly like any curated connector. A -// curated one-click MCP preset (Granola, Exa, Linear, Notion, Sentry, -// Attio, Railway, PostHog, Sumble — -// `mcp-presets.ts`) and an already-connected custom server both resolve through the same -// `mcp-server-routes.ts` store; `directoryEntryFromMcpPreset` and -// `directoryEntryFromMcpServer` below only adapt each into the one -// `DirectoryEntry` shape this page already renders everything through — -// there is no second row-rendering path. Catalog entries without a verified -// one-click authorization path are intentionally omitted. -import { - Button, - ConfirmButton, - Dialog, - DialogBody, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - Input, - toast, -} from "@corbits/react-ui"; -import { - listPluginsForTenant, - type ResolvedPlugin, -} from "@workbench/connections/plugins"; -import { MCP_PRESET_CONNECTOR_IDS } from "@workbench/connections/mcp-presets"; -import { useEffect, useState } from "react"; - -import type { APIQuery } from "@corbits/api-query"; -import { - QueryView, - UnauthenticatedError, - describeQueryError, -} from "@corbits/api-query"; -import { - completeConnectorCredential, - PluginsApiError, - removeWorkbenchCredential, -} from "./plugins-api"; -import { - connectMcpPreset, - disconnectMcpServer, - listMcpPresets, - listMcpServers, - mcpOAuthStartPath, - type McpPresetRow, - type McpServer, -} from "./mcp-servers-api"; - -function errorMessage(cause: unknown, fallback: string): string { - return cause instanceof PluginsApiError ? cause.message : fallback; -} - -function isToolConnector(plugin: ResolvedPlugin): boolean { - return plugin.descriptor.feedsTools.length > 0; -} - -function isConnectedLegacyPlugin(plugin: ResolvedPlugin): boolean { - return plugin.status !== "not_connected"; -} - -/** Whether a plugin has anything to remove or override — a connected-here - * credential can be removed outright; everything else (inherited, broken, - * or never connected) only ever gets a Connect/Override action. */ -function ownedHere(plugin: ResolvedPlugin): boolean { - return ( - plugin.status !== "not_connected" && plugin.provenance === "this-workbench" - ); -} - -/** Needs-attention is the one colored state on this page (owner rule: grey - * is for text/structure, orange is the only accent, and it only ever marks - * something to act on) — everything else, including ownership, reads as - * plain caption text. */ -function needsAttention(plugin: ResolvedPlugin): boolean { - return plugin.status === "needs_attention"; -} - -function isInherited(plugin: ResolvedPlugin): boolean { - return plugin.status !== "not_connected" && plugin.provenance === "inherited"; -} - -/** The one row shape this whole directory renders through, regardless of - * whether the entry came from the static connector registry, a curated MCP - * preset, or an already-connected custom MCP server — CL-6261's "dynamic - * and curated entries share the same row shape, - * no parallel path" rule, made concrete as a type every adapter below - * produces and `PluginRow` is the only thing that reads. */ -type DirectoryEntry = { - readonly key: string; - readonly displayName: string; - readonly description?: string; - readonly icon?: { readonly path: string; readonly hex: string }; - readonly status: "connected" | "needs_attention" | "not_connected"; - readonly inherited: boolean; - /** A connected-here entry that this page can remove outright — an - * inherited connector or a not-yet-connected preset never - * is. */ - readonly removable: boolean; - readonly connectLabel: string; - readonly onConnect: () => void; - readonly onRemove: () => void; -}; - -function directoryEntryFromResolvedPlugin( - plugin: ResolvedPlugin, - onConnect: (plugin: ResolvedPlugin) => void, - onRemove: (plugin: ResolvedPlugin) => void, -): DirectoryEntry { - return { - key: `connector:${plugin.descriptor.id}`, - displayName: plugin.descriptor.displayName, - ...(plugin.descriptor.description !== undefined - ? { description: plugin.descriptor.description } - : {}), - ...(plugin.descriptor.icon !== undefined - ? { icon: plugin.descriptor.icon } - : {}), - status: needsAttention(plugin) - ? "needs_attention" - : plugin.status === "not_connected" - ? "not_connected" - : "connected", - inherited: isInherited(plugin), - removable: ownedHere(plugin), - connectLabel: plugin.provenance === "inherited" ? "Override" : "Connect", - onConnect: () => onConnect(plugin), - onRemove: () => onRemove(plugin), - }; -} - -function directoryEntryFromMcpServer( - server: McpServer, - onRemove: (server: McpServer) => void, -): DirectoryEntry { - return { - key: `mcp-server:${server.slug}`, - displayName: server.name, - description: server.url, - status: "connected", - inherited: false, - removable: true, - connectLabel: "Connect", - onConnect: () => undefined, - onRemove: () => onRemove(server), - }; -} - -function directoryEntryFromMcpPreset( - preset: McpPresetRow, - onConnect: (preset: McpPresetRow) => void, - onRemove: (preset: McpPresetRow) => void, -): DirectoryEntry { - return { - key: `mcp-preset:${preset.slug}`, - displayName: preset.displayName, - description: preset.description, - ...(preset.icon !== undefined ? { icon: preset.icon } : {}), - status: preset.connected ? "connected" : "not_connected", - inherited: false, - removable: preset.connected, - connectLabel: "Connect", - onConnect: () => onConnect(preset), - onRemove: () => onRemove(preset), - }; -} - -/** Every directory entry, split into what's already active (connected here - * or inherited from an ancestor workbench) and what's merely available to - * add — the marketplace framing the owner asked for: plugins can be added - * at any time, so the catalog is always the whole list, not just what's - * wired up. Generic so the same split serves `ResolvedPlugin`s (existing - * tests) and the unified `DirectoryEntry` this page renders. */ -export function splitPluginDirectory( - plugins: readonly T[], -): { - readonly active: readonly T[]; - readonly available: readonly T[]; -} { - return { - active: plugins.filter((plugin) => plugin.status !== "not_connected"), - available: plugins.filter((plugin) => plugin.status === "not_connected"), - }; -} - -function matchesQuery(entry: DirectoryEntry, query: string): boolean { - if (query === "") return true; - const haystack = - `${entry.displayName} ${entry.description ?? ""}`.toLowerCase(); - return haystack.includes(query.toLowerCase()); -} - -function EntryLogo({ entry }: { readonly entry: DirectoryEntry }) { - if (entry.icon !== undefined) { - return ( - - ); - } - return ( - - ); -} - -function PluginRow({ entry }: { readonly entry: DirectoryEntry }) { - return ( -
- -
-
- {entry.displayName} - {entry.status === "needs_attention" ? ( - - Needs attention - - ) : entry.inherited ? ( - Inherited - ) : null} -
- {entry.description !== undefined ? ( -

{entry.description}

- ) : null} -
-
- {entry.removable ? ( - - Remove - - ) : ( - - )} -
-
- ); -} - -export function PluginsSection({ tenantId }: { readonly tenantId: string }) { - const [query, setQuery] = useState>({ - kind: "loading", - }); - const [mcpServers, setMcpServers] = useState([]); - const [mcpPresets, setMcpPresets] = useState([]); - const [rowError, setRowError] = useState(null); - const [connectTarget, setConnectTarget] = useState( - null, - ); - const [search, setSearch] = useState(""); - - function load() { - setQuery({ kind: "loading" }); - listPluginsForTenant(tenantId) - .then((plugins) => - setQuery({ kind: "ready", data: plugins.filter(isToolConnector) }), - ) - .catch((cause: unknown) => { - if (cause instanceof UnauthenticatedError) { - setQuery({ kind: "unauthenticated" }); - return; - } - setQuery({ - kind: "error", - message: describeQueryError(cause), - retry: load, - }); - }); - } - - function loadMcp() { - listMcpServers(tenantId) - .then(setMcpServers) - .catch(() => undefined); - listMcpPresets(tenantId) - .then(setMcpPresets) - .catch(() => undefined); - } - - useEffect(load, [tenantId]); - useEffect(loadMcp, [tenantId]); - - function handleRemove(plugin: ResolvedPlugin) { - if (plugin.credentialId === null) return; - setRowError(null); - removeWorkbenchCredential(tenantId, plugin.credentialId) - .then(() => { - load(); - toast(`Removed ${plugin.descriptor.displayName} from this workbench.`); - }) - .catch((cause: unknown) => - setRowError(errorMessage(cause, "Couldn't remove that connection.")), - ); - } - - function handleRemoveMcpServer(server: McpServer) { - setRowError(null); - disconnectMcpServer(tenantId, server.slug) - .then(() => { - loadMcp(); - toast(`${server.name} disconnected.`); - }) - .catch(() => setRowError("Couldn't disconnect — try again.")); - } - - function handleRemoveMcpPreset(preset: McpPresetRow) { - setRowError(null); - disconnectMcpServer(tenantId, preset.slug) - .then(() => { - loadMcp(); - toast(`${preset.displayName} disconnected.`); - }) - .catch(() => setRowError("Couldn't disconnect — try again.")); - } - - function handleConnectMcpPreset(preset: McpPresetRow) { - if (preset.connectionMode === "oauth") { - window.location.href = mcpOAuthStartPath(tenantId, preset.slug); - return; - } - setRowError(null); - connectMcpPreset(tenantId, preset.slug, undefined) - .then((result) => { - loadMcp(); - toast( - `Connected — ${result.toolCount} tool${result.toolCount === 1 ? "" : "s"} available.`, - ); - }) - .catch(() => setRowError("Couldn't connect — try again.")); - } - - return ( - - {(plugins) => { - const connectorEntries = plugins - .filter( - (plugin) => - !MCP_PRESET_CONNECTOR_IDS.includes(plugin.descriptor.id) && - isConnectedLegacyPlugin(plugin), - ) - .map((plugin) => - directoryEntryFromResolvedPlugin( - plugin, - setConnectTarget, - handleRemove, - ), - ); - const presetSlugs = new Set(mcpPresets.map((preset) => preset.slug)); - const mcpServerEntries = mcpServers - .filter((server) => !presetSlugs.has(server.slug)) - .map((server) => - directoryEntryFromMcpServer(server, handleRemoveMcpServer), - ); - const presetEntries = mcpPresets.map((preset) => - directoryEntryFromMcpPreset( - preset, - handleConnectMcpPreset, - handleRemoveMcpPreset, - ), - ); - const allEntries = [ - ...connectorEntries, - ...presetEntries, - ...mcpServerEntries, - ]; - const filtered = allEntries.filter((entry) => - matchesQuery(entry, search), - ); - const { active, available } = splitPluginDirectory(filtered); - return ( -
-

- Connections added here are used only by this workbench. Inference - provider keys live in Shared Settings, not here. -

- {rowError !== null ? ( -

- {rowError} -

- ) : null} -
- setSearch(event.target.value)} - aria-label="Search plugins" - /> -
- {active.length === 0 && available.length === 0 ? ( -

- No plugins match “{search}”. -

- ) : null} - {active.length > 0 ? ( -
-
Active
-
- {active.map((entry) => ( - - ))} -
-
- ) : null} - {available.length > 0 ? ( -
-
Available
-
- {available.map((entry) => ( - - ))} -
-
- ) : null} - - setConnectTarget(null)} - onConnected={() => { - setConnectTarget(null); - load(); - }} - /> -
- ); - }} -
- ); -} - -function ConnectDialog({ - tenantId, - plugin, - onClose, - onConnected, -}: { - readonly tenantId: string; - readonly plugin: ResolvedPlugin | null; - readonly onClose: () => void; - readonly onConnected: () => void; -}) { - const [apiKey, setApiKey] = useState(""); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - setApiKey(""); - setSubmitting(false); - setError(null); - }, [plugin]); - - // One connect action (CL-6377): the server proves the key before ever - // storing it, so this is the only round-trip — no separate test step. - function handleSubmit() { - if (plugin === null || apiKey.trim() === "") return; - setSubmitting(true); - setError(null); - completeConnectorCredential(tenantId, plugin.descriptor.id, apiKey) - .then((completed) => { - // CL-6351: a fresh Ollama connect with only an embedding model - // pulled still succeeds — `modelGuidance` says so instead of - // the generic "connected" toast. - toast( - completed.modelGuidance ?? - `Connected ${plugin.descriptor.displayName} for this workbench.`, - ); - onConnected(); - }) - .catch((cause: unknown) => - setError(errorMessage(cause, "Couldn't save that key.")), - ) - .finally(() => setSubmitting(false)); - } - - return ( - { - if (!next) onClose(); - }} - > - - - - {plugin === null - ? "" - : plugin.provenance === "inherited" - ? `Override ${plugin.descriptor.displayName} for this workbench` - : `Connect ${plugin.descriptor.displayName}`} - - - This key is only used for this workbench. - - - - - {error !== null ? ( -

- {error} -

- ) : null} -
- - - - -
-
- ); -} diff --git a/packages/chat-ui/src/workbench-settings/surface.tsx b/packages/chat-ui/src/workbench-settings/surface.tsx index 8854b2210..fb16c5ee6 100644 --- a/packages/chat-ui/src/workbench-settings/surface.tsx +++ b/packages/chat-ui/src/workbench-settings/surface.tsx @@ -36,7 +36,6 @@ import { import type { ContextWindowMode } from "./context-window"; import { DangerSection } from "./danger-section"; import { GeneralSection } from "./general-section"; -import { PluginsSection } from "./plugins-section"; import { MembersSection } from "./members-section"; import { workbenchSettingsSections } from "./model"; import type { @@ -365,10 +364,6 @@ export function WorkbenchSettingsSurface({ /> ) : null} - {activeSection.id === "plugins" ? ( - - ) : null} - {activeSection.id === "capacity" ? ( ) : null} diff --git a/packages/chat-ui/test/plugins-section.test.tsx b/packages/chat-ui/test/plugins-section.test.tsx deleted file mode 100644 index 6f96bdd89..000000000 --- a/packages/chat-ui/test/plugins-section.test.tsx +++ /dev/null @@ -1,366 +0,0 @@ -// CL-6215: the workbench Plugins section carries only tool/plugin -// connections — Granola, Exa, Linear, and other verified MCP presets — -// never the inference-provider connectors (Anthropic, OpenAI, Groq, -// Ollama, Opencode Zen, ...) that also live in -// `@workbench/connections`'s registry. Those now live only in Shared -// Settings' Connections section. Mounted through `WorkbenchSettingsSurface` -// itself, the same composition a person actually reaches — stubs -// `global.fetch` directly (every descriptor resolves via -// `GET /credentials/resolve/:name`), never `mock.module`. - -import { afterEach, describe, expect, test } from "bun:test"; -import { act, createElement } from "react"; -import { createRoot } from "react-dom/client"; -import type { Root } from "react-dom/client"; - -import { WorkbenchSettingsSurface } from "../src/workbench-settings"; - -const realFetch = globalThis.fetch; -afterEach(() => { - globalThis.fetch = realFetch; -}); - -const json = (body: unknown, status = 200) => - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); - -let container: HTMLDivElement | null = null; -let root: Root | null = null; - -function mount(props: Parameters[0]) { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - act(() => { - root?.render(createElement(WorkbenchSettingsSurface, props)); - }); - return container; -} - -afterEach(() => { - if (root !== null) { - act(() => root?.unmount()); - root = null; - } - if (container !== null) { - container.remove(); - container = null; - } -}); - -const settle = () => - act(() => new Promise((resolve) => setTimeout(resolve, 10))); - -function baseProps( - overrides: Partial[0]> = {}, -) { - return { - tenantId: "tnt_1", - workbenchId: "ch_1", - workbenchTitle: "Talk to Myra", - onBack: () => undefined, - onInviteParticipant: () => undefined, - section: "plugins" as const, - ...overrides, - }; -} - -const STUB_MCP_PRESETS = [ - { - slug: "granola", - displayName: "Granola", - description: "Pull your Granola meeting notes and transcripts — via MCP.", - url: "https://mcp.granola.ai/mcp", - connectionMode: "oauth", - docsUrl: "https://www.granola.ai", - connected: false, - }, - { - slug: "exa", - displayName: "Exa", - description: "Search the web (Exa) — no key needed.", - url: "https://mcp.exa.ai/mcp", - connectionMode: "keyless", - docsUrl: "https://exa.ai", - connected: false, - }, - { - slug: "linear", - displayName: "Linear", - description: "Manage Linear issues and projects — via MCP.", - url: "https://mcp.linear.app/mcp", - connectionMode: "oauth", - docsUrl: "https://linear.app", - connected: false, - }, - { - slug: "notion", - displayName: "Notion", - description: "Search and update pages, databases, and workspace content.", - url: "https://mcp.notion.com/mcp", - connectionMode: "oauth", - docsUrl: "https://developers.notion.com/guides/mcp/get-started-with-mcp", - connected: false, - }, - { - slug: "sentry", - displayName: "Sentry", - description: "Investigate errors, traces, releases, and projects.", - url: "https://mcp.sentry.dev/mcp", - connectionMode: "oauth", - docsUrl: "https://mcp.sentry.dev/", - connected: false, - }, - { - slug: "attio", - displayName: "Attio", - description: "Work with CRM records, lists, notes, and tasks.", - url: "https://mcp.attio.com/mcp", - connectionMode: "oauth", - docsUrl: "https://docs.attio.com/mcp/overview", - connected: false, - }, - { - slug: "railway", - displayName: "Railway", - description: "Inspect and manage projects, services, and deployments.", - url: "https://mcp.railway.com", - connectionMode: "oauth", - docsUrl: "https://docs.railway.com/ai/mcp-server", - connected: false, - }, - { - slug: "posthog", - displayName: "PostHog", - description: "Explore product analytics, errors, flags, and experiments.", - url: "https://mcp.posthog.com/mcp", - connectionMode: "oauth", - docsUrl: "https://posthog.com/docs/model-context-protocol", - connected: false, - }, - { - slug: "sumble", - displayName: "Sumble", - description: "Research accounts, people, technologies, and buying signals.", - url: "https://mcp.sumble.com/", - connectionMode: "oauth", - docsUrl: "https://sumble.com/guides/account-research", - connected: false, - }, -]; - -function stubFetch( - options: { - readonly inheritedConnectorId?: string; - readonly mcpServers?: readonly { - readonly slug: string; - readonly name: string; - readonly url: string; - }[]; - readonly mcpPresets?: typeof STUB_MCP_PRESETS; - readonly onConnect?: (body: unknown) => Response; - } = {}, -) { - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const path = typeof input === "string" ? input : String(input); - if (/\/chat\/workbenches\/[^/]+\/settings$/.test(path)) { - return json({ - id: "ch_1", - title: "Talk to Myra", - kind: "chat", - pinned: false, - participants: [], - settings: {}, - contextWindow: { value: 20, source: "inherit" }, - }); - } - if (/\/chat\/bench\/settings$/.test(path)) { - return json({ settings: {}, contextWindow: 20 }); - } - const resolveMatch = /\/credentials\/resolve\/([^/]+)$/.exec(path); - if (resolveMatch !== null) { - const name = decodeURIComponent(resolveMatch[1] as string); - // GitHub resolves from an ancestor tenant, never this one — the - // directory's "Inherited" caption case. Every other connector - // (tool and inference-provider alike) resolves "not connected" — - // this test only cares which descriptors ever reach the DOM and - // how the one inherited connection reads. - if ( - options.inheritedConnectorId !== undefined && - name === options.inheritedConnectorId - ) { - return json({ - id: "cred_1", - tenantId: "tnt_ancestor", - name, - status: "active", - }); - } - return json({}, 404); - } - if (/\/mcp-servers\/presets$/.test(path)) { - return json({ data: options.mcpPresets ?? STUB_MCP_PRESETS }); - } - if (/\/mcp-servers$/.test(path)) { - if (init?.method === "POST" && options.onConnect !== undefined) { - const body: unknown = - init.body !== undefined ? JSON.parse(String(init.body)) : undefined; - return options.onConnect(body); - } - return json({ data: options.mcpServers ?? [] }); - } - throw new Error(`unstubbed fetch: ${path}`); - }) as unknown as typeof fetch; -} - -describe("Plugins section", () => { - test("shows verified one-click tool connectors, never providers or API-key catalog entries", async () => { - stubFetch(); - const el = mount(baseProps()); - await settle(); - - const names = Array.from( - el.querySelectorAll(".plugins-directory-name"), - ).map((node) => node.textContent); - - expect(names).toContain("Granola"); - expect(names).toContain("Exa"); - expect(names).toContain("Linear"); - expect(names).not.toContain("GitHub"); - expect(names).not.toContain("ScrapeCreators"); - expect(names).not.toContain("Anthropic"); - expect(names).not.toContain("OpenAI"); - expect(names).not.toContain("Groq"); - expect(names).not.toContain("Ollama"); - }); - - test("everything not connected anywhere lists under Available with a quiet Connect action", async () => { - stubFetch(); - const el = mount(baseProps()); - await settle(); - - const groupLabels = Array.from( - el.querySelectorAll(".plugins-directory-group-label"), - ).map((node) => node.textContent); - expect(groupLabels).toEqual(["Available"]); - - const connectButtons = Array.from( - el.querySelectorAll(".plugins-directory-connect-action"), - ); - expect(connectButtons.length).toBeGreaterThan(0); - expect( - connectButtons.some((button) => button.textContent === "Connect"), - ).toBe(true); - expect( - connectButtons.every((button) => button.textContent === "Connect"), - ).toBe(true); - expect(el.querySelectorAll(".plugins-directory-remove-action").length).toBe( - 0, - ); - }); - - test("a connection inherited from an ancestor tenant lists as Active with an Inherited caption and an Override action", async () => { - stubFetch({ inheritedConnectorId: "GitHub" }); - const el = mount(baseProps()); - await settle(); - - const groupLabels = Array.from( - el.querySelectorAll(".plugins-directory-group-label"), - ).map((node) => node.textContent); - expect(groupLabels).toEqual(["Active", "Available"]); - - const activeGroup = el.querySelectorAll(".plugins-directory-group")[0]; - expect(activeGroup?.textContent).toContain("GitHub"); - expect( - activeGroup?.querySelector(".plugins-directory-ownership")?.textContent, - ).toBe("Inherited"); - expect( - activeGroup?.querySelector(".plugins-directory-connect-action") - ?.textContent, - ).toBe("Override"); - expect( - activeGroup?.querySelector(".plugins-directory-remove-action"), - ).toBeNull(); - }); - - test("search narrows the directory to matching plugins", async () => { - stubFetch(); - const el = mount(baseProps()); - await settle(); - - const search = el.querySelector( - ".plugins-directory-search", - ) as HTMLInputElement | null; - const setter = Object.getOwnPropertyDescriptor( - HTMLInputElement.prototype, - "value", - )?.set; - act(() => { - setter?.call(search, "linear"); - search?.dispatchEvent(new Event("input", { bubbles: true })); - }); - await settle(); - - const names = Array.from( - el.querySelectorAll(".plugins-directory-name"), - ).map((node) => node.textContent); - expect(names).toEqual(["Linear"]); - }); - - test("a dynamically added MCP server lists alongside the curated connectors, sharing the same row shape", async () => { - stubFetch({ - mcpServers: [ - { - slug: "acme", - name: "Acme Tools", - url: "https://acme.example.com/mcp", - }, - ], - }); - const el = mount(baseProps()); - await settle(); - - const names = Array.from( - el.querySelectorAll(".plugins-directory-name"), - ).map((node) => node.textContent); - expect(names).toContain("Acme Tools"); - - const groupLabels = Array.from( - el.querySelectorAll(".plugins-directory-group-label"), - ).map((node) => node.textContent); - expect(groupLabels).toEqual(["Active", "Available"]); - - const activeGroup = el.querySelectorAll(".plugins-directory-group")[0]; - expect(activeGroup?.textContent).toContain("Acme Tools"); - expect( - activeGroup?.querySelector(".plugins-directory-remove-action"), - ).not.toBeNull(); - }); - - test("omits every catalog entry that lacks verified one-click authorization", async () => { - stubFetch(); - const el = mount(baseProps()); - await settle(); - - const names = Array.from( - el.querySelectorAll(".plugins-directory-name"), - ).map((node) => node.textContent); - for (const excluded of [ - "GitHub", - "ScrapeCreators", - "Slack", - "Vercel", - "Render", - "HubSpot", - "Zoom", - "Google Workspace", - "Browserbase", - ]) { - expect(names).not.toContain(excluded); - } - expect(el.querySelector(".plugins-directory-add-mcp-action")).toBeNull(); - expect(el.textContent).not.toContain("Add MCP server"); - }); -});