From 3be5e410b4304d6c3630c703ea0fd9ad7195f8bd Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:27:11 -1000 Subject: [PATCH 1/4] feat(connectors): make every connector installable into any workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove `defaultBinding` and the Composio personal-workspace install guard. Any connector — Composio, DCR, static, or mpak — now installs into whichever workspace the request is for (personal or shared); binding scope is a property of the target workspace, not the catalog entry. Why this is safe: - Install (connector-tools.ts) already keys purely on the target `wsId` and treats personal vs shared identically — same BundleRef shape, same `credentials/composio//` layout. - Disconnect cleanup (lifecycle.ts `cleanupComposioBundle`) is also keyed on `{ wsId, connectorId }` with no scope discrimination, so the "orphan the upstream Composio account" footgun the guard claimed to prevent does not exist. The guard's comment cited a `lifecycle.disconnect` `isWorkspaceScope` gate that no longer exists. - Install already targets the request's active workspace (`ctx.getWorkspaceId()`, set from the `/w/` route); nothing derives the target from `defaultBinding` anymore. Changes: - Drop the `isPersonal === true` Composio guard and its stale comments. - Remove `defaultBinding` from the connector meta schema, projection, registry/web types, catalog YAML, and the two synthesized DirectoryEntry call sites. - Collapse the Browse page to one unified list (no personal/workspace split); drop the now-unused `mode` prop. - Update tests: drop `defaultBinding` fixtures/assertions; reframe the personal-workspace install test around the unified path. Verification: backend + web tsc clean; biome format/lint clean; check:catalog passes; connector unit + web component + integration install suites all green. --- src/api/handlers.ts | 17 ++++++- src/connectors/catalog.yaml | 30 +----------- src/connectors/server-detail.ts | 18 +------ src/registries/projection.ts | 4 -- src/registries/types.ts | 6 --- src/tools/connector-tools.ts | 49 ++++++------------- src/tools/system-tools.ts | 7 ++- .../connector-tools-install.test.ts | 1 - test/unit/composio-auth.test.ts | 2 - test/unit/connector-directory.test.ts | 7 +-- .../connector-tools-composio-install.test.ts | 1 - test/unit/connector-tools.test.ts | 27 ++++------ test/unit/connectors-catalog.test.ts | 2 +- test/unit/directory-entry-projection.test.ts | 11 ----- web/src/App.tsx | 5 +- web/src/__tests__/connector-sections.test.tsx | 2 - web/src/api/client.ts | 2 - .../connectors/ConnectorStatusHero.tsx | 1 - .../connectors/OperatorOAuthSection.tsx | 1 - web/src/context/WorkspaceContext.tsx | 11 ++--- .../pages/settings/ConnectorBrowsePage.tsx | 26 +++++----- 21 files changed, 67 insertions(+), 163 deletions(-) diff --git a/src/api/handlers.ts b/src/api/handlers.ts index 13896ddd..fe44b0d9 100644 --- a/src/api/handlers.ts +++ b/src/api/handlers.ts @@ -1503,7 +1503,22 @@ export async function handleOidcRefresh( return res; } catch (err) { - console.error("[nimblebrain] Token refresh failed:", err); + // invalid_grant = refresh token expired/revoked (e.g. session ended due to + // inactivity). Expected; the client re-authenticates. Log terse, no stack. + // Anything else is unexpected — log the message (still no bundled-source dump). + const expired = + typeof err === "object" && + err !== null && + "error" in err && + (err as { error?: unknown }).error === "invalid_grant"; + if (expired) { + console.warn( + "[nimblebrain] Token refresh rejected (session expired) — client will re-authenticate", + ); + } else { + const reason = err instanceof Error ? err.message : String(err); + console.error("[nimblebrain] Token refresh failed:", reason); + } return apiError(401, "refresh_failed", "Token refresh failed"); } } diff --git a/src/connectors/catalog.yaml b/src/connectors/catalog.yaml index 6d3d1b7d..de12ace6 100644 --- a/src/connectors/catalog.yaml +++ b/src/connectors/catalog.yaml @@ -4,8 +4,8 @@ # before it leaves the registry. # # `_meta["ai.nimblebrain/connector"]` carries platform-specific fields -# that don't fit upstream-defined slots (defaultBinding, OAuth flow type, -# operator-setup pointer, recommended scopes, search tags, UI hints). +# that don't fit upstream-defined slots (OAuth flow type, operator-setup +# pointer, recommended scopes, search tags, UI hints). # # Operators with custom curation needs override this list at runtime by # pointing `NB_REGISTRIES` at a JSON config that adds their own @@ -32,7 +32,6 @@ servers: url: https://mcp.asana.com/v2/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: static operatorSetup: portalUrl: https://app.asana.com/0/my-apps @@ -52,7 +51,6 @@ servers: url: https://mcp.notion.com/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: dcr tags: [docs, knowledge] @@ -68,7 +66,6 @@ servers: url: https://mcp.linear.app/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: dcr tags: [issues, project-mgmt] @@ -90,7 +87,6 @@ servers: url: https://bindings.mcp.cloudflare.com/sse _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: dcr tags: [infra, devops] @@ -112,7 +108,6 @@ servers: url: https://mcp.dropbox.com/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: static requiredScopes: - files.metadata.read @@ -151,7 +146,6 @@ servers: url: https://backend.composio.dev/v3/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: composio composio: toolkit: gmail @@ -206,7 +200,6 @@ servers: url: https://backend.composio.dev/v3/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: composio composio: toolkit: outlook @@ -244,7 +237,6 @@ servers: url: https://mcp.mercury.com/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: dcr tags: [banking, finance] @@ -260,7 +252,6 @@ servers: url: https://mcp.neon.tech/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: dcr tags: [database, postgres] @@ -276,7 +267,6 @@ servers: url: https://mcp.paypal.com/sse _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: dcr tags: [payments] @@ -292,7 +282,6 @@ servers: url: https://mcp.sentry.dev/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: dcr tags: [observability, errors] @@ -308,7 +297,6 @@ servers: url: https://mcp.stripe.com/ _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: dcr tags: [payments, billing] @@ -335,7 +323,6 @@ servers: url: https://mcp.webflow.com/sse _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: dcr tags: [design, cms] @@ -351,7 +338,6 @@ servers: url: https://mcp.wix.com/sse _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: dcr tags: [website, cms] @@ -367,7 +353,6 @@ servers: url: https://mcp.granola.ai/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: dcr tags: [meetings, notes] @@ -391,7 +376,6 @@ servers: url: https://backend.composio.dev/v3/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: composio composio: toolkit: box @@ -426,7 +410,6 @@ servers: url: https://backend.composio.dev/v3/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: composio composio: toolkit: github @@ -461,7 +444,6 @@ servers: url: https://backend.composio.dev/v3/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: composio composio: toolkit: googlecalendar @@ -495,7 +477,6 @@ servers: url: https://backend.composio.dev/v3/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: composio composio: toolkit: quickbooks @@ -531,7 +512,6 @@ servers: url: https://backend.composio.dev/v3/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: composio composio: toolkit: linkedin @@ -566,7 +546,6 @@ servers: url: https://backend.composio.dev/v3/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: composio composio: toolkit: slack @@ -601,7 +580,6 @@ servers: url: https://backend.composio.dev/v3/mcp _meta: ai.nimblebrain/connector: - defaultBinding: workspace auth: composio composio: toolkit: whatsapp @@ -635,7 +613,6 @@ servers: url: https://gmailmcp.googleapis.com/mcp/v1 _meta: ai.nimblebrain/connector: - defaultBinding: personal auth: static requiredScopes: - https://www.googleapis.com/auth/gmail.readonly @@ -661,7 +638,6 @@ servers: url: https://mcp.hubspot.com _meta: ai.nimblebrain/connector: - defaultBinding: personal auth: static operatorSetup: portalUrl: https://developers.hubspot.com/docs/apps/developer-platform/build-apps/integrate-with-the-remote-hubspot-mcp-server @@ -681,7 +657,6 @@ servers: url: https://mcp.microsoft.com/mail _meta: ai.nimblebrain/connector: - defaultBinding: personal auth: static requiredScopes: - https://graph.microsoft.com/Mail.ReadWrite @@ -705,7 +680,6 @@ servers: url: https://mcp.zoom.us/mcp _meta: ai.nimblebrain/connector: - defaultBinding: personal auth: static requiredScopes: [meeting:read, recording:read] operatorSetup: diff --git a/src/connectors/server-detail.ts b/src/connectors/server-detail.ts index b15a29d0..de694a0a 100644 --- a/src/connectors/server-detail.ts +++ b/src/connectors/server-detail.ts @@ -8,8 +8,7 @@ * `MpakSource` reads the same shape natively from mpak's `/v1/servers/...` * via the SDK. Consumers always see one type. The `_meta` extension * `ai.nimblebrain/connector` carries our platform-specific fields - * (defaultBinding, auth, operatorSetup, etc.) without polluting - * upstream-defined slots. + * (auth, operatorSetup, etc.) without polluting upstream-defined slots. * * Validated at every system boundary so an invalid entry is dropped * at the source it came from, never reaching the UI / agent. Each @@ -106,21 +105,6 @@ export interface Package { * it undefined). */ export interface NimbleBrainConnectorMeta { - /** - * Default install target — a UX hint the platform uses to decide - * which workspace receives the install action when the catalog entry - * is exercised. - * - * - `"workspace"` — install into the active workspace; bundle is - * shared with all members. Granted to admins only. - * - `"personal"` — install into the caller's personal workspace - * (`personalWorkspaceIdFor(userId)`); tokens are sole-owner by - * construction. - * - * Not a per-ref `oauthScope` — every installed ref is workspace-bound - * post-Stage-2. This field selects the wsId. - */ - defaultBinding?: "workspace" | "personal"; /** * OAuth flow type for remote services. * diff --git a/src/registries/projection.ts b/src/registries/projection.ts index 65ea63fc..1a2477fe 100644 --- a/src/registries/projection.ts +++ b/src/registries/projection.ts @@ -34,7 +34,6 @@ export interface ProjectionContext { * - `description` ← `ServerDetail.description` * - `iconUrl` ← `ServerDetail.icons[0].src` (theme-aware picker is a follow-up) * - `tags` ← `_meta.ai.nimblebrain/connector.tags` - * - `defaultBinding` ← `_meta.ai.nimblebrain/connector.defaultBinding` ?? `"workspace"` * - `install` ← derived from `packages[]` (mpak-bundle) or `remotes[]` (remote-oauth) * * Returns null if the entry isn't installable (no packages, no remotes, @@ -59,7 +58,6 @@ export function projectServerDetailToDirectoryEntry( description: s.description, ...(iconUrl ? { iconUrl } : {}), ...(meta?.tags && meta.tags.length > 0 ? { tags: meta.tags } : {}), - defaultBinding: meta?.defaultBinding ?? "workspace", install, }; } @@ -112,7 +110,6 @@ export interface ConnectorCatalogEntry { /** Remote MCP server URL — the value that goes into the bundle `url`. */ url: string; auth: "dcr" | "static" | "composio"; - defaultBinding: "workspace" | "personal"; requiredScopes?: string[]; additionalAuthorizationParams?: Record; operatorSetup?: { portalUrl: string; hint: string; clientSecretKey: string }; @@ -149,7 +146,6 @@ export function serverDetailToCatalogEntry(s: ServerDetail): ConnectorCatalogEnt iconUrl, url: remote.url, auth: meta?.auth ?? "dcr", - defaultBinding: meta?.defaultBinding ?? "workspace", ...(meta?.requiredScopes ? { requiredScopes: meta.requiredScopes } : {}), ...(meta?.additionalAuthorizationParams ? { additionalAuthorizationParams: meta.additionalAuthorizationParams } diff --git a/src/registries/types.ts b/src/registries/types.ts index fe8f691b..a08574a2 100644 --- a/src/registries/types.ts +++ b/src/registries/types.ts @@ -91,12 +91,6 @@ export interface DirectoryEntry { description: string; iconUrl?: string; tags?: string[]; - /** - * Default install target. UI uses this to filter Personal vs Workspace - * browse. `"workspace"` installs into the active workspace; `"personal"` - * installs into the caller's personal workspace. - */ - defaultBinding: "personal" | "workspace"; install: InstallAction; /** * For static-auth entries: whether the workspace has operator OAuth diff --git a/src/tools/connector-tools.ts b/src/tools/connector-tools.ts index 33f0ade2..85eb9c12 100644 --- a/src/tools/connector-tools.ts +++ b/src/tools/connector-tools.ts @@ -28,13 +28,11 @@ import type { InProcessTool } from "./in-process-app.ts"; * shell, and keeping one tool minimizes route bloat. * * Stage 2: every install is workspace-scoped. The `install` action - * REQUIRES an explicit `wsId` argument — the UI picks the target - * workspace and passes it in; the tool hard-errors on missing `wsId`, - * mirroring Stage 1's `startBundleSource` precedent. No - * default-to-personal fallback inside the tool. `defaultBinding` on a - * catalog entry is a UX hint the picker consults to preselect a - * workspace; the tool itself never reads it to pick a target. The - * bundle ref's `oauthScope` is always `"workspace"`. + * targets the request's active workspace (`ctx.getWorkspaceId()`, set + * from the `/w/` route); an explicit `wsId` arg overrides it for + * direct API callers. Any workspace — personal or shared — is a valid + * target; the tool never special-cases the target's `isPersonal` flag. + * The bundle ref's `oauthScope` is always `"workspace"`. * * Persistence: `WorkspaceStore.bundles[]` + * `workspaces//credentials/...` for tokens. @@ -946,12 +944,14 @@ function parseDirectoryEntry(input: unknown): DirectoryEntry | null { /** * Remote OAuth install — targets the explicit `wsId` passed in by the - * dispatcher. Composio-auth entries additionally require a non-personal - * (shared) target workspace because their credential layout under - * `credentials/composio//` only fits the shared-workspace - * shape today. Static-auth entries require operator OAuth client config - * persisted under `workspace.json#oauthOperatorApps[entry.id]` + the - * matching client_secret in the credential store before this can proceed. + * dispatcher (the request's active workspace). Every workspace — personal + * or shared — is a valid target: install, boot-state derivation, and + * disconnect cleanup are all keyed purely on `wsId`, so the credential + * layout under `credentials/composio//` works identically + * regardless of the target's `isPersonal` flag. Static-auth entries + * require operator OAuth client config persisted under + * `workspace.json#oauthOperatorApps[entry.id]` + the matching + * client_secret in the credential store before this can proceed. */ async function handleInstallRemoteOAuth( ctx: ManageConnectorsContext, @@ -973,22 +973,6 @@ async function handleInstallRemoteOAuth( if (!action.composio) { return errResult(`"${entry.name}" is composio-auth but missing composio config block.`); } - // Composio's state lives at workspace scope only: - // `credentials/composio//connection.json` is under - // `workspaces//`, and `lifecycle.disconnect`'s composio - // cleanup branch gates on `isWorkspaceScope`. A personal-workspace - // target would silently bypass that cleanup and orphan the upstream - // Composio account on disconnect. Reject at install time rather - // than ship the latent footgun. The source of truth is the target - // workspace record's `isPersonal` flag (NOT the catalog's - // `defaultBinding` — that's a UX hint, not an authority). - if (ws.isPersonal === true) { - return errResult( - `"${entry.name}" is composio-auth and cannot install into a personal workspace — ` + - "the credential layout does not yet support personal-workspace bindings. " + - "Pick a shared workspace from the install dialog instead.", - ); - } const { authConfigEnv } = action.composio; if (!process.env.COMPOSIO_API_KEY?.trim()) { return errResult( @@ -1117,10 +1101,9 @@ async function handleInstallRemoteOAuth( // for vendors whose remote `type` is `sse` — PayPal / Cloudflare / // Webflow / Wix in the bundled catalog today. transport: composioWiring?.transport ?? { type: action.transportType }, - // Post-Stage-2: every ref's oauthScope is "workspace". The catalog's - // `defaultBinding` selects which workspace the install targets - // (personal vs active), NOT a per-ref scope literal — see the - // dispatch logic above. + // Post-Stage-2: every ref's oauthScope is "workspace". The install + // targets the request's active workspace (see the dispatch logic + // above); the ref carries no per-target scope literal. oauthScope: "workspace", ...(action.requiredScopes ? { scopes: action.requiredScopes } : {}), ...(action.additionalAuthorizationParams diff --git a/src/tools/system-tools.ts b/src/tools/system-tools.ts index 0a1da2b9..531792a4 100644 --- a/src/tools/system-tools.ts +++ b/src/tools/system-tools.ts @@ -203,10 +203,9 @@ export async function createSystemTools( systemToolDefs.push(createManageWorkspacesTool(mergedCtx)); } - // Connectors tool. Single surface for both workspace-targeted and - // personal-workspace-targeted connectors — the install destination - // is chosen by the catalog entry's `defaultBinding`, and disconnects - // look up the binding workspace from the installed ref. + // Connectors tool. Single surface for all connectors — the install + // destination is the request's active workspace (personal or shared), + // and disconnects look up the binding workspace from the installed ref. if (runtime && manageWorkspacesCtx) { systemToolDefs.push( createManageConnectorsTool({ diff --git a/test/integration/connector-tools-install.test.ts b/test/integration/connector-tools-install.test.ts index d61de747..59509206 100644 --- a/test/integration/connector-tools-install.test.ts +++ b/test/integration/connector-tools-install.test.ts @@ -150,7 +150,6 @@ function dcrEntry(): DirectoryEntry { registryType: "static", name: "Granola", description: "Meeting notes", - defaultBinding: "personal", install: { kind: "remote-oauth", url: "https://api.granola.test/mcp", diff --git a/test/unit/composio-auth.test.ts b/test/unit/composio-auth.test.ts index e758d999..1bbeed87 100644 --- a/test/unit/composio-auth.test.ts +++ b/test/unit/composio-auth.test.ts @@ -145,7 +145,6 @@ function composioEntry(id: string) { iconUrl: "https://example.com/icon.png", url: "https://backend.composio.dev/v3/mcp/SERVER", auth: "composio" as const, - defaultBinding: "workspace" as const, composio: { toolkit: "gmail", authConfigEnv: "COMPOSIO_GMAIL_AUTH_CONFIG_ID", @@ -706,7 +705,6 @@ describe("POST /v1/composio-auth/initiate", () => { iconUrl: "https://example.com/icon.png", url: "https://mcp.example.com/mcp", auth: "dcr" as const, - defaultBinding: "workspace" as const, }; const { app } = makeApp(entry as unknown as ReturnType); const res = await app.request("http://nb.test/v1/composio-auth/initiate", { diff --git a/test/unit/connector-directory.test.ts b/test/unit/connector-directory.test.ts index 77ca98de..98a7ec63 100644 --- a/test/unit/connector-directory.test.ts +++ b/test/unit/connector-directory.test.ts @@ -313,7 +313,6 @@ describe("ConnectorDirectory.list", () => { _meta: { "ai.nimblebrain/connector": { auth: "static", - defaultBinding: "workspace", operatorSetup: { portalUrl: "https://app.asana.com/0/developer-console", hint: "Create OAuth app", @@ -329,7 +328,7 @@ describe("ConnectorDirectory.list", () => { icons: [{ src: "https://x.test/granola.svg" }], remotes: [{ type: "streamable-http", url: "https://api.granola.test/mcp" }], _meta: { - "ai.nimblebrain/connector": { auth: "dcr", defaultBinding: "workspace" }, + "ai.nimblebrain/connector": { auth: "dcr" }, }, }, ]); @@ -449,7 +448,6 @@ describe("ConnectorDirectory safety scrub (mpak XSS via _meta extension URLs)", mpakServer({ _meta: { "ai.nimblebrain/connector": { - defaultBinding: "workspace", auth: "dcr", docsUrl: "javascript:alert(1)", }, @@ -475,7 +473,6 @@ describe("ConnectorDirectory safety scrub (mpak XSS via _meta extension URLs)", mpakServer({ _meta: { "ai.nimblebrain/connector": { - defaultBinding: "workspace", auth: "static", operatorSetup: { portalUrl: "javascript:fetch('https://evil')", @@ -505,7 +502,6 @@ describe("ConnectorDirectory safety scrub (mpak XSS via _meta extension URLs)", mpakServer({ _meta: { "ai.nimblebrain/connector": { - defaultBinding: "workspace", auth: "dcr", additionalAuthorizationParams: { client_id: "attacker-controlled" }, }, @@ -553,7 +549,6 @@ describe("ConnectorDirectory safety scrub (mpak XSS via _meta extension URLs)", icons: [{ src: "https://x.test/safe.png" }], _meta: { "ai.nimblebrain/connector": { - defaultBinding: "workspace", auth: "dcr", docsUrl: "https://safe.example/docs", }, diff --git a/test/unit/connector-tools-composio-install.test.ts b/test/unit/connector-tools-composio-install.test.ts index 90721070..90ab9b01 100644 --- a/test/unit/connector-tools-composio-install.test.ts +++ b/test/unit/connector-tools-composio-install.test.ts @@ -105,7 +105,6 @@ function gmailEntry(): import("../../src/registries/types.ts").DirectoryEntry { registryType: "static", name: "Gmail", description: "Read, send, and draft mail", - defaultBinding: "workspace", install: { kind: "remote-oauth", url: GMAIL_URL, diff --git a/test/unit/connector-tools.test.ts b/test/unit/connector-tools.test.ts index 9778084f..b40d5ccf 100644 --- a/test/unit/connector-tools.test.ts +++ b/test/unit/connector-tools.test.ts @@ -52,7 +52,7 @@ const NOTION_ID = "com.notion/mcp"; * Build a DirectoryEntry shaped like what `StaticSource` projects for * Asana — used by install tests since the install API takes the full * entry, not an id. Field set matches the real source output; tests - * can override pieces (operatorSetup, defaultBinding) per case. + * can override pieces (operatorSetup, install) per case. */ function asanaEntry(over: Partial = {}): DirectoryEntry { return { @@ -61,7 +61,6 @@ function asanaEntry(over: Partial = {}): DirectoryEntry { registryType: "static", name: "Asana", description: "Tasks, projects, and team workflows", - defaultBinding: "workspace", install: { kind: "remote-oauth", url: ASANA_URL, @@ -94,7 +93,6 @@ function mpakEntry(over: { id?: string; pkg?: string; name?: string } = {}): Dir registryType: "mpak", name: over.name ?? "Echo", description: "Reference MCP server for testing", - defaultBinding: "workspace", install: { kind: "mpak-bundle", package: over.pkg ?? "@nimblebraininc/echo", @@ -797,11 +795,10 @@ describe("manage_connectors.install", () => { // Install defaults to the request's workspace (`getWorkspaceId()`), // but here the session workspace is explicitly null (third arg to // buildTool) and the call omits `wsId` — so there is no workspace - // anywhere. The tool must NOT recover a target from identity or the - // catalog's `defaultBinding`; with nothing in context it hard-errors, - // naming the missing arg. (This is the no-default-to-personal guard: - // it fires only when no workspace is in scope at all, not on the - // normal path where the route supplies one.) + // anywhere. The tool must NOT recover a target from identity; with + // nothing in context it hard-errors, naming the missing arg. (This is + // the no-default-to-personal guard: it fires only when no workspace is + // in scope at all, not on the normal path where the route supplies one.) const tool = buildTool(h, ADMIN_USER, null); const result = await tool.handler({ action: "install", entry: mpakEntry() }); expect(result.isError).toBe(true); @@ -872,7 +869,6 @@ describe("manage_connectors.install", () => { registryType: "static", name: "Evil", description: "x", - defaultBinding: "workspace", install: { kind: "remote-oauth", url: "javascript:alert(1)", @@ -904,7 +900,6 @@ describe("manage_connectors.install", () => { registryType: "static", name: "Evil", description: "x", - defaultBinding: "workspace", install: { kind: "remote-oauth", url: "https://mcp.evil.test/mcp", @@ -918,12 +913,11 @@ describe("manage_connectors.install", () => { expect(text.toLowerCase()).toContain("install action is required"); }); - test("personal-workspace install: wsId picked by the dialog targets personalWorkspaceIdFor(userId); stored ref has oauthScope=workspace", async () => { - // Stage 2 / T010: the install dialog's WorkspaceTargetPicker picks - // the personal workspace by `defaultBinding === "personal"` and - // supplies its id explicitly. The tool itself never reads the - // catalog's `defaultBinding` to pick a target — that is the picker - // UI's job. Pin three things: + test("personal-workspace install: an explicit personal wsId targets personalWorkspaceIdFor(userId); stored ref has oauthScope=workspace", async () => { + // Any workspace is a valid install target, personal included — the + // caller supplies the target wsId (from the active `/w/` route + // or an explicit arg) and the tool installs there with no special- + // casing of `isPersonal`. Pin three things: // - The recorded `wsId` IS `personalWorkspaceIdFor(callerId)` // (the canonical helper, NOT a hand-built `ws_user_` // template literal — `check:personal-workspace-id` is `src/`- @@ -949,7 +943,6 @@ describe("manage_connectors.install", () => { registryType: "static", name: "Canva", description: "x", - defaultBinding: "personal", install: { kind: "remote-oauth", url: "https://mcp.canva.com/mcp", diff --git a/test/unit/connectors-catalog.test.ts b/test/unit/connectors-catalog.test.ts index 4c151698..ade22f55 100644 --- a/test/unit/connectors-catalog.test.ts +++ b/test/unit/connectors-catalog.test.ts @@ -92,7 +92,7 @@ describe("validateServerDetail", () => { test("accepts _meta with arbitrary reverse-DNS extension keys", () => { const detail = makeValid({ _meta: { - "ai.nimblebrain/connector": { defaultBinding: "workspace", auth: "dcr" }, + "ai.nimblebrain/connector": { auth: "dcr" }, "dev.mpak/registry": { downloads: 42 }, }, }); diff --git a/test/unit/directory-entry-projection.test.ts b/test/unit/directory-entry-projection.test.ts index 7ada9df1..af1a0928 100644 --- a/test/unit/directory-entry-projection.test.ts +++ b/test/unit/directory-entry-projection.test.ts @@ -102,7 +102,6 @@ describe("projectServerDetailToDirectoryEntry", () => { remotes: [{ type: "streamable-http", url: "https://example.com/mcp" }], _meta: { "ai.nimblebrain/connector": { - defaultBinding: "personal", auth: "static", requiredScopes: ["read", "write"], additionalAuthorizationParams: { access_type: "offline" }, @@ -118,7 +117,6 @@ describe("projectServerDetailToDirectoryEntry", () => { CTX, ); expect(e?.install.kind).toBe("remote-oauth"); - expect(e?.defaultBinding).toBe("personal"); expect(e?.tags).toEqual(["email"]); if (e?.install.kind === "remote-oauth") { expect(e.install.auth).toBe("static"); @@ -158,15 +156,6 @@ describe("projectServerDetailToDirectoryEntry", () => { } }); - test("defaults defaultBinding to 'workspace' when no NimbleBrain meta is present", () => { - const e = projectServerDetailToDirectoryEntry( - detail({ - remotes: [{ type: "streamable-http", url: "https://example.com/mcp" }], - }), - CTX, - ); - expect(e?.defaultBinding).toBe("workspace"); - }); test("defaults remote auth to 'dcr' when meta is absent", () => { const e = projectServerDetailToDirectoryEntry( diff --git a/web/src/App.tsx b/web/src/App.tsx index 16728cd5..852c9e4b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -387,10 +387,7 @@ function AuthenticatedAppContent({ } /> } /> } /> - } - /> + } /> } diff --git a/web/src/__tests__/connector-sections.test.tsx b/web/src/__tests__/connector-sections.test.tsx index 28e41540..abaf91f7 100644 --- a/web/src/__tests__/connector-sections.test.tsx +++ b/web/src/__tests__/connector-sections.test.tsx @@ -180,7 +180,6 @@ function dcrConnector(over: Partial = {}): InstalledConnecto iconUrl: "", url: "https://api.granola.test/mcp", auth: "dcr", - defaultBinding: "workspace", }, ...over, }; @@ -207,7 +206,6 @@ function staticAuthConnector(over: Partial = {}): InstalledC iconUrl: "", url: "https://app.asana.com/api/mcp", auth: "static", - defaultBinding: "workspace", operatorSetup: { portalUrl: "https://app.asana.com/0/developer-console", hint: "Create OAuth app", diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 4afde944..6e25afbe 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -654,7 +654,6 @@ export interface ConnectorCatalogEntry { iconUrl: string; url: string; auth: "dcr" | "static" | "composio"; - defaultBinding: "workspace" | "personal"; requiredScopes?: string[]; additionalAuthorizationParams?: Record; operatorSetup?: { portalUrl: string; hint: string; clientSecretKey: string }; @@ -945,7 +944,6 @@ export interface DirectoryEntry { description: string; iconUrl?: string; tags?: string[]; - defaultBinding: "personal" | "workspace"; /** * Static-auth entries: true when the workspace has both clientId and * client_secret configured. Undefined for entries where operator diff --git a/web/src/components/connectors/ConnectorStatusHero.tsx b/web/src/components/connectors/ConnectorStatusHero.tsx index acdbb229..a6a5aa4a 100644 --- a/web/src/components/connectors/ConnectorStatusHero.tsx +++ b/web/src/components/connectors/ConnectorStatusHero.tsx @@ -67,7 +67,6 @@ export function ConnectorStatusHero({ description: cat.description, ...(installed.iconUrl ? { iconUrl: installed.iconUrl } : {}), tags: cat.tags, - defaultBinding: cat.defaultBinding, install: { kind: "remote-oauth", url: cat.url, diff --git a/web/src/components/connectors/OperatorOAuthSection.tsx b/web/src/components/connectors/OperatorOAuthSection.tsx index 38bf7f6d..1927e557 100644 --- a/web/src/components/connectors/OperatorOAuthSection.tsx +++ b/web/src/components/connectors/OperatorOAuthSection.tsx @@ -41,7 +41,6 @@ export function OperatorOAuthSection({ description: cat.description, ...(installed.iconUrl ? { iconUrl: installed.iconUrl } : {}), tags: cat.tags, - defaultBinding: cat.defaultBinding, install: { kind: "remote-oauth", url: cat.url, diff --git a/web/src/context/WorkspaceContext.tsx b/web/src/context/WorkspaceContext.tsx index ca027d2a..e3d67a65 100644 --- a/web/src/context/WorkspaceContext.tsx +++ b/web/src/context/WorkspaceContext.tsx @@ -16,12 +16,11 @@ export interface WorkspaceInfo { userRole?: "admin" | "member"; /** * `true` for the user's personal workspace (auto-provisioned at first - * login, sole-owner-by-design). Drives the install dialog's preselection - * heuristic — personal-typical connectors (`defaultBinding: "personal"`) - * default to the personal workspace; non-personal workspaces require an - * explicit pick. The platform's bootstrap endpoint sets this; the - * `parseWorkspaceListResponse` fallback also propagates it so the - * shell mounted via either path agrees. + * login, sole-owner-by-design). Connectors install into whichever + * workspace is active, personal or shared; this flag only marks the + * sole-owner workspace for display. The platform's bootstrap endpoint + * sets this; the `parseWorkspaceListResponse` fallback also propagates + * it so the shell mounted via either path agrees. */ isPersonal?: boolean; } diff --git a/web/src/pages/settings/ConnectorBrowsePage.tsx b/web/src/pages/settings/ConnectorBrowsePage.tsx index 4386769e..7fb71d6c 100644 --- a/web/src/pages/settings/ConnectorBrowsePage.tsx +++ b/web/src/pages/settings/ConnectorBrowsePage.tsx @@ -22,7 +22,7 @@ import { roleAtLeast, useScopedRole } from "../../hooks/useScopedRole"; * because the catalog is long enough that a single column wastes * horizontal space. */ -export function ConnectorBrowsePage({ mode }: { mode: "personal" | "workspace" }) { +export function ConnectorBrowsePage() { const [entries, setEntries] = useState([]); const [errors, setErrors] = useState>([]); const [installed, setInstalled] = useState([]); @@ -37,8 +37,8 @@ export function ConnectorBrowsePage({ mode }: { mode: "personal" | "workspace" } const navigate = useNavigate(); const { slug } = useParams<{ slug: string }>(); - // Workspace connectors are addressed by the URL slug. (Personal-scope - // connectors have no UI surface today; the mode is kept for the API.) + // Connectors are addressed by the URL slug — install targets whichever + // workspace (personal or shared) the user is currently viewing. const backPath = `/w/${slug}/settings/connectors`; const configureBasePath = backPath; @@ -92,16 +92,14 @@ export function ConnectorBrowsePage({ mode }: { mode: "personal" | "workspace" } return false; } - // Filter to mode, drop installed, apply search. The UI `mode` is a - // page-view discriminator (`"personal"` vs `"workspace"`) — both - // values map 1:1 onto the catalog entry's `defaultBinding`. The - // legacy `"user"` literal was renamed in T009 (Group D audit) because - // a route/page mode indicator is not an oauthScope. + // One unified browse list: every connector is installable into any + // workspace, so the only filtering is dropping already-installed + // entries and applying the search query. const visibleEntries = useMemo(() => { - const inScope = entries.filter((e) => e.defaultBinding === mode && !isInstalled(e)); - if (!query.trim()) return inScope; + const available = entries.filter((e) => !isInstalled(e)); + if (!query.trim()) return available; const q = query.trim().toLowerCase(); - return inScope.filter( + return available.filter( (e) => e.name.toLowerCase().includes(q) || e.description.toLowerCase().includes(q) || @@ -109,7 +107,7 @@ export function ConnectorBrowsePage({ mode }: { mode: "personal" | "workspace" } ); // installedByKey is captured by isInstalled via closure; re-running // when it changes is what lets newly-installed connectors disappear. - }, [entries, mode, query, installedByKey]); + }, [entries, query, installedByKey]); // Install into the workspace the user is already in. The page is // mounted under `/w//...`, so the route names an unambiguous @@ -161,9 +159,7 @@ export function ConnectorBrowsePage({ mode }: { mode: "personal" | "workspace" }

Browse connectors

- {mode === "personal" - ? "Personal services to connect to your account." - : "Tools and services to add to this workspace."} + Tools and services to add to this workspace.

From 6ac58d6a4229c7730317382631811bcd47204df8 Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:18:24 -1000 Subject: [PATCH 2/4] test(connectors): pin composio personal-workspace install + cleanup invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA follow-up. The reframed personal-workspace test used a dcr fixture (Canva) — a path the removed guard never gated — so it gave false confidence about the path this change actually unblocks. Add two tests to the composio-install suite that exercise the composio branch: - (g) composio install into a personal workspace persists the ref (succeeds where the old isPersonal guard rejected). - (h) disconnect of a personal-workspace composio bundle runs cleanup keyed on that wsId — seeds a connection.json under the personal workspace, disconnects through the real lifecycle caller, and asserts it was removed. Pins the "cleanup resolves by wsId, not isPersonal" invariant the safety argument rests on, so a future isPersonal gate in a cleanup caller fails a test instead of silently orphaning upstream. Also retune the stale bootstrap.ts isPersonal passthrough comment: it described a T010 install-dialog preselection/picker this change removed. --- .../connector-tools-composio-install.test.ts | 95 ++++++++++++++++++- web/src/lib/bootstrap.ts | 8 +- 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/test/unit/connector-tools-composio-install.test.ts b/test/unit/connector-tools-composio-install.test.ts index 90ab9b01..e44ccd5c 100644 --- a/test/unit/connector-tools-composio-install.test.ts +++ b/test/unit/connector-tools-composio-install.test.ts @@ -86,8 +86,12 @@ import { type ManageConnectorsContext, } from "../../src/tools/connector-tools.ts"; import { ToolRegistry } from "../../src/tools/registry.ts"; -import { WorkspaceStore } from "../../src/workspace/workspace-store.ts"; +import { personalWorkspaceIdFor, WorkspaceStore } from "../../src/workspace/workspace-store.ts"; import { _resetComposioConfigForTest } from "../../src/composio/sdk.ts"; +import { + hasPersistedComposioConnection, + saveComposioConnection, +} from "../../src/bundles/composio-connection.ts"; // ── Catalog fixture ───────────────────────────────────────────────── // @@ -482,4 +486,93 @@ describe("manage_connectors.install (composio-auth)", () => { const ws = await h.workspaceStore.get(h.wsId); expect(ws?.bundles ?? []).toHaveLength(0); }); + + // ── personal-workspace target ───────────────────────────────────── + // + // The path this whole change unblocks: composio-auth install into a + // personal workspace. A stale guard used to reject it inside the + // `auth === "composio"` branch; dcr/static were never gated, so a + // dcr fixture would NOT cover this. These two tests pin the composio + // path specifically — install succeeds, and disconnect cleanup is + // keyed on the personal `wsId` with no `isPersonal` special-casing + // (the invariant the removed guard's safety argument rests on). + + test("(g) composio install into a personal workspace persists the ref (the path the removed guard blocked)", async () => { + process.env.COMPOSIO_API_KEY = "k_test"; + process.env.COMPOSIO_GMAIL_AUTH_CONFIG_ID = "ac_gmail"; + + const personalWsId = personalWorkspaceIdFor(ADMIN.id); + await h.workspaceStore.create("Admin Personal", `user_${ADMIN.id}`, { + isPersonal: true, + ownerUserId: ADMIN.id, + }); + + const tool = buildTool(h); + const result = await tool.handler({ + action: "install", + entry: gmailEntry(), + wsId: personalWsId, + }); + + // Eager startBundleSource fails on the fake session URL (same as + // the shared-workspace path), so this returns success-with-warning, + // NOT the old "cannot install into a personal workspace" error. + expect(result.isError).toBe(false); + const sc = result.structuredContent as { scope?: string; wsId?: string }; + expect(sc.scope).toBe("workspace"); + expect(sc.wsId).toBe(personalWsId); + + // The ref landed in the personal workspace with the composio marker + // and the post-T008 workspace scope. + const personalWs = await h.workspaceStore.get(personalWsId); + const installed = personalWs?.bundles.find( + (b): b is Extract => "url" in b && "composio" in b, + ); + expect(installed).toBeDefined(); + expect(installed?.composio?.connectorId).toBe(GMAIL_ID); + expect(installed?.oauthScope).toBe("workspace"); + }); + + test("(h) disconnect of a personal-workspace composio bundle runs cleanup keyed on that wsId (no isPersonal gate)", async () => { + process.env.COMPOSIO_API_KEY = "k_test"; + process.env.COMPOSIO_GMAIL_AUTH_CONFIG_ID = "ac_gmail"; + + const personalWsId = personalWorkspaceIdFor(ADMIN.id); + await h.workspaceStore.create("Admin Personal", `user_${ADMIN.id}`, { + isPersonal: true, + ownerUserId: ADMIN.id, + }); + + // Install seeds the BundleRef + lifecycle instance for the personal + // workspace (the eager-start failure is caught and logged after). + const tool = buildTool(h); + await tool.handler({ action: "install", entry: gmailEntry(), wsId: personalWsId }); + + // Simulate a completed Composio OAuth: a connection.json under the + // personal workspace's credential path. Cleanup must find and remove + // THIS file — proving it resolves by wsId, personal included. + await saveComposioConnection(h.workDir, personalWsId, GMAIL_ID, { + connectedAccountId: "ca_personal", + toolkit: "gmail", + userId: ADMIN.id, + connectedAt: "2026-06-02T00:00:00.000Z", + status: "ACTIVE", + }); + expect(hasPersistedComposioConnection(h.workDir, personalWsId, GMAIL_ID)).toBe(true); + + // Drop the API key so cleanup skips the upstream revoke (offline); + // the local-delete branch is the part that pins the wsId routing. + delete process.env.COMPOSIO_API_KEY; + + // Disconnect through the real lifecycle caller (serverName is the + // slug of GMAIL_ID; principal is the workspace principal). If a + // future change gated this caller on `isPersonal`, the personal + // workspace's connection would survive and orphan upstream — this + // assertion fails first. + await h.runtime + .getLifecycle() + .disconnect("com-google-gmail", personalWsId, "_workspace", { workDir: h.workDir }); + + expect(hasPersistedComposioConnection(h.workDir, personalWsId, GMAIL_ID)).toBe(false); + }); }); diff --git a/web/src/lib/bootstrap.ts b/web/src/lib/bootstrap.ts index 47dc8fda..dbfbed94 100644 --- a/web/src/lib/bootstrap.ts +++ b/web/src/lib/bootstrap.ts @@ -70,10 +70,10 @@ export function parseWorkspaceListResponse(raw: unknown): WorkspaceInfo[] { : [], ...(userRole ? { userRole } : {}), // Pass through `isPersonal` from either contract. Bootstrap - // returns it directly; `manage_workspaces.list` is expected to - // surface it now that T010's install dialog depends on it for - // preselection. Missing field gracefully degrades to "not - // identified as personal" — the picker shows no preselection. + // returns it directly; `manage_workspaces.list` surfaces it too. + // It no longer gates connector installs (every workspace is a + // valid target) — it only marks the sole-owner workspace for + // display. Missing field degrades to "not identified as personal." ...(typeof ws.isPersonal === "boolean" ? { isPersonal: ws.isPersonal } : {}), }; }); From 5563d440cba34b7289cdbda941ece79aba2ddc0b Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:27:04 -1000 Subject: [PATCH 3/4] refactor(connectors): finish removing the personal/workspace mode split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA round 2 follow-up. ConnectorBrowsePage lost its `mode` discriminator in the earlier commit, but three sibling surfaces still carried it despite only ever being mounted as `"workspace"`: - ConnectorDetailPage: drop the `mode` prop. Its `canManage` ternary (`mode === "personal" ? true : roleAtLeast(...)`) was a dead branch that would grant manage unconditionally if `mode="personal"` were ever wired — now just `roleAtLeast(role, "ws_admin")`, which already covers the personal owner (sole admin of their personal workspace). - ConnectorList, ToolPermissionsTable: both already ignored `mode` (`mode: _mode`); remove the prop and its mount-site args. Also fix the install start-warning message to distinguish "your personal workspace" vs "this workspace", matching the success path (was a generic "the target workspace"). No behavior change — every surface was already workspace-only. --- src/tools/connector-tools.ts | 4 +++- web/src/App.tsx | 5 +--- .../components/connectors/ConnectorList.tsx | 24 +++++++------------ .../connectors/ToolPermissionsTable.tsx | 14 +---------- .../pages/settings/ConnectorDetailPage.tsx | 22 ++++++++--------- .../pages/settings/WorkspaceConnectorsTab.tsx | 2 +- 6 files changed, 24 insertions(+), 47 deletions(-) diff --git a/src/tools/connector-tools.ts b/src/tools/connector-tools.ts index 85eb9c12..9195f304 100644 --- a/src/tools/connector-tools.ts +++ b/src/tools/connector-tools.ts @@ -1256,7 +1256,9 @@ async function handleInstallRemoteOAuth( return { content: textContent( startWarning - ? `Installed "${entry.name}" in the target workspace. Source eager-start failed (${startWarning}) — click Connect to retry.` + ? `Installed "${entry.name}" in ${ + isPersonalTarget ? "your personal workspace" : "this workspace" + }. Source eager-start failed (${startWarning}) — click Connect to retry.` : isPersonalTarget ? `Installed "${entry.name}" in your personal workspace.` : `Installed "${entry.name}" for this workspace.`, diff --git a/web/src/App.tsx b/web/src/App.tsx index 852c9e4b..d1994898 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -388,10 +388,7 @@ function AuthenticatedAppContent({ } /> } /> } /> - } - /> + } /> } /> diff --git a/web/src/components/connectors/ConnectorList.tsx b/web/src/components/connectors/ConnectorList.tsx index ff76651a..d67d5c8b 100644 --- a/web/src/components/connectors/ConnectorList.tsx +++ b/web/src/components/connectors/ConnectorList.tsx @@ -7,28 +7,20 @@ import { EmptyState } from "../../pages/settings/components"; import { ConnectorIcon } from "./ConnectorIcon"; /** - * Lists installed connectors for a single page mode (Personal or - * Workspace). The list is intentionally minimal — icon, name, status - * + action verb when something needs attention, chevron link to - * Configure. Type / interactive / version metadata is deferred to - * the Configure page so the list stays scannable. + * Lists installed connectors for the active workspace. The list is + * intentionally minimal — icon, name, status + action verb when + * something needs attention, chevron link to Configure. Type / + * interactive / version metadata is deferred to the Configure page so + * the list stays scannable. * * Browsing for new connectors lives on a separate /browse page * reached via the action in the page header — the list itself only * shows what's already installed. * - * `mode` is a UI affordance (which settings page is rendering us); - * `getInstalledConnectors` always reads from the active workspace - * (Stage 2: personal connectors live in the user's personal - * workspace, addressed by setting it active before navigating here). + * `getInstalledConnectors` reads from the active workspace, whichever + * (personal or shared) the current `/w/:slug` route names. */ -export function ConnectorList({ - mode: _mode, - configureBasePath, -}: { - mode: "personal" | "workspace"; - configureBasePath: string; -}) { +export function ConnectorList({ configureBasePath }: { configureBasePath: string }) { const [installed, setInstalled] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); diff --git a/web/src/components/connectors/ToolPermissionsTable.tsx b/web/src/components/connectors/ToolPermissionsTable.tsx index 09309262..d16af091 100644 --- a/web/src/components/connectors/ToolPermissionsTable.tsx +++ b/web/src/components/connectors/ToolPermissionsTable.tsx @@ -23,19 +23,7 @@ import { * a Configure page with 10+ tools. A heavy border made the page feel * walled off; lighter chrome lets it sit alongside the other sections. */ -export function ToolPermissionsTable({ - serverName, - mode: _mode, -}: { - serverName: string; - /** - * UI affordance — which settings page is rendering us. The REST - * helpers always read/write workspace-scope permissions (Stage 2: - * personal connectors live in the user's personal workspace, - * addressed by setting it active before navigating here). - */ - mode: "personal" | "workspace"; -}) { +export function ToolPermissionsTable({ serverName }: { serverName: string }) { const [tools, setTools] = useState([]); const [policies, setPolicies] = useState>({}); const [loading, setLoading] = useState(true); diff --git a/web/src/pages/settings/ConnectorDetailPage.tsx b/web/src/pages/settings/ConnectorDetailPage.tsx index 6bb21c94..a5b007fe 100644 --- a/web/src/pages/settings/ConnectorDetailPage.tsx +++ b/web/src/pages/settings/ConnectorDetailPage.tsx @@ -33,14 +33,13 @@ import { roleAtLeast, useScopedRole } from "../../hooks/useScopedRole"; * for any ready connector — that's what users come here for once * setup is past. * - * Reachable from `/settings/{personal,workspace}/connectors/:serverName`; - * `mode` comes from the route prefix. + * Reachable from `/w/:slug/settings/connectors/:serverName`. */ -export function ConnectorDetailPage({ mode }: { mode: "personal" | "workspace" }) { +export function ConnectorDetailPage() { const { serverName = "", slug } = useParams<{ serverName: string; slug: string }>(); const navigate = useNavigate(); - // Workspace connectors are addressed by the URL slug. (Personal-scope - // connectors have no UI surface today; the mode is kept for the API.) + // Connectors are addressed by the URL slug — the page acts on whichever + // workspace (personal or shared) the slug names. const backPath = `/w/${slug}/settings/connectors`; const [installed, setInstalled] = useState(null); @@ -55,10 +54,10 @@ export function ConnectorDetailPage({ mode }: { mode: "personal" | "workspace" } const [uninstallArmed, setUninstallArmed] = useState(false); const role = useScopedRole(); - // Workspace-mode edit gates ride on ws_admin. Personal-mode is - // always editable by the owner — it's their personal workspace, and - // the workspace store enforces sole-owner membership invariants. - const canManage = mode === "personal" ? true : roleAtLeast(role, "ws_admin"); + // Edit gates ride on ws_admin. In a personal workspace the sole owner + // is its admin (the workspace store enforces that invariant), so the + // same check covers both cases. + const canManage = roleAtLeast(role, "ws_admin"); const refresh = useCallback(async () => { setError(null); @@ -66,8 +65,7 @@ export function ConnectorDetailPage({ mode }: { mode: "personal" | "workspace" } // Targeted single-connector fetch — avoids building entries // (and tools() round-trips) for every other installed bundle // when we only render one. Server resolves the connector from - // serverName; the page `mode` is a UI affordance, not a server - // arg. + // serverName. const res = await getInstalledConnector(serverName); setInstalled(res.installed); } catch (err) { @@ -193,7 +191,7 @@ export function ConnectorDetailPage({ mode }: { mode: "personal" | "workspace" }
- +
{configureModalOpen && ( diff --git a/web/src/pages/settings/WorkspaceConnectorsTab.tsx b/web/src/pages/settings/WorkspaceConnectorsTab.tsx index 71b6d34d..0781ee2e 100644 --- a/web/src/pages/settings/WorkspaceConnectorsTab.tsx +++ b/web/src/pages/settings/WorkspaceConnectorsTab.tsx @@ -25,7 +25,7 @@ export function WorkspaceConnectorsTab() { } /> - + ); From 1bdf2117c471871eaefe36ab44ee800fd9a5bafa Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:32:13 -1000 Subject: [PATCH 4/4] style(connectors): unify install message preposition ("in this workspace") The eager-start-warning branch already said "in this workspace"; the no-warning branch still said "for this workspace" for the same shared- workspace case. Use "in" across all four variants (both personal branches already do). Cosmetic only. --- src/tools/connector-tools.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/connector-tools.ts b/src/tools/connector-tools.ts index 9195f304..7b66b503 100644 --- a/src/tools/connector-tools.ts +++ b/src/tools/connector-tools.ts @@ -1261,7 +1261,7 @@ async function handleInstallRemoteOAuth( }. Source eager-start failed (${startWarning}) — click Connect to retry.` : isPersonalTarget ? `Installed "${entry.name}" in your personal workspace.` - : `Installed "${entry.name}" for this workspace.`, + : `Installed "${entry.name}" in this workspace.`, ), structuredContent: { ok: true,