From 660c691745efe2082d71dee16ae02119fac799c8 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 18:17:27 -0700 Subject: [PATCH 1/2] Take the connector set as input to @corbits/connections (CL-7384) --- IMPLEMENTATION.md | 2 +- .../hub/src/connection-live-reconcile.test.ts | 2 +- apps/hub/src/connection-live-reconcile.ts | 2 +- apps/hub/src/index.ts | 12 +- .../src/pinned-package-credential-bindings.ts | 2 +- apps/web/src/connect-service-actions.ts | 11 +- apps/web/src/pages/plugins-page.tsx | 8 +- apps/web/src/shell/provider-health-banner.tsx | 5 +- bun.lock | 18 +- docs/connect-cards.md | 3 +- docs/credential-wiring.md | 11 +- packages/chat/src/chat-orchestrator.test.ts | 11 +- packages/chat/src/chat-orchestrator.ts | 16 +- packages/connections-tools/package.json | 1 + packages/connections-tools/src/tool.ts | 9 +- packages/connections/README.md | 21 +- packages/connections/package.json | 9 +- .../connect-github-credential-link.test.ts | 30 +- .../connections/src/connect-github-routes.ts | 2 +- .../connections/src/credential-error.test.ts | 6 +- packages/connections/src/credential-error.ts | 19 +- packages/connections/src/index.ts | 10 +- .../connections/src/mcp-oauth-routes.test.ts | 72 ++- packages/connections/src/mcp-oauth-routes.ts | 14 +- packages/connections/src/mcp-presets.test.ts | 140 ---- packages/connections/src/mcp-presets.ts | 173 +---- .../connections/src/mcp-server-routes.test.ts | 52 ++ packages/connections/src/mcp-server-routes.ts | 10 +- packages/connections/src/oauth-routes.ts | 8 +- .../src/oauth-tenant-connect.test.ts | 2 + .../connections/src/oauth-tenant-connect.ts | 8 +- .../src/persist-credential.test.ts | 58 +- packages/connections/src/plugins.test.ts | 35 +- packages/connections/src/plugins.ts | 5 +- packages/connections/src/registry.ts | 477 +------------- packages/connections/src/routes.ts | 12 +- packages/connections/src/template-settings.ts | 71 +++ .../src/workflow-connection-routes.test.ts | 36 ++ .../src/workflow-connection-routes.ts | 7 +- .../test/github-oauth-connect.test.ts | 56 +- packages/evals/src/targets/world-snapshot.ts | 3 +- .../src/credential-delivery-error.test.ts | 6 +- packages/onboarding/package.json | 1 + .../onboarding/src/complete-credential.ts | 2 +- packages/onboarding/src/routes.ts | 2 +- packages/plugins-ui/README.md | 4 +- packages/plugins-ui/package.json | 1 + packages/plugins-ui/src/mcp-preset-cards.tsx | 6 +- .../plugins-ui/src/mcp-servers-section.tsx | 2 +- .../plugins-ui/src/plugin-connect-panel.tsx | 2 +- packages/plugins-ui/src/plugins-gallery.tsx | 2 +- .../plugins-ui/test/mcp-preset-cards.test.tsx | 2 +- .../plugins-ui/test/plugins-gallery.test.tsx | 2 +- .../settings-ui/src/connections-section.tsx | 8 +- packages/workflows/package.json | 1 + packages/workflows/src/catalog.test.ts | 6 +- .../connectors.test.ts | 151 ++++- templates/connectors.ts | 601 ++++++++++++++++++ templates/package.json | 6 +- templates/schema.test.ts | 3 +- templates/settings.ts | 83 +-- 61 files changed, 1386 insertions(+), 954 deletions(-) delete mode 100644 packages/connections/src/mcp-presets.test.ts create mode 100644 packages/connections/src/template-settings.ts rename packages/connections/src/registry.test.ts => templates/connectors.test.ts (60%) create mode 100644 templates/connectors.ts diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 9cf2661ff..20729e989 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -206,7 +206,7 @@ names both halves, not a bare error. ## Canva MCP connect (shipped) -Canva is the `canva` MCP preset (`packages/connections/src/mcp-presets.ts`): +Canva is the `canva` MCP preset (`templates/connectors.ts`): `https://mcp.canva.com/mcp`, `connectionMode: "oauth"`, with the 16 advertised PRM scopes space-joined onto RFC 7591 DCR `clientMetadata.scope` (`createMcpOAuthProvider` in `packages/connections/src/mcp-oauth.ts`). diff --git a/apps/hub/src/connection-live-reconcile.test.ts b/apps/hub/src/connection-live-reconcile.test.ts index d978412a1..61a9387f9 100644 --- a/apps/hub/src/connection-live-reconcile.test.ts +++ b/apps/hub/src/connection-live-reconcile.test.ts @@ -3,7 +3,7 @@ // and not persist-only. `createHub` is not booted here; this is the // hook body `apps/hub/src/index.ts` fires from `settleServiceConnection`. import { describe, expect, test } from "bun:test"; -import { CONNECTOR_REGISTRY } from "@corbits/connections/registry"; +import { CONNECTOR_REGISTRY } from "@workbench/templates/connectors"; import { reconcilePinnedToolPackagesAfterConnect } from "./connection-live-reconcile"; describe("reconcilePinnedToolPackagesAfterConnect", () => { diff --git a/apps/hub/src/connection-live-reconcile.ts b/apps/hub/src/connection-live-reconcile.ts index 67f8115ca..d4cb839fb 100644 --- a/apps/hub/src/connection-live-reconcile.ts +++ b/apps/hub/src/connection-live-reconcile.ts @@ -6,7 +6,7 @@ // until this pass relaunches it. Persist-only (stamp the pin onto the // launch row and leave the run) is the bug: the sidecar keeps the old // snapshot. -import { CONNECTOR_REGISTRY } from "@corbits/connections/registry"; +import { CONNECTOR_REGISTRY } from "@workbench/templates/connectors"; export type PinnedToolPackageReconcile = { reconcilePinnedToolPackages( diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 0a34f719f..8a467fd92 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -307,7 +307,10 @@ import { listMcpServerConnections, } from "@corbits/connections"; import type { ServiceConnectedHook } from "@corbits/connections"; -import { CONNECTOR_REGISTRY } from "@corbits/connections/registry"; +import { + CONNECTOR_REGISTRY, + MCP_PRESETS, +} from "@workbench/templates/connectors"; import { createProviderHealthPort, createProviderHealthStore, @@ -1415,6 +1418,7 @@ export async function createHub(config: HubConfig) { approvals: createApprovalStore(db), recordActivity: chatPlatform.recordActivity, claims: writeClaims, + connectorRegistry: CONNECTOR_REGISTRY, }; if (memoryHandle !== undefined) { chatOrchestratorDeps.memory = memoryHandle.memory; @@ -2275,6 +2279,7 @@ export async function createHub(config: HubConfig) { `${TENANT_PREFIX}/connections`, createConnectionRoutes({ hubUrl: config.baseUrl, + registry: CONNECTOR_REGISTRY, requireGrant: createRequireGrant({ grantStore: chatGrantStore, conditionRegistry: chatConditionRegistry, @@ -2349,6 +2354,7 @@ export async function createHub(config: HubConfig) { hubUrl: config.baseUrl, log: (line) => log.info`${line}`, credentialCipher, + registry: CONNECTOR_REGISTRY, // Same env bag `GET .../connections/oauth-configured` reads above. oauthEnv: { huggingfaceClientId: config.huggingfaceOAuthClientId, @@ -2360,6 +2366,7 @@ export async function createHub(config: HubConfig) { connectCredential: createTenantConnectCredential({ hubUrl: config.baseUrl, log: (line) => log.info`${line}`, + registry: CONNECTOR_REGISTRY, providerHealth: providerHealthStore, }), onConnected: settleServiceConnection, @@ -2646,6 +2653,7 @@ export async function createHub(config: HubConfig) { conditionRegistry: chatConditionRegistry, }), log: (line) => log.info`${line}`, + presets: MCP_PRESETS, onConnected: settleServiceConnection, }), ); @@ -2663,6 +2671,7 @@ export async function createHub(config: HubConfig) { }), log: (line) => log.info`${line}`, credentialCipher, + presets: MCP_PRESETS, onConnected: settleServiceConnection, // `/w/` for the same reason as the connections/oauth mount above. returnPathAllowlist: [ @@ -2680,6 +2689,7 @@ export async function createHub(config: HubConfig) { "/api/workflow-connections", createWorkflowConnectionRoutes({ authenticator: createWorkflowRunAuthenticator({ db }), + registry: CONNECTOR_REGISTRY, // Same `isConnectorConnected` the pinned-package factory is wired // with above (CL-6492). isConnectorConnected, diff --git a/apps/hub/src/pinned-package-credential-bindings.ts b/apps/hub/src/pinned-package-credential-bindings.ts index 9c48fb251..d4feea67a 100644 --- a/apps/hub/src/pinned-package-credential-bindings.ts +++ b/apps/hub/src/pinned-package-credential-bindings.ts @@ -13,7 +13,7 @@ import type { CredentialBinding } from "@intx/types"; import type { ToolPackagePin } from "@intx/types/tool-packages"; import type { PinnedPackageCredentialBindingsFor } from "@corbits/folded-runs"; -import { CONNECTOR_REGISTRY } from "@corbits/connections/registry"; +import { CONNECTOR_REGISTRY } from "@workbench/templates/connectors"; export type IsConnectorConnected = ( tenantId: string, diff --git a/apps/web/src/connect-service-actions.ts b/apps/web/src/connect-service-actions.ts index fd36a5189..b22b2ea33 100644 --- a/apps/web/src/connect-service-actions.ts +++ b/apps/web/src/connect-service-actions.ts @@ -27,8 +27,11 @@ import { listMcpPresets, McpServersApiError, } from "@corbits/plugins-ui"; -import { CONNECTOR_REGISTRY } from "@corbits/connections/registry"; import { mcpPresetBySlug } from "@corbits/connections/mcp-presets"; +import { + CONNECTOR_REGISTRY, + MCP_PRESETS, +} from "@workbench/templates/connectors"; function bareConnectorId(connectorId: string): string { return connectorId.startsWith("mcp:") @@ -48,7 +51,7 @@ export function createChatConnectServiceActions( async function readState(connectorId: string): Promise { const slug = bareConnectorId(connectorId); - const preset = mcpPresetBySlug(slug); + const preset = mcpPresetBySlug(MCP_PRESETS, slug); if (preset !== undefined) { const presets = await listMcpPresets(tenantId); const listed = presets.find((entry) => entry.slug === preset.slug); @@ -115,7 +118,7 @@ export function createChatConnectServiceActions( }, async connect(connectorId) { const slug = bareConnectorId(connectorId); - const preset = mcpPresetBySlug(slug); + const preset = mcpPresetBySlug(MCP_PRESETS, slug); if (preset !== undefined && preset.connectionMode === "keyless") { try { await connectMcpPreset(tenantId, preset.slug, undefined); @@ -134,7 +137,7 @@ export function createChatConnectServiceActions( }, async submitKey(connectorId, key) { const slug = bareConnectorId(connectorId); - const preset = mcpPresetBySlug(slug); + const preset = mcpPresetBySlug(MCP_PRESETS, slug); if (preset !== undefined && preset.connectionMode === "token") { try { await connectMcpPreset(tenantId, preset.slug, key); diff --git a/apps/web/src/pages/plugins-page.tsx b/apps/web/src/pages/plugins-page.tsx index 07ff06292..c7888eef5 100644 --- a/apps/web/src/pages/plugins-page.tsx +++ b/apps/web/src/pages/plugins-page.tsx @@ -23,8 +23,10 @@ import { } from "@corbits/plugins-ui"; import type { ResolvedPlugin } from "@corbits/connections/plugins"; import { listPluginsForTenant } from "@corbits/connections/plugins"; -import { CONNECTOR_REGISTRY } from "@corbits/connections/registry"; -import { MCP_PRESETS } from "@corbits/connections/mcp-presets"; +import { + CONNECTOR_REGISTRY, + MCP_PRESETS, +} from "@workbench/templates/connectors"; import { Plus, SquaresFour, Warning } from "@corbits/icons"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -130,7 +132,7 @@ export function PluginsRoute({ const isTenantChange = pluginsLoadedTenantRef.current !== selectedTenantId; pluginsLoadedTenantRef.current = selectedTenantId; if (isTenantChange) setPluginsState({ status: "loading" }); - listPluginsForTenant(selectedTenantId) + listPluginsForTenant(selectedTenantId, CONNECTOR_REGISTRY) .then((plugins) => { if (!cancelled) setPluginsState({ status: "ready", plugins }); }) diff --git a/apps/web/src/shell/provider-health-banner.tsx b/apps/web/src/shell/provider-health-banner.tsx index 4c032cf34..9b476afe9 100644 --- a/apps/web/src/shell/provider-health-banner.tsx +++ b/apps/web/src/shell/provider-health-banner.tsx @@ -16,6 +16,7 @@ import { Button } from "@corbits/react-ui"; import { connectorDescriptors } from "@corbits/connections/registry"; import type { ClassifiedInferenceFailureCategory } from "@corbits/connections/provider-health"; +import { CONNECTOR_REGISTRY } from "@workbench/templates/connectors"; import { Warning, X } from "@corbits/icons"; import { useEffect, useState } from "react"; @@ -57,7 +58,9 @@ const POLL_ERROR_COPY = "Couldn't check provider health. Try again shortly."; // expected in practice, but a caller should still see something rather // than nothing). function providerDisplayName(provider: string): string { - const descriptor = connectorDescriptors().find((d) => d.id === provider); + const descriptor = connectorDescriptors(CONNECTOR_REGISTRY).find( + (d) => d.id === provider, + ); return descriptor?.displayName ?? provider; } diff --git a/bun.lock b/bun.lock index 8eaa4cc5f..8f7f39c51 100644 --- a/bun.lock +++ b/bun.lock @@ -564,7 +564,6 @@ "@intx/hub-api": "workspace:*", "@intx/types": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", - "@workbench/templates": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", @@ -578,11 +577,12 @@ }, "packages/connections-tools": { "name": "@corbits/connections-tools", - "version": "0.0.6", + "version": "0.0.7", "dependencies": { "@corbits/connections": "workspace:*", "@intx/agent": "workspace:*", "@intx/types": "workspace:*", + "@workbench/templates": "workspace:*", "arktype": "catalog:", }, "devDependencies": { @@ -1063,6 +1063,7 @@ "@intx/hub-api": "workspace:*", "@intx/types": "workspace:*", "@workbench/access-policy": "workspace:*", + "@workbench/templates": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", @@ -1081,6 +1082,7 @@ "@corbits/icons": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#3b122812a307ccb35be31386f7696020c5a84635", "@corbits/settings-ui": "workspace:*", + "@workbench/templates": "workspace:*", "arktype": "catalog:", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -1527,6 +1529,7 @@ "devDependencies": { "@corbits/connections": "workspace:*", "@types/bun": "catalog:", + "@workbench/templates": "workspace:*", "typescript": "catalog:", }, }, @@ -1535,13 +1538,14 @@ "version": "0.0.1", "dependencies": { "@corbits/code-review": "workspace:*", + "@corbits/connections": "workspace:*", "@corbits/jimmy-agent": "workspace:*", "@corbits/scout-agent": "workspace:*", "@corbits/workflows": "workspace:*", "arktype": "catalog:", + "simple-icons": "^16.28.0", }, "devDependencies": { - "@corbits/connections": "workspace:*", "@types/bun": "catalog:", "typescript": "catalog:", }, @@ -3577,7 +3581,9 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/artifacts-hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#81049ed", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-81049ed", "sha512-oTE0iFDyQdz0ifG1epo39pwaCaYaw19YcKXwfaZqAEQ56a1g9YIozXwH9CG4NaUTwcJKUeYGuNls6oJsMPisCw=="], + "@corbits/artifact-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/memory-hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], @@ -3601,7 +3607,9 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], - "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#81049ed", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-81049ed", "sha512-oTE0iFDyQdz0ifG1epo39pwaCaYaw19YcKXwfaZqAEQ56a1g9YIozXwH9CG4NaUTwcJKUeYGuNls6oJsMPisCw=="], + "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + + "@workbench/web/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], diff --git a/docs/connect-cards.md b/docs/connect-cards.md index 838cb2f80..1c6df9915 100644 --- a/docs/connect-cards.md +++ b/docs/connect-cards.md @@ -74,7 +74,8 @@ own template card does not ride that path. ## Gmail `gmail` is a pure hosted-OAuth connector -(`packages/connections/src/gmail-connect.ts` + the registry entry): +(`packages/connections/src/gmail-connect.ts` + its descriptor in +`templates/connectors.ts`): Google's code flow with PKCE, the `gmail.modify` scope, and `access_type=offline` so the exchange captures a refresh token — stored as the credential row's `refreshSecret` next to the one-hour access diff --git a/docs/credential-wiring.md b/docs/credential-wiring.md index d3e166b69..6cda553eb 100644 --- a/docs/credential-wiring.md +++ b/docs/credential-wiring.md @@ -66,11 +66,12 @@ All five plugins the sidecar registers mirror the same origin-pinning and `redirect: "manual"` protections; only the injected header (or its absence, for keyless MCP) differs. -`packages/connections` (the Connections-surface registry) is the other -place this plugin id needs to be correct: whatever seeds or registers a -Linear provider row there must set `plugin: "http-raw-authorization"`, -and a Manus row must set `plugin: "http-x-manus-api-key"`, matching this -doc, not the `"http"` default other connectors use. +`templates/connectors.ts` (the concrete connector set a build passes into +`@corbits/connections`) is the other place this plugin id needs to be +correct: a Linear connector descriptor there must set +`plugin: "http-raw-authorization"`, and a Manus one must set +`plugin: "http-x-manus-api-key"`, matching this doc, not the `"http"` +default other connectors use. ## Trust boundary diff --git a/packages/chat/src/chat-orchestrator.test.ts b/packages/chat/src/chat-orchestrator.test.ts index c66ae2ded..1d4cbb5ac 100644 --- a/packages/chat/src/chat-orchestrator.test.ts +++ b/packages/chat/src/chat-orchestrator.test.ts @@ -57,7 +57,16 @@ describe("createReplyPartsAccumulator", () => { }); test("a missing-credential detail renders the connect-service block naming the connector", () => { - const acc = createReplyPartsAccumulator(); + const acc = createReplyPartsAccumulator({ + github: { + id: "github", + displayName: "GitHub", + authKind: "api-key", + credentialPlugin: "http", + docsUrl: "https://github.com/settings/tokens", + feedsTools: ["@corbits/github-tools"], + }, + }); acc.onInferenceDone(AGENT_ADDRESS, [toolCallBlock("call_1")]); acc.onToolDone(AGENT_ADDRESS, { callId: "call_1", diff --git a/packages/chat/src/chat-orchestrator.ts b/packages/chat/src/chat-orchestrator.ts index 87294eb32..7b056301f 100644 --- a/packages/chat/src/chat-orchestrator.ts +++ b/packages/chat/src/chat-orchestrator.ts @@ -45,8 +45,8 @@ import { type ProviderHealthPort, } from "@corbits/connections/provider-health"; import { - CONNECTOR_REGISTRY, parseMissingCredentialDetail, + type ConnectorRegistry, } from "@corbits/connections/registry"; import { artifactPartsForFinalizedTurn } from "./artifact-delivery"; import type { ApproveBlockData } from "./blocks"; @@ -69,6 +69,12 @@ const log = getLogger(["chat", "orchestrator"]); export type ChatOrchestratorDeps = { db: DB["db"]; + /** The connector set this build ships (`@corbits/connections` carries + * none of its own, CL-7384) — used only to resolve a missing + * credential's `displayName` for the in-turn connect card. Absent + * falls back to the raw connector id, same as + * `MissingCredentialError`'s own default. */ + connectorRegistry?: ConnectorRegistry; store: Pick; /** * The room timeline every poster below writes to (CL-6327): an agent's @@ -227,7 +233,9 @@ function turnKeyFor(agentAddress: string, sessionId: string): string { * turn (see `turnKeyFor` below): reset the moment a turn's * `connector.reply` or turn-drop notice consumes it. */ -export function createReplyPartsAccumulator(): { +export function createReplyPartsAccumulator( + connectorRegistry?: ConnectorRegistry, +): { onInferenceDone(turnKey: string, blocks: ReplyContentBlock[]): void; onToolDone( turnKey: string, @@ -290,7 +298,7 @@ export function createReplyPartsAccumulator(): { : undefined; if (missingCredential !== undefined) { const displayName = - CONNECTOR_REGISTRY[missingCredential.connectorId]?.displayName ?? + connectorRegistry?.[missingCredential.connectorId]?.displayName ?? missingCredential.connectorId; parts.push({ kind: "block", @@ -1130,7 +1138,7 @@ export function createChatOrchestrator( >({ ttlMs: AGENT_TURN_STALE_MS, now }); // See `createReplyPartsAccumulator`'s own doc comment above. - const replyParts = createReplyPartsAccumulator(); + const replyParts = createReplyPartsAccumulator(deps.connectorRegistry); const unsubscribe = deps.events.on( "agent.event", diff --git a/packages/connections-tools/package.json b/packages/connections-tools/package.json index 8c33eca34..6a3169a24 100644 --- a/packages/connections-tools/package.json +++ b/packages/connections-tools/package.json @@ -16,6 +16,7 @@ "@intx/agent": "workspace:*", "@intx/types": "workspace:*", "@corbits/connections": "workspace:*", + "@workbench/templates": "workspace:*", "arktype": "catalog:" }, "devDependencies": { diff --git a/packages/connections-tools/src/tool.ts b/packages/connections-tools/src/tool.ts index 5af33cc4c..5e3be13aa 100644 --- a/packages/connections-tools/src/tool.ts +++ b/packages/connections-tools/src/tool.ts @@ -30,8 +30,11 @@ import { defineTool } from "@intx/agent"; import type { BaseEnv } from "@intx/agent"; import type { ToolCall, ToolResult } from "@intx/types/runtime"; -import { CONNECTOR_REGISTRY } from "@corbits/connections"; -import { MCP_PRESETS, mcpPresetByName } from "@corbits/connections/mcp-presets"; +import { mcpPresetByName } from "@corbits/connections/mcp-presets"; +import { + CONNECTOR_REGISTRY, + MCP_PRESETS, +} from "@workbench/templates/connectors"; import { type } from "arktype"; import { @@ -232,7 +235,7 @@ async function runRequestConnection( // to "connect Exa" should land on that MCP card, not the old api-key // one, which `settings-ui`/`plugins-ui` no longer render as a separate // card at all. - const preset = mcpPresetByName(parsed.connector); + const preset = mcpPresetByName(MCP_PRESETS, parsed.connector); if (preset !== undefined) { try { const mcpServers = await listMcpServerConnections(clientConfig(env)); diff --git a/packages/connections/README.md b/packages/connections/README.md index 82f434038..287dad4dc 100644 --- a/packages/connections/README.md +++ b/packages/connections/README.md @@ -1,9 +1,15 @@ # @corbits/connections The Connections settings surface: shared PKCE/state primitives for OAuth -connect flows, the connector registry (inference providers plus -tool-credential connectors), and the route factories that test, store, and -resolve a connector's credential across a tenant chain. +connect flows, the `ConnectorDescriptor`/`ConnectorRegistry` shape (and +the curated `McpPreset` shape), and the route factories that test, store, +and resolve a connector's credential across a tenant chain. This package +carries no concrete connector set of its own (CL-7384) — a caller builds +one with `createConnectorRegistry` and passes it into every factory that +needs one. Workbench's own connector set (the inference providers plus +Granola, Exa, ScrapeCreators, Linear, GitHub, Gmail, and the curated MCP +presets) lives in `templates/connectors.ts`, the product-data package, +never here. ## How it composes with Interchange @@ -21,10 +27,11 @@ resolve a connector's credential across a tenant chain. ## Key modules -- `registry.ts` — the browser-safe `ConnectorDescriptor` map for every - api-key and OAuth connector (inference providers plus Granola, Exa, - ScrapeCreators, Linear, GitHub); imported directly by `settings-ui` - without pulling in `hono` or `@intx/inference`. +- `registry.ts` — the browser-safe `ConnectorDescriptor`/`ConnectorRegistry` + shape and `createConnectorRegistry`/`connectorDescriptors` helpers; no + concrete connector set lives here (see `templates/connectors.ts`). + Imported directly by `settings-ui` without pulling in `hono` or + `@intx/inference`. - `descriptor.ts` — the `ConnectorDescriptor`/`ConnectorOAuthConfig` shape every registry entry implements. - `routes.ts` — tenant-scoped `POST /:connectorId/complete`: proves an diff --git a/packages/connections/package.json b/packages/connections/package.json index ae3ca8c57..845c8cbfa 100644 --- a/packages/connections/package.json +++ b/packages/connections/package.json @@ -16,7 +16,13 @@ "./connect-github-setup": "./src/connect-github-setup.ts", "./reconcile-duplicate-repo-grants": "./src/reconcile-duplicate-repo-grants.ts", "./credential-test": "./src/credential-test.ts", - "./model-capability": "./src/model-capability.ts" + "./model-capability": "./src/model-capability.ts", + "./template-settings": "./src/template-settings.ts", + "./probes": "./src/probes.ts", + "./huggingface-connect": "./src/huggingface-connect.ts", + "./gmail-connect": "./src/gmail-connect.ts", + "./openrouter-connect": "./src/openrouter-connect.ts", + "./github-connect": "./src/github-connect.ts" }, "scripts": { "typecheck": "tsc --noEmit", @@ -36,7 +42,6 @@ "@modelcontextprotocol/sdk": "catalog:", "@corbits/hub-api-client": "workspace:*", "@corbits/seeding": "workspace:*", - "@workbench/templates": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", diff --git a/packages/connections/src/connect-github-credential-link.test.ts b/packages/connections/src/connect-github-credential-link.test.ts index 534648b62..6cc0ed8ca 100644 --- a/packages/connections/src/connect-github-credential-link.test.ts +++ b/packages/connections/src/connect-github-credential-link.test.ts @@ -15,13 +15,26 @@ import type { MiddlewareHandler } from "hono"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; import type { EnsureCredentialArgs } from "@corbits/seeding"; import type { ApiCall } from "@corbits/hub-api-client"; -import { - CONNECTOR_REGISTRY, - persistConnectorCredential, -} from "@corbits/connections"; +import { persistConnectorCredential } from "@corbits/connections"; +import type { ConnectorDescriptor } from "@corbits/connections/registry"; import { createConnectGithubRoutes } from "./connect-github-routes"; +/** A fixture standing in for the real `github` connector descriptor a + * build's own connector set (`templates/connectors.ts`) carries — this + * package holds no concrete connector set of its own (CL-7384), so its + * own tests build the minimal shape `persistConnectorCredential` and + * `createConnectGithubRoutes` actually read. */ +const GITHUB_DESCRIPTOR: ConnectorDescriptor = { + id: "github", + displayName: "GitHub", + authKind: "api-key", + credentialPlugin: "http", + docsUrl: "https://github.com/settings/tokens", + feedsTools: ["@corbits/github-tools"], + probe: async () => ({ ok: true }), +}; + const TENANT = { id: "tnt_1", name: "Acme", @@ -100,15 +113,8 @@ function buildResolveGithubConfig( }; } -/** `CONNECTOR_REGISTRY["github"]` is a fixed registry entry that always - * exists; a missing one is a broken test fixture, not a case to assert - * away with a non-null assertion. */ function requireGithubDescriptor() { - const descriptor = CONNECTOR_REGISTRY["github"]; - if (descriptor === undefined) { - throw new Error('CONNECTOR_REGISTRY has no "github" entry'); - } - return descriptor; + return GITHUB_DESCRIPTOR; } describe("the room GitHub connect card reads what its own submit writes", () => { diff --git a/packages/connections/src/connect-github-routes.ts b/packages/connections/src/connect-github-routes.ts index e78bcf2c0..bd7fff7a3 100644 --- a/packages/connections/src/connect-github-routes.ts +++ b/packages/connections/src/connect-github-routes.ts @@ -40,7 +40,7 @@ import { } from "@corbits/code-review/introductions"; import { startReviewingRepos } from "./connect-github-setup"; -import { templateReposSettingsPatch } from "@workbench/templates/settings"; +import { templateReposSettingsPatch } from "./template-settings"; // `startReviewingRepos`' own selected-repo mismatch throw and a GitHub // client failure both carry provider/internal detail (a rejected repo id, diff --git a/packages/connections/src/credential-error.test.ts b/packages/connections/src/credential-error.test.ts index a653cfa3d..802b94dde 100644 --- a/packages/connections/src/credential-error.test.ts +++ b/packages/connections/src/credential-error.test.ts @@ -3,8 +3,8 @@ import { describe, expect, test } from "bun:test"; import { MissingCredentialError } from "./credential-error"; describe("MissingCredentialError", () => { - test("names the connector by its consumer-facing display name", () => { - const error = new MissingCredentialError("github"); + test("names the connector by a caller-supplied display name", () => { + const error = new MissingCredentialError("github", "GitHub"); expect(error.name).toBe("MissingCredentialError"); expect(error.connectorId).toBe("github"); @@ -12,7 +12,7 @@ describe("MissingCredentialError", () => { expect(error.message).toBe("GitHub is not connected."); }); - test("falls back to the raw connector id when it has no registry entry", () => { + test("falls back to the raw connector id when no display name is given", () => { const error = new MissingCredentialError("not-a-real-connector"); expect(error.connectorId).toBe("not-a-real-connector"); diff --git a/packages/connections/src/credential-error.ts b/packages/connections/src/credential-error.ts index 387221db0..c0d3a8a40 100644 --- a/packages/connections/src/credential-error.ts +++ b/packages/connections/src/credential-error.ts @@ -6,22 +6,19 @@ // generic `Error` string, and every tool package bakes its own // hardcoded "not connected" prose instead of naming the connector // structurally. This class is the shared, identifiable shape a thrower -// and a catcher can agree on. `displayName` comes straight from -// `CONNECTOR_REGISTRY` — the one place a connector's consumer-facing -// name lives — so nothing downstream re-derives or hand-writes it, and -// a caller never has to fall back to showing the raw connector id. -import { CONNECTOR_REGISTRY } from "./registry"; - +// and a catcher can agree on. `displayName` is an optional caller-supplied +// override (this package carries no connector set of its own to look one +// up in, CL-7384) — a caller with a registry handy passes the connector's +// `displayName`; one without falls back to the raw connector id. export class MissingCredentialError extends Error { readonly connectorId: string; readonly displayName: string; - constructor(connectorId: string) { - const displayName = - CONNECTOR_REGISTRY[connectorId]?.displayName ?? connectorId; - super(`${displayName} is not connected.`); + constructor(connectorId: string, displayName?: string) { + const resolvedDisplayName = displayName ?? connectorId; + super(`${resolvedDisplayName} is not connected.`); this.name = "MissingCredentialError"; this.connectorId = connectorId; - this.displayName = displayName; + this.displayName = resolvedDisplayName; } } diff --git a/packages/connections/src/index.ts b/packages/connections/src/index.ts index 6a1316434..0a1270a40 100644 --- a/packages/connections/src/index.ts +++ b/packages/connections/src/index.ts @@ -19,7 +19,11 @@ export type { ConnectorOAuthConfig, OAuthExchangeResult, } from "./descriptor"; -export { CONNECTOR_REGISTRY, connectorDescriptors } from "./registry"; +export { + connectorDescriptors, + createConnectorRegistry, + type ConnectorRegistry, +} from "./registry"; export { MissingCredentialError } from "./credential-error"; export { missingCredentialDetail, @@ -48,11 +52,11 @@ export { } from "./mcp-server-routes"; export { probeMcpServer, type McpProbeResult } from "./mcp-probe"; export { - MCP_PRESETS, - MCP_PRESET_CONNECTOR_IDS, mcpPresetBySlug, mcpPresetByName, + mcpPresetConnectorIds, type McpPreset, + type McpPresetConnectionMode, } from "./mcp-presets"; export { createMcpOAuthProvider, diff --git a/packages/connections/src/mcp-oauth-routes.test.ts b/packages/connections/src/mcp-oauth-routes.test.ts index 70c77066a..ae06b280c 100644 --- a/packages/connections/src/mcp-oauth-routes.test.ts +++ b/packages/connections/src/mcp-oauth-routes.test.ts @@ -15,9 +15,59 @@ import { createNoopCredentialCipher } from "@intx/crypto"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; import type { ApiCall } from "@corbits/hub-api-client"; import { createMcpOAuthRoutes } from "./mcp-oauth-routes"; -import { mcpPresetBySlug } from "./mcp-presets"; +import { mcpPresetBySlug, type McpPreset } from "./mcp-presets"; import type { McpProbeResult } from "./mcp-probe"; +/** Fixtures standing in for the real curated preset list a build's own + * `templates/connectors.ts` carries — this package holds no concrete + * preset list of its own (CL-7384). */ +const TEST_PRESETS: readonly McpPreset[] = [ + { + slug: "exa", + displayName: "Exa", + description: "Search and research the live web.", + url: "https://mcp.exa.ai/mcp", + connectionMode: "keyless", + docsUrl: "https://docs.exa.ai/reference/exa-mcp", + nativeConnectorId: "exa", + }, + { + slug: "github-mcp", + displayName: "GitHub MCP", + description: "Search code, work with issues and pull requests.", + url: "https://api.githubcopilot.com/mcp/", + connectionMode: "token", + docsUrl: "https://github.com/settings/tokens", + tokenSteps: ["Open github.com/settings/tokens and generate a new token."], + }, + { + slug: "canva", + displayName: "Canva", + description: "Design decks, docs, and graphics.", + url: "https://mcp.canva.com/mcp", + connectionMode: "oauth", + docsUrl: "https://www.canva.dev/docs/mcp/", + oauthScopes: [ + "profile:read", + "design:meta:read", + "design:content:write", + "design:content:read", + "folder:read", + "folder:write", + "brandtemplate:content:read", + "brandtemplate:meta:read", + "brandtemplate:content:write", + "comment:write", + "comment:read", + "asset:read", + "asset:write", + "brandkit:read", + "help:answers:read", + "help:answers:write", + ], + }, +]; + const TENANT = { id: "tnt_1", name: "Acme", @@ -322,6 +372,7 @@ describe("MCP OAuth connect flow", () => { requireGrant: allowAll, log: () => {}, credentialCipher: createNoopCredentialCipher(), + presets: TEST_PRESETS, apiCall: hub.apiCall, }); const app = mountAs(routes); @@ -398,6 +449,7 @@ describe("MCP OAuth connect flow", () => { requireGrant: allowAll, log: () => {}, credentialCipher: createNoopCredentialCipher(), + presets: TEST_PRESETS, apiCall: hub.apiCall, }); const app = mountAs(routes); @@ -410,7 +462,7 @@ describe("MCP OAuth connect flow", () => { const location = response.headers.get("location") ?? ""; expect(location.startsWith(`${canvaOrigin}/authorize`)).toBe(true); expect(as.registrationBodies).toHaveLength(1); - const canvaScopes = mcpPresetBySlug("canva")?.oauthScopes; + const canvaScopes = mcpPresetBySlug(TEST_PRESETS, "canva")?.oauthScopes; expect(canvaScopes).toHaveLength(16); const dcrBody = as.registrationBodies[0]; expect(dcrBody).toEqual( @@ -471,6 +523,7 @@ describe("MCP OAuth connect flow", () => { requireGrant: allowAll, log: () => {}, credentialCipher: createNoopCredentialCipher(), + presets: TEST_PRESETS, apiCall: hub.apiCall, }); const app = mountAs(routes); @@ -537,6 +590,7 @@ describe("MCP OAuth connect flow", () => { requireGrant: allowAll, log: () => {}, credentialCipher: createNoopCredentialCipher(), + presets: TEST_PRESETS, apiCall: hub.apiCall, }); const app = mountAs(routes); @@ -602,6 +656,7 @@ describe("MCP OAuth connect flow", () => { requireGrant: allowAll, log: () => {}, credentialCipher: createNoopCredentialCipher(), + presets: TEST_PRESETS, apiCall: hub.apiCall, }); const app = mountAs(routes); @@ -629,6 +684,7 @@ describe("MCP OAuth connect flow", () => { requireGrant: allowAll, log: () => {}, credentialCipher: createNoopCredentialCipher(), + presets: TEST_PRESETS, apiCall: hub.apiCall, }); const app = mountAs(routes); @@ -659,6 +715,7 @@ describe("MCP OAuth connect flow", () => { requireGrant: allowAll, log: () => {}, credentialCipher: createNoopCredentialCipher(), + presets: TEST_PRESETS, apiCall: hub.apiCall, probe: async (_url, token): Promise => { probedToken = token; @@ -696,6 +753,7 @@ describe("MCP OAuth connect flow", () => { requireGrant: allowAll, log: () => {}, credentialCipher: createNoopCredentialCipher(), + presets: TEST_PRESETS, apiCall: hub.apiCall, }); const app = mountAs(routes); @@ -757,6 +815,7 @@ describe("MCP OAuth connect flow", () => { requireGrant: allowAll, log: () => {}, credentialCipher: createNoopCredentialCipher(), + presets: TEST_PRESETS, apiCall: failingApiCall, probe: async (): Promise => ({ ok: true, @@ -798,6 +857,7 @@ describe("MCP OAuth connect flow", () => { requireGrant: allowAll, log: () => {}, credentialCipher: createNoopCredentialCipher(), + presets: TEST_PRESETS, apiCall: hub.apiCall, probe: async (): Promise => ({ ok: true, @@ -835,6 +895,7 @@ describe("MCP OAuth connect flow", () => { requireGrant: allowAll, log: () => {}, credentialCipher: createNoopCredentialCipher(), + presets: TEST_PRESETS, apiCall: hub.apiCall, probe: async (): Promise => ({ ok: true, @@ -865,6 +926,7 @@ describe("MCP OAuth connect flow", () => { requireGrant: allowAll, log: () => {}, credentialCipher: createNoopCredentialCipher(), + presets: TEST_PRESETS, apiCall: hub.apiCall, }); const app = mountAs(routes); @@ -884,6 +946,7 @@ describe("MCP OAuth connect flow", () => { requireGrant: allowAll, log: () => {}, credentialCipher: createNoopCredentialCipher(), + presets: TEST_PRESETS, apiCall: hub.apiCall, }); const app = mountAs(routes); @@ -909,6 +972,7 @@ describe("MCP OAuth connect flow", () => { requireGrant: allowAll, log: () => {}, credentialCipher: createNoopCredentialCipher(), + presets: TEST_PRESETS, apiCall: hub.apiCall, probe: async (): Promise => ({ ok: true, @@ -937,6 +1001,7 @@ describe("MCP OAuth connect flow", () => { requireGrant: allowAll, log: () => {}, credentialCipher: createNoopCredentialCipher(), + presets: TEST_PRESETS, apiCall: hub.apiCall, probe: async (): Promise => ({ ok: true, @@ -970,6 +1035,7 @@ describe("MCP OAuth connect flow", () => { requireGrant: allowAll, log: () => {}, credentialCipher: createNoopCredentialCipher(), + presets: TEST_PRESETS, apiCall: hub.apiCall, probe: async (): Promise => ({ ok: true, @@ -1026,6 +1092,7 @@ describe("MCP OAuth connect flow", () => { requireGrant: allowAll, log: () => {}, credentialCipher: createNoopCredentialCipher(), + presets: TEST_PRESETS, apiCall: hub.apiCall, }); const app = mountAs(routes); @@ -1050,6 +1117,7 @@ describe("onConnected hook", () => { requireGrant: allowAll, log: () => {}, credentialCipher: createNoopCredentialCipher(), + presets: TEST_PRESETS, apiCall: hub.apiCall, probe: async (): Promise => ({ ok: true, diff --git a/packages/connections/src/mcp-oauth-routes.ts b/packages/connections/src/mcp-oauth-routes.ts index 796321e4e..4b5fdba1a 100644 --- a/packages/connections/src/mcp-oauth-routes.ts +++ b/packages/connections/src/mcp-oauth-routes.ts @@ -32,7 +32,7 @@ import type { OAuthClientInformationMixed } from "@modelcontextprotocol/sdk/shar import { createMcpOAuthProvider, type McpOAuthSession } from "./mcp-oauth"; import { createConnectStateStore, randomToken } from "./pkce"; import { fireConnectedHook, type ServiceConnectedHook } from "./connected-hook"; -import { mcpPresetBySlug } from "./mcp-presets"; +import { mcpPresetBySlug, type McpPreset } from "./mcp-presets"; import { probeMcpServer, type McpProbeResult } from "./mcp-probe"; import { reportError } from "@corbits/error-sink"; import { @@ -172,6 +172,10 @@ export type CreateMcpOAuthRoutesDeps = { requireGrant: RequireGrant; log: (line: string) => void; credentialCipher: CredentialCipher; + /** The curated MCP preset list this build ships — this package + * carries none of its own (CL-7384), so a caller always supplies + * one. */ + presets: readonly McpPreset[]; apiCall?: ApiCall; probe?: typeof probeMcpServer; defaultReturnPath?: string; @@ -184,6 +188,7 @@ export type CreateMcpOAuthRoutesDeps = { }; function resolveTarget( + presets: readonly McpPreset[], slugParam: string, queryUrl: string | undefined, queryName: string | undefined, @@ -193,7 +198,7 @@ function resolveTarget( queryName !== undefined && queryName.length > 0 ? queryName : slugParam; return { slug: slugParam, name, url: queryUrl }; } - const preset = mcpPresetBySlug(slugParam); + const preset = mcpPresetBySlug(presets, slugParam); if (preset === undefined) return undefined; return { slug: preset.slug, name: preset.displayName, url: preset.url }; } @@ -229,6 +234,7 @@ export function createMcpOAuthRoutes( async (c) => { const slugParam = c.req.param("slug"); const target = resolveTarget( + deps.presets, slugParam, c.req.query("url"), c.req.query("name"), @@ -251,7 +257,7 @@ export function createMcpOAuthRoutes( const queryUrl = c.req.query("url"); const preset = queryUrl === undefined || queryUrl.length === 0 - ? mcpPresetBySlug(slugParam) + ? mcpPresetBySlug(deps.presets, slugParam) : undefined; if (preset !== undefined && preset.connectionMode !== "oauth") { // A keyless or token preset has no OAuth dance to start — refuse @@ -446,7 +452,7 @@ export function createMcpOAuthRoutes( } : {}), }; - const callbackPreset = mcpPresetBySlug(payload.slug); + const callbackPreset = mcpPresetBySlug(deps.presets, payload.slug); const provider = createMcpOAuthProvider({ callbackUrl, clientName: "Corbits Workbench", diff --git a/packages/connections/src/mcp-presets.test.ts b/packages/connections/src/mcp-presets.test.ts deleted file mode 100644 index b757aae67..000000000 --- a/packages/connections/src/mcp-presets.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - MCP_PRESET_CONNECTOR_IDS, - MCP_PRESETS, - mcpPresetByName, - mcpPresetBySlug, -} from "./mcp-presets"; -import { CONNECTOR_REGISTRY } from "./registry"; - -describe("MCP_PRESETS", () => { - test("lists only verified zero-configuration remote MCP servers", () => { - const slugs = MCP_PRESETS.map((preset) => preset.slug).sort(); - expect(slugs).toEqual( - [ - "attio", - "canva", - "exa", - "github-mcp", - "granola", - "linear", - "notion", - "posthog", - "railway", - "sentry", - "sumble", - ].sort(), - ); - for (const preset of MCP_PRESETS) { - expect(() => new URL(preset.url)).not.toThrow(); - expect(preset.url.startsWith("https://")).toBe(true); - } - }); - - test("Exa is keyless, GitHub MCP is token, every other preset uses OAuth", () => { - expect(mcpPresetBySlug("exa")?.connectionMode).toBe("keyless"); - expect(mcpPresetBySlug("github-mcp")?.connectionMode).toBe("token"); - for (const preset of MCP_PRESETS) { - if (preset.slug === "exa" || preset.slug === "github-mcp") continue; - expect(preset.connectionMode).toBe("oauth"); - } - }); - - test("GitHub MCP is a token preset that never shadows the github connector", () => { - const preset = mcpPresetBySlug("github-mcp"); - expect(preset?.displayName).toBe("GitHub MCP"); - expect(preset?.url).toBe("https://api.githubcopilot.com/mcp/"); - // GitHub's MCP server accepts a personal access token as a bearer but - // offers no dynamic client registration, so OAuth can't complete here. - expect(preset?.connectionMode).toBe("token"); - expect(preset?.docsUrl).toBe("https://github.com/settings/tokens"); - expect(preset?.tokenSteps?.length).toBeGreaterThanOrEqual(2); - // The native `github` REST connector (PAT/OAuth-App) stays its own - // card: no nativeConnectorId hides it, and neither "github" the slug - // nor "GitHub" the display name resolves to this preset. - expect(preset?.nativeConnectorId).toBeUndefined(); - expect(CONNECTOR_REGISTRY["github"]).toBeDefined(); - expect(mcpPresetByName("github")).toBeUndefined(); - expect(mcpPresetByName("GitHub")).toBeUndefined(); - expect(mcpPresetByName("github mcp")?.slug).toBe("github-mcp"); - }); - - test("uses Sumble's OAuth MCP host, not its product-page URL", () => { - expect(mcpPresetBySlug("sumble")?.url).toBe("https://mcp.sumble.com/"); - expect(mcpPresetBySlug("sumble")?.connectionMode).toBe("oauth"); - }); - - test("keeps every verified endpoint exact", () => { - expect( - Object.fromEntries( - MCP_PRESETS.map((preset) => [preset.slug, preset.url]), - ), - ).toEqual({ - granola: "https://mcp.granola.ai/mcp", - exa: "https://mcp.exa.ai/mcp", - "github-mcp": "https://api.githubcopilot.com/mcp/", - linear: "https://mcp.linear.app/mcp", - notion: "https://mcp.notion.com/mcp", - sentry: "https://mcp.sentry.dev/mcp", - attio: "https://mcp.attio.com/mcp", - railway: "https://mcp.railway.com", - posthog: "https://mcp.posthog.com/mcp", - sumble: "https://mcp.sumble.com/", - canva: "https://mcp.canva.com/mcp", - }); - }); - - test("every preset's nativeConnectorId names a real registry entry", () => { - for (const preset of MCP_PRESETS) { - if (preset.nativeConnectorId === undefined) continue; - expect(CONNECTOR_REGISTRY[preset.nativeConnectorId]).toBeDefined(); - } - }); - - test("MCP_PRESET_CONNECTOR_IDS matches the presets' native ids", () => { - expect(new Set(MCP_PRESET_CONNECTOR_IDS)).toEqual( - new Set(["granola", "exa", "linear"]), - ); - }); - - test("mcpPresetByName resolves by slug or display name, case-insensitively", () => { - expect(mcpPresetByName("Exa")?.slug).toBe("exa"); - expect(mcpPresetByName("EXA")?.slug).toBe("exa"); - expect(mcpPresetByName("granola")?.slug).toBe("granola"); - expect(mcpPresetByName("nope")).toBeUndefined(); - }); - - test("mcpPresetBySlug returns undefined for an unknown slug", () => { - expect(mcpPresetBySlug("not-a-preset")).toBeUndefined(); - }); - - test("Canva is a DCR-verified OAuth preset with no simple-icons mark yet", () => { - expect(mcpPresetBySlug("canva")?.connectionMode).toBe("oauth"); - expect(mcpPresetBySlug("canva")?.icon).toBeUndefined(); - }); - - test("Canva lists the 16 live PRM OAuth scopes; other presets omit oauthScopes", () => { - expect(mcpPresetBySlug("canva")?.oauthScopes).toEqual([ - "profile:read", - "design:meta:read", - "design:content:write", - "design:content:read", - "folder:read", - "folder:write", - "brandtemplate:content:read", - "brandtemplate:meta:read", - "brandtemplate:content:write", - "comment:write", - "comment:read", - "asset:read", - "asset:write", - "brandkit:read", - "help:answers:read", - "help:answers:write", - ]); - for (const preset of MCP_PRESETS) { - if (preset.slug === "canva") continue; - expect("oauthScopes" in preset).toBe(false); - } - }); -}); diff --git a/packages/connections/src/mcp-presets.ts b/packages/connections/src/mcp-presets.ts index faea601e4..9d702d024 100644 --- a/packages/connections/src/mcp-presets.ts +++ b/packages/connections/src/mcp-presets.ts @@ -1,11 +1,7 @@ -import { - siGithub, - siNotion, - siPosthog, - siRailway, - siSentry, -} from "simple-icons"; - +// The `McpPreset` shape and the generic helpers a caller needs to work +// with a preset list — the concrete curated presets themselves (Granola, +// Exa, Linear, GitHub MCP, ...) moved to `templates/connectors.ts` as +// part of CL-7384, alongside `./registry.ts`'s connector descriptors. export type McpPresetConnectionMode = "oauth" | "keyless" | "token"; export type McpPreset = { @@ -27,156 +23,27 @@ export type McpPreset = { readonly oauthScopes?: readonly string[]; }; -/** - * Curated remote MCP servers that Workbench can connect without asking a - * person for a URL, API key, client id, or client secret. OAuth entries have - * been verified to advertise OAuth discovery, PKCE, and dynamic client - * registration; keyless entries can be probed and stored immediately; token - * entries accept a pasted access token as the bearer (GitHub's MCP server - * does OAuth only for clients pre-registered with GitHub — it offers no - * dynamic client registration — so its connect here is the token walk). - */ -export const MCP_PRESETS: readonly McpPreset[] = [ - { - slug: "granola", - displayName: "Granola", - description: "Search meeting notes, transcripts, and action items.", - url: "https://mcp.granola.ai/mcp", - connectionMode: "oauth", - docsUrl: "https://docs.granola.ai/help-center/sharing/integrations/mcp", - nativeConnectorId: "granola", - }, - { - slug: "exa", - displayName: "Exa", - description: "Search and research the live web.", - url: "https://mcp.exa.ai/mcp", - connectionMode: "keyless", - docsUrl: "https://docs.exa.ai/reference/exa-mcp", - nativeConnectorId: "exa", - }, - { - slug: "linear", - displayName: "Linear", - description: "Read and update issues, projects, and comments.", - url: "https://mcp.linear.app/mcp", - connectionMode: "oauth", - docsUrl: "https://linear.app/docs/mcp", - nativeConnectorId: "linear", - }, - { - // Deliberately NOT slug "github" and NOT nativeConnectorId "github": - // the native GitHub connector (PAT/OAuth-App feeding - // @corbits/github-tools) stays its own card, and preset lookups by - // "github" must keep resolving to it, never here. - slug: "github-mcp", - displayName: "GitHub MCP", - description: "Search code, work with issues and pull requests.", - url: "https://api.githubcopilot.com/mcp/", - connectionMode: "token", - docsUrl: "https://github.com/settings/tokens", - icon: { path: siGithub.path, hex: siGithub.hex }, - tokenSteps: [ - "Open github.com/settings/tokens and generate a new token.", - "Give it the repo scope — that lets agents read code, issues, and pull requests.", - "Paste it below. It's stored encrypted, only your agents use it, and you can disconnect any time.", - ], - }, - { - 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", - icon: { path: siNotion.path, hex: siNotion.hex }, - }, - { - slug: "sentry", - displayName: "Sentry", - description: "Investigate errors, traces, releases, and projects.", - url: "https://mcp.sentry.dev/mcp", - connectionMode: "oauth", - docsUrl: "https://mcp.sentry.dev/", - icon: { path: siSentry.path, hex: siSentry.hex }, - }, - { - 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", - }, - { - 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", - icon: { path: siRailway.path, hex: siRailway.hex }, - }, - { - 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", - icon: { path: siPosthog.path, hex: siPosthog.hex }, - }, - { - 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", - }, - { - // No simple-icons listing for Canva (CL-6647) — same gap as Granola - // and Sumble above, so this card falls back to the initial-letter - // tile rather than risk a hand-traced or doctored mark. - slug: "canva", - displayName: "Canva", - description: "Design decks, docs, and graphics.", - url: "https://mcp.canva.com/mcp", - connectionMode: "oauth", - docsUrl: "https://www.canva.dev/docs/mcp/", - oauthScopes: [ - "profile:read", - "design:meta:read", - "design:content:write", - "design:content:read", - "folder:read", - "folder:write", - "brandtemplate:content:read", - "brandtemplate:meta:read", - "brandtemplate:content:write", - "comment:write", - "comment:read", - "asset:read", - "asset:write", - "brandkit:read", - "help:answers:read", - "help:answers:write", - ], - }, -]; - -export const MCP_PRESET_CONNECTOR_IDS: readonly string[] = MCP_PRESETS.flatMap( - (preset) => +export function mcpPresetConnectorIds( + presets: readonly McpPreset[], +): readonly string[] { + return presets.flatMap((preset) => preset.nativeConnectorId === undefined ? [] : [preset.nativeConnectorId], -); + ); +} -export function mcpPresetBySlug(slug: string): McpPreset | undefined { - return MCP_PRESETS.find((preset) => preset.slug === slug); +export function mcpPresetBySlug( + presets: readonly McpPreset[], + slug: string, +): McpPreset | undefined { + return presets.find((preset) => preset.slug === slug); } -export function mcpPresetByName(name: string): McpPreset | undefined { +export function mcpPresetByName( + presets: readonly McpPreset[], + name: string, +): McpPreset | undefined { const needle = name.trim().toLowerCase(); - return MCP_PRESETS.find( + return presets.find( (preset) => preset.slug === needle || preset.displayName.toLowerCase() === needle, ); diff --git a/packages/connections/src/mcp-server-routes.test.ts b/packages/connections/src/mcp-server-routes.test.ts index 06df00568..68c32e79a 100644 --- a/packages/connections/src/mcp-server-routes.test.ts +++ b/packages/connections/src/mcp-server-routes.test.ts @@ -10,6 +10,57 @@ import type { RequireGrant, TenantEnv } from "@intx/hub-api"; import type { ApiCall } from "@corbits/hub-api-client"; import { createMcpServerRoutes } from "./mcp-server-routes"; import type { McpProbeResult } from "./mcp-probe"; +import type { McpPreset } from "./mcp-presets"; + +/** Fixtures standing in for the real curated preset list a build's own + * `templates/connectors.ts` carries — this package holds no concrete + * preset list of its own (CL-7384). */ +const TEST_PRESETS: readonly McpPreset[] = [ + { + slug: "granola", + displayName: "Granola", + description: "Search meeting notes, transcripts, and action items.", + url: "https://mcp.granola.ai/mcp", + connectionMode: "oauth", + docsUrl: "https://docs.granola.ai/help-center/sharing/integrations/mcp", + nativeConnectorId: "granola", + }, + { + slug: "exa", + displayName: "Exa", + description: "Search and research the live web.", + url: "https://mcp.exa.ai/mcp", + connectionMode: "keyless", + docsUrl: "https://docs.exa.ai/reference/exa-mcp", + nativeConnectorId: "exa", + }, + { + slug: "github-mcp", + displayName: "GitHub MCP", + description: "Search code, work with issues and pull requests.", + url: "https://api.githubcopilot.com/mcp/", + connectionMode: "token", + docsUrl: "https://github.com/settings/tokens", + tokenSteps: ["Open github.com/settings/tokens and generate a new token."], + }, + { + slug: "linear", + displayName: "Linear", + description: "Read and update issues, projects, and comments.", + url: "https://mcp.linear.app/mcp", + connectionMode: "oauth", + docsUrl: "https://linear.app/docs/mcp", + nativeConnectorId: "linear", + }, + { + 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", + }, +]; const TENANT = { id: "tnt_1", @@ -173,6 +224,7 @@ function buildApp(opts: { hubUrl: "http://hub.test", requireGrant: opts.requireGrant ?? allowAll, log: () => {}, + presets: TEST_PRESETS, apiCall: opts.apiCall, probe: opts.probe ?? (async () => ({ ok: true, toolCount: 3 })), ...(opts.onConnected !== undefined diff --git a/packages/connections/src/mcp-server-routes.ts b/packages/connections/src/mcp-server-routes.ts index 46c460c46..4be62979f 100644 --- a/packages/connections/src/mcp-server-routes.ts +++ b/packages/connections/src/mcp-server-routes.ts @@ -34,7 +34,7 @@ import { import { probeMcpServer } from "./mcp-probe"; import { fireConnectedHook, type ServiceConnectedHook } from "./connected-hook"; import { makeErrorEnvelope, reportError } from "@corbits/error-sink"; -import { MCP_PRESETS, mcpPresetBySlug } from "./mcp-presets"; +import { mcpPresetBySlug, type McpPreset } from "./mcp-presets"; /** Every stored MCP server provider is named `mcp:` — the same * convention `@corbits/mcp-tools`' `mcpCredentialHandle` builds from the @@ -94,6 +94,10 @@ export type CreateMcpServerRoutesDeps = { hubUrl: string; requireGrant: RequireGrant; log: (line: string) => void; + /** The curated MCP preset list this build ships — this package + * carries none of its own (CL-7384), so a caller always supplies + * one. */ + presets: readonly McpPreset[]; /** Test-only override, matching `routes.ts`' own `ensureProviderFn` * pattern — lets `mcp-server-routes.test.ts` stub credential storage * without a network. */ @@ -195,7 +199,7 @@ export function createMcpServerRoutes( const providerBySlug = new Map( providers.map((provider) => [slugOf(provider.name), provider]), ); - const presets = MCP_PRESETS.map((preset) => { + const presets = deps.presets.map((preset) => { const provider = providerBySlug.get(preset.slug); const credential = provider === undefined @@ -235,7 +239,7 @@ export function createMcpServerRoutes( const preset = parsed.presetSlug !== undefined - ? mcpPresetBySlug(parsed.presetSlug) + ? mcpPresetBySlug(deps.presets, parsed.presetSlug) : undefined; if (parsed.presetSlug !== undefined && preset === undefined) { return c.json( diff --git a/packages/connections/src/oauth-routes.ts b/packages/connections/src/oauth-routes.ts index 8292d3cd9..0178a652b 100644 --- a/packages/connections/src/oauth-routes.ts +++ b/packages/connections/src/oauth-routes.ts @@ -44,7 +44,6 @@ import { import { fireConnectedHook, type ServiceConnectedHook } from "./connected-hook"; import { reportError } from "@corbits/error-sink"; import type { ConnectorDescriptor } from "./descriptor"; -import { CONNECTOR_REGISTRY } from "./registry"; /** * The two return surfaces this wave's callers actually mount at @@ -157,8 +156,9 @@ export type CreateOAuthConnectRoutesDeps = { * `/callback`. The same `CredentialCipher` every other secret-at-rest * seam in the hub shares. */ readonly credentialCipher: CredentialCipher; - /** Test-only override, defaulting to `CONNECTOR_REGISTRY`. */ - readonly registry?: Readonly>; + /** The connector set this build ships — this package carries none of + * its own (CL-7384), so a caller always supplies one. */ + readonly registry: Readonly>; /** The env bag a descriptor's `oauth.clientId(env)` reads a * registered app id from (e.g. `{huggingfaceClientId}`). */ readonly oauthEnv?: Readonly>; @@ -246,7 +246,7 @@ export function createOAuthConnectRoutes( deps: CreateOAuthConnectRoutesDeps, ): Hono { const app = new Hono(); - const registry = deps.registry ?? CONNECTOR_REGISTRY; + const registry = deps.registry; const oauthEnv = deps.oauthEnv ?? {}; const defaultReturnPath = deps.defaultReturnPath ?? "/onboarding"; const returnPathAllowlist = diff --git a/packages/connections/src/oauth-tenant-connect.test.ts b/packages/connections/src/oauth-tenant-connect.test.ts index 5fc5dc30a..9f2847ae0 100644 --- a/packages/connections/src/oauth-tenant-connect.test.ts +++ b/packages/connections/src/oauth-tenant-connect.test.ts @@ -64,6 +64,7 @@ function mountTenantScoped( overrides: Parameters[0] = { hubUrl: "https://bench.example.com", log: () => undefined, + registry: {}, }, ): { app: Hono; @@ -252,6 +253,7 @@ describe("createTenantConnectCredential, mounted through createOAuthConnectRoute const { app } = mountTenantScoped({ hubUrl: "https://bench.example.com", log: () => undefined, + registry: {}, providerHealth, }); diff --git a/packages/connections/src/oauth-tenant-connect.ts b/packages/connections/src/oauth-tenant-connect.ts index 95d6030a5..7c8d69ed5 100644 --- a/packages/connections/src/oauth-tenant-connect.ts +++ b/packages/connections/src/oauth-tenant-connect.ts @@ -22,7 +22,6 @@ import { createHubAPI } from "@corbits/hub-api-client"; import { reportError } from "@corbits/error-sink"; import type { ConnectorDescriptor } from "./descriptor"; import type { ProviderHealthStore } from "./provider-health"; -import { CONNECTOR_REGISTRY } from "./registry"; import type { CreateOAuthConnectRoutesDeps } from "./oauth-routes"; import { persistConnectorCredential, @@ -33,8 +32,9 @@ export type CreateTenantConnectCredentialDeps = PersistConnectorCredentialFns & { readonly hubUrl: string; readonly log: (line: string) => void; - /** Test-only override, matching every other route factory here. */ - readonly registry?: Readonly>; + /** The connector set this build ships — this package carries none + * of its own (CL-7384), so a caller always supplies one. */ + readonly registry: Readonly>; /** Cleared on a successful connect, same store `createConnectionRoutes`' * `/complete` and `GET /provider-health` share (CL-6092). */ readonly providerHealth?: ProviderHealthStore; @@ -49,7 +49,7 @@ export function createTenantConnectCredential( deps: CreateTenantConnectCredentialDeps, ): CreateOAuthConnectRoutesDeps["connectCredential"] { const api = createHubAPI(deps.hubUrl); - const registry = deps.registry ?? CONNECTOR_REGISTRY; + const registry = deps.registry; return async (args) => { const tenant = args.c.get("tenant"); diff --git a/packages/connections/src/persist-credential.test.ts b/packages/connections/src/persist-credential.test.ts index 39e90c59d..8a6a22fd2 100644 --- a/packages/connections/src/persist-credential.test.ts +++ b/packages/connections/src/persist-credential.test.ts @@ -14,7 +14,61 @@ import { isInferenceProvider, persistConnectorCredential, } from "./persist-credential"; -import { CONNECTOR_REGISTRY } from "./registry"; +/** Minimal fixtures standing in for the real connector set a build's own + * `templates/connectors.ts` carries — this package holds no concrete + * connector set of its own (CL-7384), so its own tests build only the + * fields `persistConnectorCredential` actually reads. */ +const FIXTURE_REGISTRY: Readonly> = { + github: { + id: "github", + displayName: "GitHub", + authKind: "api-key", + credentialPlugin: "http", + docsUrl: "https://github.com/settings/tokens", + feedsTools: ["@corbits/github-tools"], + }, + manus: { + id: "manus", + displayName: "Manus", + authKind: "api-key", + credentialPlugin: "http-x-manus-api-key", + docsUrl: "https://open.manus.ai/docs/v2/introduction", + feedsTools: ["@corbits/manus-tools"], + }, + openrouter: { + id: "openrouter", + displayName: "OpenRouter", + authKind: "oauth-pkce", + credentialPlugin: "http", + docsUrl: "https://openrouter.ai", + feedsTools: [], + }, + huggingface: { + id: "huggingface", + displayName: "Hugging Face", + authKind: "oauth-pkce", + credentialPlugin: "http", + docsUrl: "https://huggingface.co/settings/tokens", + feedsTools: [], + }, + ollama: { + id: "ollama", + displayName: "Ollama", + authKind: "api-key", + credentialPlugin: "http", + docsUrl: "https://ollama.com", + feedsTools: [], + credentialInputKind: "url", + }, + gmail: { + id: "gmail", + displayName: "Gmail", + authKind: "oauth-code", + credentialPlugin: "http", + docsUrl: "https://developers.google.com/gmail/api", + feedsTools: [], + }, +}; const noApi = () => { throw new Error("the api must only be reached through the injected fns"); @@ -54,7 +108,7 @@ function recordingFns() { } function descriptorOrThrow(id: string): ConnectorDescriptor { - const descriptor = CONNECTOR_REGISTRY[id]; + const descriptor = FIXTURE_REGISTRY[id]; if (descriptor === undefined) throw new Error(`no descriptor for ${id}`); return descriptor; } diff --git a/packages/connections/src/plugins.test.ts b/packages/connections/src/plugins.test.ts index 397c8b07d..6da8fc364 100644 --- a/packages/connections/src/plugins.test.ts +++ b/packages/connections/src/plugins.test.ts @@ -7,6 +7,29 @@ import { afterEach, describe, expect, test } from "bun:test"; import { listPluginsForTenant } from "./plugins"; +import type { ConnectorRegistry } from "./registry"; + +/** A fixture standing in for the real connector set a build's own + * `templates/connectors.ts` carries — this package holds no concrete + * connector set of its own (CL-7384). */ +const TEST_REGISTRY: ConnectorRegistry = { + github: { + id: "github", + displayName: "GitHub", + authKind: "api-key", + credentialPlugin: "http", + docsUrl: "https://github.com/settings/tokens", + feedsTools: ["@corbits/github-tools"], + }, + "granola-webhook": { + id: "granola-webhook", + displayName: "Granola inbound webhook", + authKind: "webhook-secret", + credentialPlugin: "http", + docsUrl: "https://www.granola.ai", + feedsTools: [], + }, +}; const realFetch = globalThis.fetch; afterEach(() => { @@ -43,7 +66,7 @@ describe("listPluginsForTenant", () => { }), }); - const resolved = await listPluginsForTenant("bench_1"); + const resolved = await listPluginsForTenant("bench_1", TEST_REGISTRY); const github = resolved.find((entry) => entry.descriptor.id === "github"); expect(github?.status).toBe("connected"); @@ -62,7 +85,7 @@ describe("listPluginsForTenant", () => { }), }); - const resolved = await listPluginsForTenant("bench_1"); + const resolved = await listPluginsForTenant("bench_1", TEST_REGISTRY); const github = resolved.find((entry) => entry.descriptor.id === "github"); expect(github?.status).toBe("connected"); @@ -72,7 +95,7 @@ describe("listPluginsForTenant", () => { test("no credential anywhere in the chain (404) resolves as not connected", async () => { stubFetch({}); - const resolved = await listPluginsForTenant("bench_1"); + const resolved = await listPluginsForTenant("bench_1", TEST_REGISTRY); const github = resolved.find((entry) => entry.descriptor.id === "github"); expect(github?.status).toBe("not_connected"); @@ -90,7 +113,7 @@ describe("listPluginsForTenant", () => { }), }); - const resolved = await listPluginsForTenant("bench_1"); + const resolved = await listPluginsForTenant("bench_1", TEST_REGISTRY); const github = resolved.find((entry) => entry.descriptor.id === "github"); expect(github?.status).toBe("not_connected"); @@ -107,7 +130,7 @@ describe("listPluginsForTenant", () => { }), }); - const resolved = await listPluginsForTenant("bench_1"); + const resolved = await listPluginsForTenant("bench_1", TEST_REGISTRY); const github = resolved.find((entry) => entry.descriptor.id === "github"); expect(github?.status).toBe("needs_attention"); @@ -117,7 +140,7 @@ describe("listPluginsForTenant", () => { test("never resolves granola-webhook — it has no Credential row to resolve", async () => { stubFetch({}); - const resolved = await listPluginsForTenant("bench_1"); + const resolved = await listPluginsForTenant("bench_1", TEST_REGISTRY); expect( resolved.some((entry) => entry.descriptor.id === "granola-webhook"), diff --git a/packages/connections/src/plugins.ts b/packages/connections/src/plugins.ts index a466db9c7..682ad91c2 100644 --- a/packages/connections/src/plugins.ts +++ b/packages/connections/src/plugins.ts @@ -33,7 +33,7 @@ import { type } from "arktype"; import type { ConnectorDescriptor } from "./descriptor"; -import { connectorDescriptors } from "./registry"; +import { connectorDescriptors, type ConnectorRegistry } from "./registry"; /** Where a resolved plugin's credential actually lives relative to the * tenant that asked: `"this-workbench"` when this exact tenant owns it, @@ -132,9 +132,10 @@ async function resolveOne( */ export function listPluginsForTenant( tenantId: string, + registry: ConnectorRegistry, ): Promise { return Promise.all( - connectorDescriptors() + connectorDescriptors(registry) .filter((descriptor) => descriptor.authKind !== "webhook-secret") .map((descriptor) => resolveOne(tenantId, descriptor)), ); diff --git a/packages/connections/src/registry.ts b/packages/connections/src/registry.ts index 7fdbd5f18..0c0ffe476 100644 --- a/packages/connections/src/registry.ts +++ b/packages/connections/src/registry.ts @@ -1,25 +1,13 @@ -// The browser-safe half of the Connections surface: every api-key -// connector this ticket exposes, as one `ConnectorDescriptor` map. This -// is the `./registry` subpath — imported directly by -// `packages/settings-ui`, a browser bundle — so it imports only from -// `./descriptor.ts`, `./probes.ts`, and this package's own light -// `./credential-test` subpath, never `hono` or `./routes.ts`. See -// `credential-test.ts`'s own header comment for why that subpath exists -// in the first place. -// -// The eight non-OAuth inference providers come straight from -// `PROVIDER_TEST_CONFIG` — the one source of provider metadata, -// re-exported here rather than duplicated. OpenRouter and Hugging Face -// are the two OAuth inference providers, wired below through the same -// `oauth` config shape `./oauth-routes.ts` reads — CL-6028's OAuth route -// factory folded their previously hand-written `packages/onboarding` -// routes into this registry. -import { - OLLAMA_PLACEHOLDER_SECRET, - PROVIDER_TEST_CONFIG, - testProviderCredential, - type SupportedCredentialProvider, -} from "./credential-test"; +// The browser-safe half of the Connections surface: the connector +// descriptor shape and the generic helpers a caller needs to work with a +// registry built from it. This package no longer bakes in any concrete +// connector set — CL-7384 moved every actual descriptor (GitHub, Gmail, +// Hugging Face, OpenRouter, the inference providers, ...) out to +// `templates/connectors.ts`, the product-data package a build supplies. +// A caller builds its own registry with `createConnectorRegistry` and +// passes it into every route factory that needs one — never a default +// baked in here, which would be exactly the fallback this package's +// callers are told never to leave lying around. export { missingCredentialDetail, parseMissingCredentialDetail, @@ -32,441 +20,20 @@ export type { OAuthExchangeResult, } from "./descriptor"; import type { ConnectorDescriptor } from "./descriptor"; -import { - exchangeCodeForToken, - HUGGINGFACE_AUTHORIZE_URL, - HUGGINGFACE_SCOPE, -} from "./huggingface-connect"; -import { - exchangeCodeForGoogleToken, - GMAIL_SCOPE, - GOOGLE_AUTHORIZE_URL, -} from "./gmail-connect"; -// simple-icons ships one brand per named export, tree-shaken by any bundler -// that respects its `sideEffects: false` — importing only the brands this -// registry actually has a listing for pulls in only those icons' data, not -// the whole ~3000-brand package (CC0-1.0 licensed — see the package's own -// LICENSE — so redistributing these marks needs no separate clearance). -// Granola and ScrapeCreators have no simple-icons listing (CL-6215's -// plugins-directory rebuild); OpenAI, xAI, Groq, and Opencode Zen have none -// either (CL-6258's connections logos) — those descriptors carry no `icon`, -// so a caller renders their monochrome initial tile instead. Exa publishes -// its own mark in its official brand kit, used below. Google's mark -// here is Gemini's, not the generic Google "G" — the model brand a person -// actually recognizes from connecting an AI provider, matching -// models.dev's own convention for this row. -import { - siAnthropic, - siDeepseek, - siGithub, - siGmail, - siGooglegemini, - siHuggingface, - siLinear, - siMistralai, - siOllama, - siOpenrouter, -} from "simple-icons"; -import { exchangeCodeForKey, OPENROUTER_AUTH_URL } from "./openrouter-connect"; -import { - exchangeCodeForGithubToken, - GITHUB_AUTHORIZE_URL, -} from "./github-connect"; -import { - testExaCredential, - testGitHubCredential, - testGranolaCredential, - testLinearCredential, - testManusCredential, - testScrapeCreatorsCredential, -} from "./probes"; -const INFERENCE_PROVIDER_DOCS_URL: Readonly< - Record -> = { - anthropic: "https://console.anthropic.com/settings/keys", - openai: "https://platform.openai.com/api-keys", - "google-genai": "https://aistudio.google.com/apikey", - xai: "https://console.x.ai", - "opencode-zen": "https://opencode.ai/zen", - groq: "https://console.groq.com/keys", - deepseek: "https://platform.deepseek.com/api_keys", - mistral: "https://console.mistral.ai/api-keys", - openrouter: "https://openrouter.ai", - huggingface: "https://huggingface.co/settings/tokens", - ollama: "https://ollama.com", -}; +export type ConnectorRegistry = Readonly>; -// Ollama's onboarding URL field default — the local-machine origin -// Ollama listens on out of the box, before anyone points it at a -// tailscale-tunneled or otherwise remote instance. -const OLLAMA_DEFAULT_URL = "http://localhost:11434"; - -const EXA_ICON = { - path: "M150.5 14.1064C150.5 14.3356 150.421 14.5579 150.277 14.736L88.4766 91 150.277 167.264C150.421 167.442 150.5 167.664 150.5 167.894V181C150.5 181.552 150.052 182 149.5 182H1C0.44772 182 0 181.552 0 181V0.999995C0 0.44771 0.447715 0 1 0H149.5C150.052 0 150.5 0.447715 150.5 1V14.1064ZM30.4059 162.719H121.728L76.0664 106.326 30.4059 162.719ZM19.2949 100.261V145.787L56.1572 100.261H19.2949ZM19.2949 80.9801H55.5434L19.2949 36.2121V80.9801ZM76.0664 75.6731L121.728 19.281H30.4059L76.0664 75.6731Z", - hex: "0143D9", - viewBox: "0 0 151 182", -} as const; - -// Every inference provider with a simple-icons listing. OpenAI, xAI, Groq, -// and Opencode Zen have none as of simple-icons' current release; those -// four fall through to the monochrome initial tile every iconless -// descriptor already gets. -const INFERENCE_PROVIDER_ICONS: Partial< - Record< - SupportedCredentialProvider, - { readonly path: string; readonly hex: string } - > -> = { - anthropic: { path: siAnthropic.path, hex: siAnthropic.hex }, - "google-genai": { path: siGooglegemini.path, hex: siGooglegemini.hex }, - deepseek: { path: siDeepseek.path, hex: siDeepseek.hex }, - mistral: { path: siMistralai.path, hex: siMistralai.hex }, - ollama: { path: siOllama.path, hex: siOllama.hex }, - openrouter: { path: siOpenrouter.path, hex: siOpenrouter.hex }, - huggingface: { path: siHuggingface.path, hex: siHuggingface.hex }, -}; - -function inferenceProviderDescriptors(): Record { - const entries: Record = {}; - for (const [id, config] of Object.entries(PROVIDER_TEST_CONFIG)) { - if (id === "openrouter" || id === "huggingface") continue; - const providerId = id as SupportedCredentialProvider; - // Ollama collects a URL, not a secret — it has no auth layer at all - // (see `credential-test.ts`'s own `ollama` config entry). Every - // other connector's single form field is a real key, probed and - // stored as-is. - const icon = INFERENCE_PROVIDER_ICONS[providerId]; - entries[id] = - providerId === "ollama" - ? { - id, - displayName: config.displayName, - authKind: "api-key", - credentialPlugin: "http", - docsUrl: INFERENCE_PROVIDER_DOCS_URL[providerId], - feedsTools: [], - credentialInputKind: "url", - credentialPlaceholder: OLLAMA_DEFAULT_URL, - probe: (baseURL) => - testProviderCredential({ - provider: providerId, - apiKey: OLLAMA_PLACEHOLDER_SECRET, - baseURL, - }), - ...(icon !== undefined ? { icon } : {}), - } - : { - id, - displayName: config.displayName, - authKind: "api-key", - credentialPlugin: "http", - docsUrl: INFERENCE_PROVIDER_DOCS_URL[providerId], - feedsTools: [], - probe: (apiKey) => - testProviderCredential({ provider: providerId, apiKey }), - ...(icon !== undefined ? { icon } : {}), - }; - } - entries["openrouter"] = { - id: "openrouter", - displayName: PROVIDER_TEST_CONFIG.openrouter.displayName, - authKind: "oauth-pkce", - credentialPlugin: "http", - docsUrl: INFERENCE_PROVIDER_DOCS_URL.openrouter, - feedsTools: [], - icon: { path: siOpenrouter.path, hex: siOpenrouter.hex }, - oauth: { - authorizeUrl: OPENROUTER_AUTH_URL, - usesPKCE: true, - // OpenRouter round-trips its state purely via the connect cookie; - // it never echoes `state` back as a callback query param. - echoesState: false, - deploysDefaultWorkflows: true, - buildAuthorizeUrl: ({ callbackUrl, codeChallenge }) => { - const url = new URL(OPENROUTER_AUTH_URL); - url.searchParams.set("callback_url", callbackUrl); - if (codeChallenge !== undefined) { - url.searchParams.set("code_challenge", codeChallenge); - } - url.searchParams.set("code_challenge_method", "S256"); - return url; - }, - exchange: async ({ code, codeVerifier }) => { - const result = await exchangeCodeForKey({ - code, - codeVerifier: codeVerifier ?? "", - }); - return result.ok ? { ok: true, apiKey: result.key } : result; - }, - }, - }; - entries["huggingface"] = { - id: "huggingface", - displayName: PROVIDER_TEST_CONFIG.huggingface.displayName, - authKind: "oauth-pkce", - credentialPlugin: "http", - docsUrl: INFERENCE_PROVIDER_DOCS_URL.huggingface, - feedsTools: [], - icon: { path: siHuggingface.path, hex: siHuggingface.hex }, - oauth: { - authorizeUrl: HUGGINGFACE_AUTHORIZE_URL, - usesPKCE: true, - // HF echoes `state` back in the callback query — the belt-and- - // suspenders check `oauth-routes.ts` runs before consulting the - // state store at all. - echoesState: true, - deploysDefaultWorkflows: true, - // No client secret (a public app); the flow is disabled with a - // `not_configured` outcome when this key is absent from the env - // bag rather than round-tripping a doomed request. - clientId: (env) => env["huggingfaceClientId"], - buildAuthorizeUrl: ({ callbackUrl, state, codeChallenge, clientId }) => { - const url = new URL(HUGGINGFACE_AUTHORIZE_URL); - if (clientId !== undefined) url.searchParams.set("client_id", clientId); - url.searchParams.set("redirect_uri", callbackUrl); - url.searchParams.set("scope", HUGGINGFACE_SCOPE); - url.searchParams.set("state", state); - if (codeChallenge !== undefined) { - url.searchParams.set("code_challenge", codeChallenge); - } - url.searchParams.set("code_challenge_method", "S256"); - return url; - }, - exchange: async ({ code, codeVerifier, redirectUri, clientId }) => { - if (clientId === undefined) { - return { - ok: false, - message: "huggingface connect is not configured", - }; - } - const result = await exchangeCodeForToken({ - code, - codeVerifier: codeVerifier ?? "", - redirectUri, - clientId, - }); - if (!result.ok) return result; - return result.expiresAt !== undefined - ? { - ok: true, - apiKey: result.accessToken, - expiresAt: result.expiresAt, - } - : { ok: true, apiKey: result.accessToken }; - }, - }, - }; - return entries; +/** Identity function with a name — lets a caller building its own + * connector set (e.g. `templates/connectors.ts`) get `ConnectorRegistry`'s + * type checking on the literal without importing the type by hand. */ +export function createConnectorRegistry( + connectors: Readonly>, +): ConnectorRegistry { + return connectors; } -export const CONNECTOR_REGISTRY: Readonly> = - { - ...inferenceProviderDescriptors(), - granola: { - id: "granola", - credentialPlugin: "http", - displayName: "Granola", - authKind: "api-key", - docsUrl: "https://www.granola.ai", - feedsTools: ["@corbits/granola-tools"], - probe: (apiKey) => testGranolaCredential(apiKey), - description: "Pull meeting notes and transcripts into your agents.", - }, - manus: { - id: "manus", - credentialPlugin: "http-x-manus-api-key", - displayName: "Manus", - authKind: "api-key", - docsUrl: "https://open.manus.ai/docs/v2/introduction", - feedsTools: ["@corbits/manus-tools"], - probe: (apiKey) => testManusCredential(apiKey), - description: - "Have Manus run tasks and produce files — including slide decks.", - }, - exa: { - id: "exa", - // Exa authenticates via an `x-api-key` header, not `authorization` — - // the `http` (Bearer) plugin doesn't fit; see the `http-x-api-key` - // provider in `@corbits/credential-providers`. - credentialPlugin: "http-x-api-key", - displayName: "Exa", - authKind: "api-key", - docsUrl: "https://exa.ai", - feedsTools: ["@corbits/web-search-tools"], - probe: (apiKey) => testExaCredential(apiKey), - description: "Neural web search built for agents.", - icon: EXA_ICON, - }, - scrapecreators: { - id: "scrapecreators", - // ScrapeCreators, same as Exa, authenticates via `x-api-key`. - credentialPlugin: "http-x-api-key", - displayName: "ScrapeCreators", - authKind: "api-key", - docsUrl: "https://scrapecreators.com", - feedsTools: ["@corbits/reddit-tools"], - probe: (apiKey) => testScrapeCreatorsCredential(apiKey), - description: "Read Reddit threads and comments.", - }, - linear: { - id: "linear", - credentialPlugin: "http-raw-authorization", - displayName: "Linear", - authKind: "api-key", - docsUrl: "https://linear.app/settings/api", - feedsTools: ["@corbits/linear-tools"], - probe: (apiKey) => testLinearCredential(apiKey), - description: "Read and write issues, projects, and comments.", - icon: { path: siLinear.path, hex: siLinear.hex }, - }, - gmail: { - id: "gmail", - displayName: "Gmail", - // Pure hosted-app OAuth — there is no Gmail equivalent of a PAT - // to paste, so unlike `github` below this connector has no - // api-key arm: unconfigured (`GMAIL_CLIENT_ID`/`GMAIL_CLIENT_SECRET` - // unset) means the card renders "not configured", never a dead - // paste form. The exchanged access token expires in about an - // hour; the refresh token rides `OAuthExchangeResult.refreshToken` - // into the credential row's `refreshSecret` so a refresh sweep - // can keep the connection alive. - authKind: "oauth-code", - credentialPlugin: "http", - docsUrl: "https://developers.google.com/gmail/api", - feedsTools: [], - description: "Read, draft, and send email — sending waits for your ok.", - icon: { path: siGmail.path, hex: siGmail.hex }, - oauth: { - authorizeUrl: GOOGLE_AUTHORIZE_URL, - usesPKCE: true, - echoesState: true, - deploysDefaultWorkflows: false, - clientId: (env) => env["gmailClientId"], - clientSecret: (env) => env["gmailClientSecret"], - buildAuthorizeUrl: ({ - callbackUrl, - state, - codeChallenge, - clientId, - }) => { - const url = new URL(GOOGLE_AUTHORIZE_URL); - if (clientId !== undefined) - url.searchParams.set("client_id", clientId); - url.searchParams.set("redirect_uri", callbackUrl); - url.searchParams.set("response_type", "code"); - url.searchParams.set("scope", GMAIL_SCOPE); - url.searchParams.set("state", state); - // `offline` + forced consent is what makes Google issue a - // refresh token at all — without both, a re-connect silently - // returns only another one-hour access token. - url.searchParams.set("access_type", "offline"); - url.searchParams.set("prompt", "consent"); - if (codeChallenge !== undefined) { - url.searchParams.set("code_challenge", codeChallenge); - url.searchParams.set("code_challenge_method", "S256"); - } - return url; - }, - exchange: async ({ - code, - codeVerifier, - redirectUri, - clientId, - clientSecret, - }) => { - if (clientId === undefined || clientSecret === undefined) { - return { - ok: false, - message: "gmail connect is not configured", - }; - } - return exchangeCodeForGoogleToken({ - code, - ...(codeVerifier !== undefined ? { codeVerifier } : {}), - redirectUri, - clientId, - clientSecret, - }); - }, - }, - }, - github: { - id: "github", - // GitHub's REST API accepts a fine-grained PAT — or a GitHub OAuth - // App user token, which is the same Bearer shape — as a Bearer - // token. Absent entirely, github-tools degrades to a lower - // unauthenticated rate limit rather than "not connected" — see its - // tool.ts. `authKind` stays "api-key" (the PAT paste form is - // always available, CL-6386's guaranteed fallback); the `oauth` - // config below is this connector's one exception to "oauth fields - // are oauth-pkce/oauth-code only" — a caller checks - // `GET /oauth-configured`'s `github` entry to decide whether to - // offer the one-click app connect ahead of the paste form, never - // the other way around, so an unconfigured deploy never dead-ends. - credentialPlugin: "http", - displayName: "GitHub", - authKind: "api-key", - docsUrl: "https://github.com/settings/tokens", - feedsTools: ["@corbits/github-tools"], - probe: (apiKey, opts) => - testGitHubCredential(apiKey, undefined, opts?.baseUrl), - description: "Read repos, issues, and pull requests.", - icon: { path: siGithub.path, hex: siGithub.hex }, - oauth: { - authorizeUrl: GITHUB_AUTHORIZE_URL, - usesPKCE: false, - echoesState: true, - deploysDefaultWorkflows: false, - clientId: (env) => env["githubAppClientId"], - clientSecret: (env) => env["githubAppClientSecret"], - buildAuthorizeUrl: ({ callbackUrl, state, clientId }) => { - const url = new URL(GITHUB_AUTHORIZE_URL); - if (clientId !== undefined) - url.searchParams.set("client_id", clientId); - url.searchParams.set("redirect_uri", callbackUrl); - url.searchParams.set("scope", "repo"); - url.searchParams.set("state", state); - return url; - }, - exchange: async ({ code, redirectUri, clientId, clientSecret }) => { - if (clientId === undefined || clientSecret === undefined) { - return { - ok: false, - message: "github app connect is not configured", - }; - } - const result = await exchangeCodeForGithubToken({ - code, - redirectUri, - clientId, - clientSecret, - }); - return result.ok ? { ok: true, apiKey: result.key } : result; - }, - }, - }, - "granola-webhook": { - id: "granola-webhook", - // No credential-provider plugin mediates this connector's secret — - // it is minted and encrypted entirely inside - // `@corbits/webhook-triggers` (see `management-routes.ts`), never - // stored as a Credential row. `credentialPlugin` still has to name - // something (`ConnectorDescriptor` requires it uniformly); `http` - // is inert here, kept only so registry-wide invariants (every - // non-bearer connector reads `http`) hold without a special case. - credentialPlugin: "http", - displayName: "Granola inbound webhook", - authKind: "webhook-secret", - docsUrl: "https://www.granola.ai", - // The direction is inverted from the `granola` api-key connector - // above: this issues a signed inbound address for Granola to call, - // feeding nothing — a routine's webhook trigger binding, not a - // tool package's credential handle. - feedsTools: [], - }, - }; - -export function connectorDescriptors(): readonly ConnectorDescriptor[] { - return Object.values(CONNECTOR_REGISTRY); +export function connectorDescriptors( + registry: ConnectorRegistry, +): readonly ConnectorDescriptor[] { + return Object.values(registry); } diff --git a/packages/connections/src/routes.ts b/packages/connections/src/routes.ts index 42a4dc5cc..88d2823ef 100644 --- a/packages/connections/src/routes.ts +++ b/packages/connections/src/routes.ts @@ -50,7 +50,6 @@ import { persistConnectorCredential, } from "./persist-credential"; import type { ProviderHealthStore } from "./provider-health"; -import { CONNECTOR_REGISTRY } from "./registry"; export type DisconnectConnectorArgs = { readonly tenantId: string; @@ -194,10 +193,11 @@ export type CreateConnectionRoutesDeps = { hubUrl: string; requireGrant: RequireGrant; log: (line: string) => void; - /** Test-only override, defaulting to `CONNECTOR_REGISTRY` — lets - * `routes.test.ts` stub a connector's probe without hitting the real - * network or reaching for module mocking. */ - registry?: Readonly>; + /** The connector set this build ships — this package carries none of + * its own (CL-7384), so a caller always supplies one (the hub passes + * `@workbench/templates/connectors`' `CONNECTOR_REGISTRY`; a test + * passes its own stub). */ + registry: Readonly>; /** Test-only override, matching `complete-credential.ts`'s `seedCatalogFn` * override pattern — lets `routes.test.ts` stub credential storage * without reaching for module mocking. */ @@ -303,7 +303,7 @@ export function createConnectionRoutes( ): Hono { const app = new Hono(); const api = createHubAPI(deps.hubUrl); - const registry = deps.registry ?? CONNECTOR_REGISTRY; + const registry = deps.registry; const runDisconnectConnector = deps.disconnectConnectorFn ?? disconnectConnector; const runGetResolvedCatalog = diff --git a/packages/connections/src/template-settings.ts b/packages/connections/src/template-settings.ts new file mode 100644 index 000000000..e36c5c0d0 --- /dev/null +++ b/packages/connections/src/template-settings.ts @@ -0,0 +1,71 @@ +// The `template/*` workbench-settings vocabulary: what a room minted +// from a template persists about that fact, so the shell can render +// its setup state (which connections still need connecting) without a +// second round trip. `@corbits/chat`'s settings route validates only +// its own `chat/*` keys and passes any other namespace through opaquely +// (see `packages/chat/src/workbench-settings.ts`) — this schema is the +// boundary check a caller runs before a `template/*` patch ever leaves +// the client, matching the "parse at every trust boundary" rule for a +// namespace `chat` deliberately leaves to its owner. +import { type } from "arktype"; + +export const TemplateSettingsPatch = type({ + "template/id": "string > 0", + "template/pendingConnections": "string[]", +}); +export type TemplateSettingsPatch = typeof TemplateSettingsPatch.infer; + +/** Builds and validates the settings patch a freshly instantiated + * template writes onto its workbench. Throws on a malformed id or + * connections list rather than sending bad data to the wire. */ +export function templateSettingsPatch( + templateId: string, + pendingConnections: readonly string[], +): TemplateSettingsPatch { + const patch = { + "template/id": templateId, + "template/pendingConnections": [...pendingConnections], + }; + const parsed = TemplateSettingsPatch(patch); + if (parsed instanceof type.errors) { + throw new Error(`invalid template settings patch: ${parsed.summary}`); + } + return parsed; +} + +/** + * The `github` connect card's own settled state (CL-6345): once a + * person has picked which repos to review, `./connect-github-setup.ts` + * writes both keys in the same patch — `pendingConnections` with + * `"github"` removed (this template needs nothing else, so that leaves + * it empty) and `selectedRepos` naming exactly what got a live webhook + * trigger. This patch rides the same `chat/*` settings PATCH route + * every other `template/*` write does; the card itself refetches its + * state as the direct consequence of the `start-reviewing` call that + * triggers this patch, same as it does for its own PAT submit + * (`ConnectGithubBlockContainer`) — never a fold off this stream event. + */ +export const TemplateReposSettingsPatch = type({ + "template/pendingConnections": "string[]", + "template/selectedRepos": "string[]", +}); +export type TemplateReposSettingsPatch = + typeof TemplateReposSettingsPatch.infer; + +/** Builds and validates the settings patch that records which repos a + * room's GitHub connect card started reviewing. Throws on a malformed + * repo id list rather than sending bad data to the wire. */ +export function templateReposSettingsPatch( + pendingConnections: readonly string[], + selectedRepoIds: readonly string[], +): TemplateReposSettingsPatch { + const patch = { + "template/pendingConnections": [...pendingConnections], + "template/selectedRepos": [...selectedRepoIds], + }; + const parsed = TemplateReposSettingsPatch(patch); + if (parsed instanceof type.errors) { + throw new Error(`invalid template repos settings patch: ${parsed.summary}`); + } + return parsed; +} diff --git a/packages/connections/src/workflow-connection-routes.test.ts b/packages/connections/src/workflow-connection-routes.test.ts index a11c09b92..64145c98b 100644 --- a/packages/connections/src/workflow-connection-routes.test.ts +++ b/packages/connections/src/workflow-connection-routes.test.ts @@ -4,6 +4,37 @@ import { createWorkflowConnectionRoutes, type WorkflowConnectionRunScope, } from "./workflow-connection-routes"; +import type { ConnectorRegistry } from "./registry"; + +/** A fixture standing in for the real connector set a build's own + * `templates/connectors.ts` carries — this package holds no concrete + * connector set of its own (CL-7384). */ +const TEST_REGISTRY: ConnectorRegistry = { + github: { + id: "github", + displayName: "GitHub", + authKind: "api-key", + credentialPlugin: "http", + docsUrl: "https://github.com/settings/tokens", + feedsTools: ["@corbits/github-tools"], + }, + linear: { + id: "linear", + displayName: "Linear", + authKind: "api-key", + credentialPlugin: "http-raw-authorization", + docsUrl: "https://linear.app/settings/api", + feedsTools: ["@corbits/linear-tools"], + }, + anthropic: { + id: "anthropic", + displayName: "Anthropic", + authKind: "api-key", + credentialPlugin: "http", + docsUrl: "https://console.anthropic.com/settings/keys", + feedsTools: [], + }, +}; const VALID_TOKEN = "sc-token"; const VALID_ADDRESS = "run_1@workflow"; @@ -24,6 +55,7 @@ describe("createWorkflowConnectionRoutes", () => { test("rejects a call with a missing or unrecognized sidecar bearer token / run address", async () => { const app = createWorkflowConnectionRoutes({ authenticator: fakeAuthenticator(), + registry: TEST_REGISTRY, isConnectorConnected: async () => false, }); @@ -48,6 +80,7 @@ describe("createWorkflowConnectionRoutes", () => { let seenConnectorId: string | undefined; const app = createWorkflowConnectionRoutes({ authenticator: fakeAuthenticator(), + registry: TEST_REGISTRY, isConnectorConnected: async (tenantId, connectorId) => { if (connectorId === "github") { seenTenantId = tenantId; @@ -78,6 +111,7 @@ describe("createWorkflowConnectionRoutes", () => { test("still reports an unconnected connector as not connected", async () => { const app = createWorkflowConnectionRoutes({ authenticator: fakeAuthenticator(), + registry: TEST_REGISTRY, isConnectorConnected: async () => false, }); @@ -103,6 +137,7 @@ describe("createWorkflowConnectionRoutes", () => { test("keeps reporting an inference provider (Anthropic) connected", async () => { const app = createWorkflowConnectionRoutes({ authenticator: fakeAuthenticator(), + registry: TEST_REGISTRY, isConnectorConnected: async (_tenantId, connectorId) => connectorId === "anthropic", }); @@ -126,6 +161,7 @@ describe("createWorkflowConnectionRoutes", () => { let called = false; const app = createWorkflowConnectionRoutes({ authenticator: fakeAuthenticator(), + registry: TEST_REGISTRY, isConnectorConnected: async () => { called = true; return false; diff --git a/packages/connections/src/workflow-connection-routes.ts b/packages/connections/src/workflow-connection-routes.ts index 63e8b4f68..114b77ff3 100644 --- a/packages/connections/src/workflow-connection-routes.ts +++ b/packages/connections/src/workflow-connection-routes.ts @@ -39,7 +39,7 @@ // endpoint below. import { Hono } from "hono"; -import { CONNECTOR_REGISTRY } from "./registry"; +import type { ConnectorDescriptor } from "./descriptor"; import type { McpServerConnection } from "./mcp-server-store"; /** @@ -80,6 +80,9 @@ export type ConnectionSummary = { export type CreateWorkflowConnectionRoutesDeps = { readonly authenticator: WorkflowRunAuthenticator; + /** The connector set this build ships — this package carries none of + * its own (CL-7384), so a caller always supplies one. */ + readonly registry: Readonly>; /** A port, not a raw `db` handle — keeps this package decoupled from * the credentials schema, mirroring `@corbits/routines`' routes.ts * taking ports rather than reaching for database access directly. @@ -134,7 +137,7 @@ export function createWorkflowConnectionRoutes( app.get("/connections", async (c) => { const scope = c.get("workflowConnectionScope"); - const descriptors = Object.values(CONNECTOR_REGISTRY); + const descriptors = Object.values(deps.registry); const connections: ConnectionSummary[] = await Promise.all( descriptors.map(async (descriptor) => ({ id: descriptor.id, diff --git a/packages/connections/test/github-oauth-connect.test.ts b/packages/connections/test/github-oauth-connect.test.ts index 1c84d2588..2fd36df60 100644 --- a/packages/connections/test/github-oauth-connect.test.ts +++ b/packages/connections/test/github-oauth-connect.test.ts @@ -23,6 +23,7 @@ import type { import type { ConnectorDescriptor } from "../src/descriptor"; import { exchangeCodeForGithubToken, + GITHUB_AUTHORIZE_URL, GITHUB_TOKEN_EXCHANGE_URL, } from "../src/github-connect"; import { @@ -30,7 +31,52 @@ import { DEFAULT_RETURN_PATH_ALLOWLIST, } from "../src/oauth-routes"; import { createTenantConnectCredential } from "../src/oauth-tenant-connect"; -import { CONNECTOR_REGISTRY } from "../src/registry"; + +/** A fixture standing in for the real `github` connector descriptor a + * build's own connector set (`templates/connectors.ts`) carries — this + * package holds no concrete connector set of its own (CL-7384). Mirrors + * that descriptor's oauth wiring exactly (see its own comments there), + * with `exchange` pointed at the fake GitHub token server below instead + * of github.com. */ +const REAL_GITHUB: ConnectorDescriptor = { + id: "github", + displayName: "GitHub", + credentialPlugin: "http", + authKind: "api-key", + docsUrl: "https://github.com/settings/tokens", + feedsTools: ["@corbits/github-tools"], + oauth: { + authorizeUrl: GITHUB_AUTHORIZE_URL, + usesPKCE: false, + echoesState: true, + deploysDefaultWorkflows: false, + clientId: (env) => env["githubAppClientId"], + clientSecret: (env) => env["githubAppClientSecret"], + buildAuthorizeUrl: ({ callbackUrl, state, clientId }) => { + const url = new URL(GITHUB_AUTHORIZE_URL); + if (clientId !== undefined) url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", callbackUrl); + url.searchParams.set("scope", "repo"); + url.searchParams.set("state", state); + return url; + }, + exchange: async ({ code, redirectUri, clientId, clientSecret }) => { + if (clientId === undefined || clientSecret === undefined) { + return { + ok: false, + message: "github app connect is not configured", + }; + } + const result = await exchangeCodeForGithubToken({ + code, + redirectUri, + clientId, + clientSecret, + }); + return result.ok ? { ok: true, apiKey: result.key } : result; + }, + }, +}; // What `oauthStartHref("tnt_1", "github", "/plugins")` renders into the // plugins gallery's Connect link. @@ -70,12 +116,12 @@ function fakeGithubExchangeServer(exchangeBodies: unknown[]) { } function mountHubShaped() { - const realGithub = CONNECTOR_REGISTRY["github"]; - if (realGithub?.oauth === undefined) { - throw new Error("registry is missing the github oauth entry"); + const realGithub = REAL_GITHUB; + if (realGithub.oauth === undefined) { + throw new Error("fixture is missing the github oauth entry"); } const exchangeBodies: unknown[] = []; - // The real descriptor, with its exchange pointed at the fake GitHub + // The fixture descriptor, with its exchange pointed at the fake GitHub // token server instead of github.com — same seam the descriptor's own // `fetchImpl` exposes. const github: ConnectorDescriptor = { diff --git a/packages/evals/src/targets/world-snapshot.ts b/packages/evals/src/targets/world-snapshot.ts index b3f851055..ebd5242c5 100644 --- a/packages/evals/src/targets/world-snapshot.ts +++ b/packages/evals/src/targets/world-snapshot.ts @@ -35,6 +35,7 @@ import { connectorDescriptors, listMcpServerConnections, } from "@corbits/connections"; +import { CONNECTOR_REGISTRY } from "@workbench/templates/connectors"; import type { FakeReceipt, WorldSnapshot } from "../types.ts"; @@ -126,7 +127,7 @@ async function readConnections( // which is not an MCP server and was invisible to this snapshot // before. const fromConnectors: typeof fromMcp = []; - for (const descriptor of connectorDescriptors()) { + for (const descriptor of connectorDescriptors(CONNECTOR_REGISTRY)) { const row = await resolveByName(db, tenantId, descriptor.displayName); if (row === null) continue; fromConnectors.push({ diff --git a/packages/folded-runs/src/credential-delivery-error.test.ts b/packages/folded-runs/src/credential-delivery-error.test.ts index a3b525931..a970ff73c 100644 --- a/packages/folded-runs/src/credential-delivery-error.test.ts +++ b/packages/folded-runs/src/credential-delivery-error.test.ts @@ -20,7 +20,11 @@ describe("credentialDeliveryError", () => { expect(error).toBeInstanceOf(MissingCredentialError); expect((error as MissingCredentialError).connectorId).toBe("github"); - expect((error as MissingCredentialError).displayName).toBe("GitHub"); + // No connector set is reachable here (CL-7384 moved it out of + // `@corbits/connections`), so the display name falls back to the raw + // connector id — the caller-facing UI resolves the real display name + // itself from `MissingCredentialError.connectorId`. + expect((error as MissingCredentialError).displayName).toBe("github"); }); test("keeps a generic error for a non-missing-credential failure", () => { diff --git a/packages/onboarding/package.json b/packages/onboarding/package.json index 74dca676d..787c87987 100644 --- a/packages/onboarding/package.json +++ b/packages/onboarding/package.json @@ -23,6 +23,7 @@ "@corbits/connections": "workspace:*", "@corbits/hub-api-client": "workspace:*", "@corbits/seeding": "workspace:*", + "@workbench/templates": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", diff --git a/packages/onboarding/src/complete-credential.ts b/packages/onboarding/src/complete-credential.ts index a00329cb7..5af80e3f7 100644 --- a/packages/onboarding/src/complete-credential.ts +++ b/packages/onboarding/src/complete-credential.ts @@ -82,7 +82,7 @@ import { persistConnectorCredential, type PersistConnectorCredentialFns, } from "@corbits/connections/persist-credential"; -import { CONNECTOR_REGISTRY } from "@corbits/connections/registry"; +import { CONNECTOR_REGISTRY } from "@workbench/templates/connectors"; import { personalTenantSlug, seededWorkflowStatus } from "./provision"; /** The onboarding UI's copy for a partial seed: every durable step diff --git a/packages/onboarding/src/routes.ts b/packages/onboarding/src/routes.ts index aeb683eb7..0b611e893 100644 --- a/packages/onboarding/src/routes.ts +++ b/packages/onboarding/src/routes.ts @@ -50,12 +50,12 @@ import { } from "./complete-credential"; import type { BenchProvisioner } from "./bench-provisioning"; import { - CONNECTOR_REGISTRY, createOAuthConnectRoutes, DEFAULT_RETURN_PATH_ALLOWLIST, type ConnectorDescriptor, type OAuthExchangeResult, } from "@corbits/connections"; +import { CONNECTOR_REGISTRY } from "@workbench/templates/connectors"; import type { PendingSeedStore } from "./pending-seed"; import { exchangeCodeForKey } from "./openrouter-connect"; import { exchangeCodeForToken as exchangeHuggingFaceCodeForToken } from "./huggingface-connect"; diff --git a/packages/plugins-ui/README.md b/packages/plugins-ui/README.md index 1f7be260b..a678caa0b 100644 --- a/packages/plugins-ui/README.md +++ b/packages/plugins-ui/README.md @@ -35,8 +35,8 @@ owns fetching data and passes it down. - `plugin-connect-panel.tsx` — the right-docked connect flow for every connector kind (OAuth, api-key, Granola's key-plus-webhook). - `plugin-meta.ts` — presentation-only sidecar lookups (icon, category, - outcome copy) keyed by connector id; `CONNECTOR_REGISTRY` itself stays - UI-agnostic. + outcome copy) keyed by connector id; the connector set itself + (`templates/connectors.ts`'s `CONNECTOR_REGISTRY`) stays UI-agnostic. ## Running tests diff --git a/packages/plugins-ui/package.json b/packages/plugins-ui/package.json index 7d932fcbf..25e85d783 100644 --- a/packages/plugins-ui/package.json +++ b/packages/plugins-ui/package.json @@ -17,6 +17,7 @@ "@corbits/react-ui": "github:corbitsdev/react-ui#3b122812a307ccb35be31386f7696020c5a84635", "@corbits/settings-ui": "workspace:*", "@corbits/connections": "workspace:*", + "@workbench/templates": "workspace:*", "arktype": "catalog:", "@corbits/icons": "workspace:*", "react": "^19.2.0", diff --git a/packages/plugins-ui/src/mcp-preset-cards.tsx b/packages/plugins-ui/src/mcp-preset-cards.tsx index 5ed524fb4..27e9f0849 100644 --- a/packages/plugins-ui/src/mcp-preset-cards.tsx +++ b/packages/plugins-ui/src/mcp-preset-cards.tsx @@ -3,8 +3,10 @@ // connected custom servers share the same server-side store. import { Button, ConfirmButton, Input, toast } from "@corbits/react-ui"; -import { CONNECTOR_REGISTRY } from "@corbits/connections/registry"; -import { MCP_PRESETS } from "@corbits/connections/mcp-presets"; +import { + CONNECTOR_REGISTRY, + MCP_PRESETS, +} from "@workbench/templates/connectors"; import { useEffect, useState } from "react"; import { diff --git a/packages/plugins-ui/src/mcp-servers-section.tsx b/packages/plugins-ui/src/mcp-servers-section.tsx index ac62f871b..50f5d8705 100644 --- a/packages/plugins-ui/src/mcp-servers-section.tsx +++ b/packages/plugins-ui/src/mcp-servers-section.tsx @@ -9,7 +9,7 @@ // advertising a URL/token form as an installation path. import { ConfirmButton, toast } from "@corbits/react-ui"; -import { MCP_PRESETS } from "@corbits/connections/mcp-presets"; +import { MCP_PRESETS } from "@workbench/templates/connectors"; import { PuzzlePiece } from "@corbits/icons"; import { useEffect, useState } from "react"; diff --git a/packages/plugins-ui/src/plugin-connect-panel.tsx b/packages/plugins-ui/src/plugin-connect-panel.tsx index 32b0a6bfc..786385b92 100644 --- a/packages/plugins-ui/src/plugin-connect-panel.tsx +++ b/packages/plugins-ui/src/plugin-connect-panel.tsx @@ -32,7 +32,7 @@ import { fetchOAuthConfigured, oauthStartHref, } from "@corbits/settings-ui"; -import { CONNECTOR_REGISTRY } from "@corbits/connections/registry"; +import { CONNECTOR_REGISTRY } from "@workbench/templates/connectors"; import type { ResolvedPlugin } from "@corbits/connections/plugins"; import { useEffect, useState } from "react"; diff --git a/packages/plugins-ui/src/plugins-gallery.tsx b/packages/plugins-ui/src/plugins-gallery.tsx index 0cf29a9f9..bbc18d84b 100644 --- a/packages/plugins-ui/src/plugins-gallery.tsx +++ b/packages/plugins-ui/src/plugins-gallery.tsx @@ -11,7 +11,7 @@ import type { ResolvedPlugin } from "@corbits/connections/plugins"; import { MCP_PRESETS, MCP_PRESET_CONNECTOR_IDS, -} from "@corbits/connections/mcp-presets"; +} from "@workbench/templates/connectors"; import { Lightning } from "@corbits/icons"; import { useMemo } from "react"; diff --git a/packages/plugins-ui/test/mcp-preset-cards.test.tsx b/packages/plugins-ui/test/mcp-preset-cards.test.tsx index 58653616e..1437c44c7 100644 --- a/packages/plugins-ui/test/mcp-preset-cards.test.tsx +++ b/packages/plugins-ui/test/mcp-preset-cards.test.tsx @@ -8,7 +8,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import type { Root } from "react-dom/client"; -import { MCP_PRESETS } from "@corbits/connections/mcp-presets"; +import { MCP_PRESETS } from "@workbench/templates/connectors"; import { McpPresetCardsSection } from "../src/mcp-preset-cards"; diff --git a/packages/plugins-ui/test/plugins-gallery.test.tsx b/packages/plugins-ui/test/plugins-gallery.test.tsx index 24c7ee8b4..3aa093a5e 100644 --- a/packages/plugins-ui/test/plugins-gallery.test.tsx +++ b/packages/plugins-ui/test/plugins-gallery.test.tsx @@ -11,7 +11,7 @@ import type { Root } from "react-dom/client"; import type { ConnectorDescriptor } from "@corbits/connections/registry"; import type { ResolvedPlugin } from "@corbits/connections/plugins"; -import { MCP_PRESETS } from "@corbits/connections/mcp-presets"; +import { MCP_PRESETS } from "@workbench/templates/connectors"; import { PluginsGallery } from "../src/plugins-gallery"; import type { SkillCardData } from "../src/skill-card"; diff --git a/packages/settings-ui/src/connections-section.tsx b/packages/settings-ui/src/connections-section.tsx index fdd26bc3a..44db53aea 100644 --- a/packages/settings-ui/src/connections-section.tsx +++ b/packages/settings-ui/src/connections-section.tsx @@ -21,12 +21,14 @@ import { toast, } from "@corbits/react-ui"; import { - CONNECTOR_REGISTRY, connectorDescriptors, type ConnectorDescriptor, } from "@corbits/connections/registry"; -import { MCP_PRESET_CONNECTOR_IDS } from "@corbits/connections/mcp-presets"; import { workflowDisplayName } from "@workbench/templates"; +import { + CONNECTOR_REGISTRY, + MCP_PRESET_CONNECTOR_IDS, +} from "@workbench/templates/connectors"; import { buildEffectiveInferenceRows, computeGlobalRoutePatches, @@ -171,7 +173,7 @@ export function ConnectorRowList({ .catch(() => onError?.(SETTINGS_STRINGS.connectionsDisconnectError)); } - const descriptors = connectorDescriptors() + const descriptors = connectorDescriptors(CONNECTOR_REGISTRY) .filter((descriptor) => descriptor.probe !== undefined) .filter((descriptor) => !MCP_PRESET_CONNECTOR_IDS.includes(descriptor.id)) .filter(filter ?? (() => true)); diff --git a/packages/workflows/package.json b/packages/workflows/package.json index 490f080a2..468c06a6f 100644 --- a/packages/workflows/package.json +++ b/packages/workflows/package.json @@ -30,6 +30,7 @@ "devDependencies": { "@types/bun": "catalog:", "@corbits/connections": "workspace:*", + "@workbench/templates": "workspace:*", "typescript": "catalog:" } } diff --git a/packages/workflows/src/catalog.test.ts b/packages/workflows/src/catalog.test.ts index 90bb639b6..c41bbe0d1 100644 --- a/packages/workflows/src/catalog.test.ts +++ b/packages/workflows/src/catalog.test.ts @@ -1,8 +1,10 @@ import { type } from "arktype"; import { describe, expect, test } from "bun:test"; -import { CONNECTOR_REGISTRY } from "@corbits/connections/registry"; -import { MCP_PRESETS } from "@corbits/connections/mcp-presets"; +import { + CONNECTOR_REGISTRY, + MCP_PRESETS, +} from "@workbench/templates/connectors"; import { deliveryWorkbenchRequiredForWorkflowName, diff --git a/packages/connections/src/registry.test.ts b/templates/connectors.test.ts similarity index 60% rename from packages/connections/src/registry.test.ts rename to templates/connectors.test.ts index d77b30eaf..a4a6b2da1 100644 --- a/packages/connections/src/registry.test.ts +++ b/templates/connectors.test.ts @@ -1,5 +1,14 @@ import { describe, expect, test } from "bun:test"; -import { CONNECTOR_REGISTRY, connectorDescriptors } from "./registry"; +import { connectorDescriptors } from "@corbits/connections/registry"; +import { + mcpPresetByName, + mcpPresetBySlug, +} from "@corbits/connections/mcp-presets"; +import { + CONNECTOR_REGISTRY, + MCP_PRESET_CONNECTOR_IDS, + MCP_PRESETS, +} from "./connectors"; describe("CONNECTOR_REGISTRY", () => { test("every entry has an id, displayName, and docsUrl", () => { @@ -156,7 +165,7 @@ describe("granola-webhook", () => { describe("connectorDescriptors", () => { test("returns every registry entry", () => { - expect(connectorDescriptors().length).toBe( + expect(connectorDescriptors(CONNECTOR_REGISTRY).length).toBe( Object.keys(CONNECTOR_REGISTRY).length, ); }); @@ -217,3 +226,141 @@ describe("gmail connector", () => { expect(url.searchParams.get("code_challenge_method")).toBe("S256"); }); }); + +describe("MCP_PRESETS", () => { + test("lists only verified zero-configuration remote MCP servers", () => { + const slugs = MCP_PRESETS.map((preset) => preset.slug).sort(); + expect(slugs).toEqual( + [ + "attio", + "canva", + "exa", + "github-mcp", + "granola", + "linear", + "notion", + "posthog", + "railway", + "sentry", + "sumble", + ].sort(), + ); + for (const preset of MCP_PRESETS) { + expect(() => new URL(preset.url)).not.toThrow(); + expect(preset.url.startsWith("https://")).toBe(true); + } + }); + + test("Exa is keyless, GitHub MCP is token, every other preset uses OAuth", () => { + expect(mcpPresetBySlug(MCP_PRESETS, "exa")?.connectionMode).toBe("keyless"); + expect(mcpPresetBySlug(MCP_PRESETS, "github-mcp")?.connectionMode).toBe( + "token", + ); + for (const preset of MCP_PRESETS) { + if (preset.slug === "exa" || preset.slug === "github-mcp") continue; + expect(preset.connectionMode).toBe("oauth"); + } + }); + + test("GitHub MCP is a token preset that never shadows the github connector", () => { + const preset = mcpPresetBySlug(MCP_PRESETS, "github-mcp"); + expect(preset?.displayName).toBe("GitHub MCP"); + expect(preset?.url).toBe("https://api.githubcopilot.com/mcp/"); + // GitHub's MCP server accepts a personal access token as a bearer but + // offers no dynamic client registration, so OAuth can't complete here. + expect(preset?.connectionMode).toBe("token"); + expect(preset?.docsUrl).toBe("https://github.com/settings/tokens"); + expect(preset?.tokenSteps?.length).toBeGreaterThanOrEqual(2); + // The native `github` REST connector (PAT/OAuth-App) stays its own + // card: no nativeConnectorId hides it, and neither "github" the slug + // nor "GitHub" the display name resolves to this preset. + expect(preset?.nativeConnectorId).toBeUndefined(); + expect(CONNECTOR_REGISTRY["github"]).toBeDefined(); + expect(mcpPresetByName(MCP_PRESETS, "github")).toBeUndefined(); + expect(mcpPresetByName(MCP_PRESETS, "GitHub")).toBeUndefined(); + expect(mcpPresetByName(MCP_PRESETS, "github mcp")?.slug).toBe("github-mcp"); + }); + + test("uses Sumble's OAuth MCP host, not its product-page URL", () => { + expect(mcpPresetBySlug(MCP_PRESETS, "sumble")?.url).toBe( + "https://mcp.sumble.com/", + ); + expect(mcpPresetBySlug(MCP_PRESETS, "sumble")?.connectionMode).toBe( + "oauth", + ); + }); + + test("keeps every verified endpoint exact", () => { + expect( + Object.fromEntries( + MCP_PRESETS.map((preset) => [preset.slug, preset.url]), + ), + ).toEqual({ + granola: "https://mcp.granola.ai/mcp", + exa: "https://mcp.exa.ai/mcp", + "github-mcp": "https://api.githubcopilot.com/mcp/", + linear: "https://mcp.linear.app/mcp", + notion: "https://mcp.notion.com/mcp", + sentry: "https://mcp.sentry.dev/mcp", + attio: "https://mcp.attio.com/mcp", + railway: "https://mcp.railway.com", + posthog: "https://mcp.posthog.com/mcp", + sumble: "https://mcp.sumble.com/", + canva: "https://mcp.canva.com/mcp", + }); + }); + + test("every preset's nativeConnectorId names a real registry entry", () => { + for (const preset of MCP_PRESETS) { + if (preset.nativeConnectorId === undefined) continue; + expect(CONNECTOR_REGISTRY[preset.nativeConnectorId]).toBeDefined(); + } + }); + + test("MCP_PRESET_CONNECTOR_IDS matches the presets' native ids", () => { + expect(new Set(MCP_PRESET_CONNECTOR_IDS)).toEqual( + new Set(["granola", "exa", "linear"]), + ); + }); + + test("mcpPresetByName resolves by slug or display name, case-insensitively", () => { + expect(mcpPresetByName(MCP_PRESETS, "Exa")?.slug).toBe("exa"); + expect(mcpPresetByName(MCP_PRESETS, "EXA")?.slug).toBe("exa"); + expect(mcpPresetByName(MCP_PRESETS, "granola")?.slug).toBe("granola"); + expect(mcpPresetByName(MCP_PRESETS, "nope")).toBeUndefined(); + }); + + test("mcpPresetBySlug returns undefined for an unknown slug", () => { + expect(mcpPresetBySlug(MCP_PRESETS, "not-a-preset")).toBeUndefined(); + }); + + test("Canva is a DCR-verified OAuth preset with no simple-icons mark yet", () => { + expect(mcpPresetBySlug(MCP_PRESETS, "canva")?.connectionMode).toBe("oauth"); + expect(mcpPresetBySlug(MCP_PRESETS, "canva")?.icon).toBeUndefined(); + }); + + test("Canva lists the 16 live PRM OAuth scopes; other presets omit oauthScopes", () => { + expect(mcpPresetBySlug(MCP_PRESETS, "canva")?.oauthScopes).toEqual([ + "profile:read", + "design:meta:read", + "design:content:write", + "design:content:read", + "folder:read", + "folder:write", + "brandtemplate:content:read", + "brandtemplate:meta:read", + "brandtemplate:content:write", + "comment:write", + "comment:read", + "asset:read", + "asset:write", + "brandkit:read", + "help:answers:read", + "help:answers:write", + ]); + for (const preset of MCP_PRESETS) { + if (preset.slug === "canva") continue; + expect("oauthScopes" in preset).toBe(false); + } + }); +}); diff --git a/templates/connectors.ts b/templates/connectors.ts new file mode 100644 index 000000000..a9de87f7a --- /dev/null +++ b/templates/connectors.ts @@ -0,0 +1,601 @@ +// Workbench's product-supplied connector set (CL-7384): every api-key +// connector and curated MCP preset this build actually ships, built from +// `@corbits/connections`' generic descriptor shape and route factories. +// `@corbits/connections` itself carries no default connector set — that +// would be exactly the kind of fallback this repo's ground rules +// forbid — so a caller (the hub, the web app) imports `CONNECTOR_REGISTRY` +// and `MCP_PRESETS` from here and passes them into the route factories +// that need one. +// +// The eight non-OAuth inference providers come straight from +// `PROVIDER_TEST_CONFIG` — the one source of provider metadata, +// re-exported here rather than duplicated. OpenRouter and Hugging Face +// are the two OAuth inference providers, wired below through the same +// `oauth` config shape `@corbits/connections`' `oauth-routes.ts` reads. +import { + OLLAMA_PLACEHOLDER_SECRET, + PROVIDER_TEST_CONFIG, + testProviderCredential, + type SupportedCredentialProvider, +} from "@corbits/connections/credential-test"; +import { + createConnectorRegistry, + type ConnectorDescriptor, + type ConnectorRegistry, +} from "@corbits/connections/registry"; +import type { McpPreset } from "@corbits/connections/mcp-presets"; +import { mcpPresetConnectorIds } from "@corbits/connections/mcp-presets"; +import { + exchangeCodeForToken, + HUGGINGFACE_AUTHORIZE_URL, + HUGGINGFACE_SCOPE, +} from "@corbits/connections/huggingface-connect"; +import { + exchangeCodeForGoogleToken, + GMAIL_SCOPE, + GOOGLE_AUTHORIZE_URL, +} from "@corbits/connections/gmail-connect"; +// simple-icons ships one brand per named export, tree-shaken by any bundler +// that respects its `sideEffects: false` — importing only the brands this +// registry actually has a listing for pulls in only those icons' data, not +// the whole ~3000-brand package (CC0-1.0 licensed — see the package's own +// LICENSE — so redistributing these marks needs no separate clearance). +// Granola and ScrapeCreators have no simple-icons listing (CL-6215's +// plugins-directory rebuild); OpenAI, xAI, Groq, and Opencode Zen have none +// either (CL-6258's connections logos) — those descriptors carry no `icon`, +// so a caller renders their monochrome initial tile instead. Exa publishes +// its own mark in its official brand kit, used below. Google's mark +// here is Gemini's, not the generic Google "G" — the model brand a person +// actually recognizes from connecting an AI provider, matching +// models.dev's own convention for this row. +import { + siAnthropic, + siDeepseek, + siGithub, + siGmail, + siGooglegemini, + siHuggingface, + siLinear, + siMistralai, + siNotion, + siOllama, + siOpenrouter, + siPosthog, + siRailway, + siSentry, +} from "simple-icons"; +import { + exchangeCodeForKey, + OPENROUTER_AUTH_URL, +} from "@corbits/connections/openrouter-connect"; +import { + exchangeCodeForGithubToken, + GITHUB_AUTHORIZE_URL, +} from "@corbits/connections/github-connect"; +import { + testExaCredential, + testGitHubCredential, + testGranolaCredential, + testLinearCredential, + testManusCredential, + testScrapeCreatorsCredential, +} from "@corbits/connections/probes"; + +const INFERENCE_PROVIDER_DOCS_URL: Readonly< + Record +> = { + anthropic: "https://console.anthropic.com/settings/keys", + openai: "https://platform.openai.com/api-keys", + "google-genai": "https://aistudio.google.com/apikey", + xai: "https://console.x.ai", + "opencode-zen": "https://opencode.ai/zen", + groq: "https://console.groq.com/keys", + deepseek: "https://platform.deepseek.com/api_keys", + mistral: "https://console.mistral.ai/api-keys", + openrouter: "https://openrouter.ai", + huggingface: "https://huggingface.co/settings/tokens", + ollama: "https://ollama.com", +}; + +// Ollama's onboarding URL field default — the local-machine origin +// Ollama listens on out of the box, before anyone points it at a +// tailscale-tunneled or otherwise remote instance. +const OLLAMA_DEFAULT_URL = "http://localhost:11434"; + +const EXA_ICON = { + path: "M150.5 14.1064C150.5 14.3356 150.421 14.5579 150.277 14.736L88.4766 91 150.277 167.264C150.421 167.442 150.5 167.664 150.5 167.894V181C150.5 181.552 150.052 182 149.5 182H1C0.44772 182 0 181.552 0 181V0.999995C0 0.44771 0.447715 0 1 0H149.5C150.052 0 150.5 0.447715 150.5 1V14.1064ZM30.4059 162.719H121.728L76.0664 106.326 30.4059 162.719ZM19.2949 100.261V145.787L56.1572 100.261H19.2949ZM19.2949 80.9801H55.5434L19.2949 36.2121V80.9801ZM76.0664 75.6731L121.728 19.281H30.4059L76.0664 75.6731Z", + hex: "0143D9", + viewBox: "0 0 151 182", +} as const; + +// Every inference provider with a simple-icons listing. OpenAI, xAI, Groq, +// and Opencode Zen have none as of simple-icons' current release; those +// four fall through to the monochrome initial tile every iconless +// descriptor already gets. +const INFERENCE_PROVIDER_ICONS: Partial< + Record< + SupportedCredentialProvider, + { readonly path: string; readonly hex: string } + > +> = { + anthropic: { path: siAnthropic.path, hex: siAnthropic.hex }, + "google-genai": { path: siGooglegemini.path, hex: siGooglegemini.hex }, + deepseek: { path: siDeepseek.path, hex: siDeepseek.hex }, + mistral: { path: siMistralai.path, hex: siMistralai.hex }, + ollama: { path: siOllama.path, hex: siOllama.hex }, + openrouter: { path: siOpenrouter.path, hex: siOpenrouter.hex }, + huggingface: { path: siHuggingface.path, hex: siHuggingface.hex }, +}; + +function inferenceProviderDescriptors(): Record { + const entries: Record = {}; + for (const [id, config] of Object.entries(PROVIDER_TEST_CONFIG)) { + if (id === "openrouter" || id === "huggingface") continue; + const providerId = id as SupportedCredentialProvider; + // Ollama collects a URL, not a secret — it has no auth layer at all + // (see `credential-test.ts`'s own `ollama` config entry). Every + // other connector's single form field is a real key, probed and + // stored as-is. + const icon = INFERENCE_PROVIDER_ICONS[providerId]; + entries[id] = + providerId === "ollama" + ? { + id, + displayName: config.displayName, + authKind: "api-key", + credentialPlugin: "http", + docsUrl: INFERENCE_PROVIDER_DOCS_URL[providerId], + feedsTools: [], + credentialInputKind: "url", + credentialPlaceholder: OLLAMA_DEFAULT_URL, + probe: (baseURL) => + testProviderCredential({ + provider: providerId, + apiKey: OLLAMA_PLACEHOLDER_SECRET, + baseURL, + }), + ...(icon !== undefined ? { icon } : {}), + } + : { + id, + displayName: config.displayName, + authKind: "api-key", + credentialPlugin: "http", + docsUrl: INFERENCE_PROVIDER_DOCS_URL[providerId], + feedsTools: [], + probe: (apiKey) => + testProviderCredential({ provider: providerId, apiKey }), + ...(icon !== undefined ? { icon } : {}), + }; + } + entries["openrouter"] = { + id: "openrouter", + displayName: PROVIDER_TEST_CONFIG.openrouter.displayName, + authKind: "oauth-pkce", + credentialPlugin: "http", + docsUrl: INFERENCE_PROVIDER_DOCS_URL.openrouter, + feedsTools: [], + icon: { path: siOpenrouter.path, hex: siOpenrouter.hex }, + oauth: { + authorizeUrl: OPENROUTER_AUTH_URL, + usesPKCE: true, + // OpenRouter round-trips its state purely via the connect cookie; + // it never echoes `state` back as a callback query param. + echoesState: false, + deploysDefaultWorkflows: true, + buildAuthorizeUrl: ({ callbackUrl, codeChallenge }) => { + const url = new URL(OPENROUTER_AUTH_URL); + url.searchParams.set("callback_url", callbackUrl); + if (codeChallenge !== undefined) { + url.searchParams.set("code_challenge", codeChallenge); + } + url.searchParams.set("code_challenge_method", "S256"); + return url; + }, + exchange: async ({ code, codeVerifier }) => { + const result = await exchangeCodeForKey({ + code, + codeVerifier: codeVerifier ?? "", + }); + return result.ok ? { ok: true, apiKey: result.key } : result; + }, + }, + }; + entries["huggingface"] = { + id: "huggingface", + displayName: PROVIDER_TEST_CONFIG.huggingface.displayName, + authKind: "oauth-pkce", + credentialPlugin: "http", + docsUrl: INFERENCE_PROVIDER_DOCS_URL.huggingface, + feedsTools: [], + icon: { path: siHuggingface.path, hex: siHuggingface.hex }, + oauth: { + authorizeUrl: HUGGINGFACE_AUTHORIZE_URL, + usesPKCE: true, + // HF echoes `state` back in the callback query — the belt-and- + // suspenders check `oauth-routes.ts` runs before consulting the + // state store at all. + echoesState: true, + deploysDefaultWorkflows: true, + // No client secret (a public app); the flow is disabled with a + // `not_configured` outcome when this key is absent from the env + // bag rather than round-tripping a doomed request. + clientId: (env) => env["huggingfaceClientId"], + buildAuthorizeUrl: ({ callbackUrl, state, codeChallenge, clientId }) => { + const url = new URL(HUGGINGFACE_AUTHORIZE_URL); + if (clientId !== undefined) url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", callbackUrl); + url.searchParams.set("scope", HUGGINGFACE_SCOPE); + url.searchParams.set("state", state); + if (codeChallenge !== undefined) { + url.searchParams.set("code_challenge", codeChallenge); + } + url.searchParams.set("code_challenge_method", "S256"); + return url; + }, + exchange: async ({ code, codeVerifier, redirectUri, clientId }) => { + if (clientId === undefined) { + return { + ok: false, + message: "huggingface connect is not configured", + }; + } + const result = await exchangeCodeForToken({ + code, + codeVerifier: codeVerifier ?? "", + redirectUri, + clientId, + }); + if (!result.ok) return result; + return result.expiresAt !== undefined + ? { + ok: true, + apiKey: result.accessToken, + expiresAt: result.expiresAt, + } + : { ok: true, apiKey: result.accessToken }; + }, + }, + }; + return entries; +} + +export const CONNECTOR_REGISTRY: ConnectorRegistry = createConnectorRegistry({ + ...inferenceProviderDescriptors(), + granola: { + id: "granola", + credentialPlugin: "http", + displayName: "Granola", + authKind: "api-key", + docsUrl: "https://www.granola.ai", + feedsTools: ["@corbits/granola-tools"], + probe: (apiKey) => testGranolaCredential(apiKey), + description: "Pull meeting notes and transcripts into your agents.", + }, + manus: { + id: "manus", + credentialPlugin: "http-x-manus-api-key", + displayName: "Manus", + authKind: "api-key", + docsUrl: "https://open.manus.ai/docs/v2/introduction", + feedsTools: ["@corbits/manus-tools"], + probe: (apiKey) => testManusCredential(apiKey), + description: + "Have Manus run tasks and produce files — including slide decks.", + }, + exa: { + id: "exa", + // Exa authenticates via an `x-api-key` header, not `authorization` — + // the `http` (Bearer) plugin doesn't fit; see the `http-x-api-key` + // provider in `@corbits/credential-providers`. + credentialPlugin: "http-x-api-key", + displayName: "Exa", + authKind: "api-key", + docsUrl: "https://exa.ai", + feedsTools: ["@corbits/web-search-tools"], + probe: (apiKey) => testExaCredential(apiKey), + description: "Neural web search built for agents.", + icon: EXA_ICON, + }, + scrapecreators: { + id: "scrapecreators", + // ScrapeCreators, same as Exa, authenticates via `x-api-key`. + credentialPlugin: "http-x-api-key", + displayName: "ScrapeCreators", + authKind: "api-key", + docsUrl: "https://scrapecreators.com", + feedsTools: ["@corbits/reddit-tools"], + probe: (apiKey) => testScrapeCreatorsCredential(apiKey), + description: "Read Reddit threads and comments.", + }, + linear: { + id: "linear", + credentialPlugin: "http-raw-authorization", + displayName: "Linear", + authKind: "api-key", + docsUrl: "https://linear.app/settings/api", + feedsTools: ["@corbits/linear-tools"], + probe: (apiKey) => testLinearCredential(apiKey), + description: "Read and write issues, projects, and comments.", + icon: { path: siLinear.path, hex: siLinear.hex }, + }, + gmail: { + id: "gmail", + displayName: "Gmail", + // Pure hosted-app OAuth — there is no Gmail equivalent of a PAT + // to paste, so unlike `github` below this connector has no + // api-key arm: unconfigured (`GMAIL_CLIENT_ID`/`GMAIL_CLIENT_SECRET` + // unset) means the card renders "not configured", never a dead + // paste form. The exchanged access token expires in about an + // hour; the refresh token rides `OAuthExchangeResult.refreshToken` + // into the credential row's `refreshSecret` so a refresh sweep + // can keep the connection alive. + authKind: "oauth-code", + credentialPlugin: "http", + docsUrl: "https://developers.google.com/gmail/api", + feedsTools: [], + description: "Read, draft, and send email — sending waits for your ok.", + icon: { path: siGmail.path, hex: siGmail.hex }, + oauth: { + authorizeUrl: GOOGLE_AUTHORIZE_URL, + usesPKCE: true, + echoesState: true, + deploysDefaultWorkflows: false, + clientId: (env) => env["gmailClientId"], + clientSecret: (env) => env["gmailClientSecret"], + buildAuthorizeUrl: ({ callbackUrl, state, codeChallenge, clientId }) => { + const url = new URL(GOOGLE_AUTHORIZE_URL); + if (clientId !== undefined) url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", callbackUrl); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", GMAIL_SCOPE); + url.searchParams.set("state", state); + // `offline` + forced consent is what makes Google issue a + // refresh token at all — without both, a re-connect silently + // returns only another one-hour access token. + url.searchParams.set("access_type", "offline"); + url.searchParams.set("prompt", "consent"); + if (codeChallenge !== undefined) { + url.searchParams.set("code_challenge", codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + } + return url; + }, + exchange: async ({ + code, + codeVerifier, + redirectUri, + clientId, + clientSecret, + }) => { + if (clientId === undefined || clientSecret === undefined) { + return { + ok: false, + message: "gmail connect is not configured", + }; + } + return exchangeCodeForGoogleToken({ + code, + ...(codeVerifier !== undefined ? { codeVerifier } : {}), + redirectUri, + clientId, + clientSecret, + }); + }, + }, + }, + github: { + id: "github", + // GitHub's REST API accepts a fine-grained PAT — or a GitHub OAuth + // App user token, which is the same Bearer shape — as a Bearer + // token. Absent entirely, github-tools degrades to a lower + // unauthenticated rate limit rather than "not connected" — see its + // tool.ts. `authKind` stays "api-key" (the PAT paste form is + // always available, CL-6386's guaranteed fallback); the `oauth` + // config below is this connector's one exception to "oauth fields + // are oauth-pkce/oauth-code only" — a caller checks + // `GET /oauth-configured`'s `github` entry to decide whether to + // offer the one-click app connect ahead of the paste form, never + // the other way around, so an unconfigured deploy never dead-ends. + credentialPlugin: "http", + displayName: "GitHub", + authKind: "api-key", + docsUrl: "https://github.com/settings/tokens", + feedsTools: ["@corbits/github-tools"], + probe: (apiKey, opts) => + testGitHubCredential(apiKey, undefined, opts?.baseUrl), + description: "Read repos, issues, and pull requests.", + icon: { path: siGithub.path, hex: siGithub.hex }, + oauth: { + authorizeUrl: GITHUB_AUTHORIZE_URL, + usesPKCE: false, + echoesState: true, + deploysDefaultWorkflows: false, + clientId: (env) => env["githubAppClientId"], + clientSecret: (env) => env["githubAppClientSecret"], + buildAuthorizeUrl: ({ callbackUrl, state, clientId }) => { + const url = new URL(GITHUB_AUTHORIZE_URL); + if (clientId !== undefined) url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", callbackUrl); + url.searchParams.set("scope", "repo"); + url.searchParams.set("state", state); + return url; + }, + exchange: async ({ code, redirectUri, clientId, clientSecret }) => { + if (clientId === undefined || clientSecret === undefined) { + return { + ok: false, + message: "github app connect is not configured", + }; + } + const result = await exchangeCodeForGithubToken({ + code, + redirectUri, + clientId, + clientSecret, + }); + return result.ok ? { ok: true, apiKey: result.key } : result; + }, + }, + }, + "granola-webhook": { + id: "granola-webhook", + // No credential-provider plugin mediates this connector's secret — + // it is minted and encrypted entirely inside + // `@corbits/webhook-triggers` (see `management-routes.ts`), never + // stored as a Credential row. `credentialPlugin` still has to name + // something (`ConnectorDescriptor` requires it uniformly); `http` + // is inert here, kept only so registry-wide invariants (every + // non-bearer connector reads `http`) hold without a special case. + credentialPlugin: "http", + displayName: "Granola inbound webhook", + authKind: "webhook-secret", + docsUrl: "https://www.granola.ai", + // The direction is inverted from the `granola` api-key connector + // above: this issues a signed inbound address for Granola to call, + // feeding nothing — a routine's webhook trigger binding, not a + // tool package's credential handle. + feedsTools: [], + }, +}); + +/** + * Curated remote MCP servers that Workbench can connect without asking a + * person for a URL, API key, client id, or client secret. OAuth entries have + * been verified to advertise OAuth discovery, PKCE, and dynamic client + * registration; keyless entries can be probed and stored immediately; token + * entries accept a pasted access token as the bearer (GitHub's MCP server + * does OAuth only for clients pre-registered with GitHub — it offers no + * dynamic client registration — so its connect here is the token walk). + */ +export const MCP_PRESETS: readonly McpPreset[] = [ + { + slug: "granola", + displayName: "Granola", + description: "Search meeting notes, transcripts, and action items.", + url: "https://mcp.granola.ai/mcp", + connectionMode: "oauth", + docsUrl: "https://docs.granola.ai/help-center/sharing/integrations/mcp", + nativeConnectorId: "granola", + }, + { + slug: "exa", + displayName: "Exa", + description: "Search and research the live web.", + url: "https://mcp.exa.ai/mcp", + connectionMode: "keyless", + docsUrl: "https://docs.exa.ai/reference/exa-mcp", + nativeConnectorId: "exa", + }, + { + slug: "linear", + displayName: "Linear", + description: "Read and update issues, projects, and comments.", + url: "https://mcp.linear.app/mcp", + connectionMode: "oauth", + docsUrl: "https://linear.app/docs/mcp", + nativeConnectorId: "linear", + }, + { + // Deliberately NOT slug "github" and NOT nativeConnectorId "github": + // the native GitHub connector (PAT/OAuth-App feeding + // @corbits/github-tools) stays its own card, and preset lookups by + // "github" must keep resolving to it, never here. + slug: "github-mcp", + displayName: "GitHub MCP", + description: "Search code, work with issues and pull requests.", + url: "https://api.githubcopilot.com/mcp/", + connectionMode: "token", + docsUrl: "https://github.com/settings/tokens", + icon: { path: siGithub.path, hex: siGithub.hex }, + tokenSteps: [ + "Open github.com/settings/tokens and generate a new token.", + "Give it the repo scope — that lets agents read code, issues, and pull requests.", + "Paste it below. It's stored encrypted, only your agents use it, and you can disconnect any time.", + ], + }, + { + 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", + icon: { path: siNotion.path, hex: siNotion.hex }, + }, + { + slug: "sentry", + displayName: "Sentry", + description: "Investigate errors, traces, releases, and projects.", + url: "https://mcp.sentry.dev/mcp", + connectionMode: "oauth", + docsUrl: "https://mcp.sentry.dev/", + icon: { path: siSentry.path, hex: siSentry.hex }, + }, + { + 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", + }, + { + 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", + icon: { path: siRailway.path, hex: siRailway.hex }, + }, + { + 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", + icon: { path: siPosthog.path, hex: siPosthog.hex }, + }, + { + 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", + }, + { + // No simple-icons listing for Canva (CL-6647) — same gap as Granola + // and Sumble above, so this card falls back to the initial-letter + // tile rather than risk a hand-traced or doctored mark. + slug: "canva", + displayName: "Canva", + description: "Design decks, docs, and graphics.", + url: "https://mcp.canva.com/mcp", + connectionMode: "oauth", + docsUrl: "https://www.canva.dev/docs/mcp/", + oauthScopes: [ + "profile:read", + "design:meta:read", + "design:content:write", + "design:content:read", + "folder:read", + "folder:write", + "brandtemplate:content:read", + "brandtemplate:meta:read", + "brandtemplate:content:write", + "comment:write", + "comment:read", + "asset:read", + "asset:write", + "brandkit:read", + "help:answers:read", + "help:answers:write", + ], + }, +]; + +export const MCP_PRESET_CONNECTOR_IDS: readonly string[] = + mcpPresetConnectorIds(MCP_PRESETS); diff --git a/templates/package.json b/templates/package.json index 04a27bb1c..57897da0e 100644 --- a/templates/package.json +++ b/templates/package.json @@ -7,6 +7,7 @@ "type": "module", "exports": { ".": "./index.ts", + "./connectors": "./connectors.ts", "./instantiate": "./instantiate.ts", "./settings": "./settings.ts" }, @@ -16,14 +17,15 @@ }, "dependencies": { "@corbits/code-review": "workspace:*", + "@corbits/connections": "workspace:*", "@corbits/jimmy-agent": "workspace:*", "@corbits/scout-agent": "workspace:*", "@corbits/workflows": "workspace:*", - "arktype": "catalog:" + "arktype": "catalog:", + "simple-icons": "^16.28.0" }, "devDependencies": { "@types/bun": "catalog:", - "@corbits/connections": "workspace:*", "typescript": "catalog:" } } diff --git a/templates/schema.test.ts b/templates/schema.test.ts index 6f195e44b..f5dd02ce5 100644 --- a/templates/schema.test.ts +++ b/templates/schema.test.ts @@ -1,6 +1,5 @@ import { expect, test } from "bun:test"; -import { CONNECTOR_REGISTRY } from "@corbits/connections/registry"; -import { MCP_PRESETS } from "@corbits/connections/mcp-presets"; +import { CONNECTOR_REGISTRY, MCP_PRESETS } from "./connectors"; import { SCOUT_TOOL_PACKAGE_PINS } from "@corbits/scout-agent/definition"; import { diff --git a/templates/settings.ts b/templates/settings.ts index e36c5c0d0..e438442c5 100644 --- a/templates/settings.ts +++ b/templates/settings.ts @@ -1,71 +1,12 @@ -// The `template/*` workbench-settings vocabulary: what a room minted -// from a template persists about that fact, so the shell can render -// its setup state (which connections still need connecting) without a -// second round trip. `@corbits/chat`'s settings route validates only -// its own `chat/*` keys and passes any other namespace through opaquely -// (see `packages/chat/src/workbench-settings.ts`) — this schema is the -// boundary check a caller runs before a `template/*` patch ever leaves -// the client, matching the "parse at every trust boundary" rule for a -// namespace `chat` deliberately leaves to its owner. -import { type } from "arktype"; - -export const TemplateSettingsPatch = type({ - "template/id": "string > 0", - "template/pendingConnections": "string[]", -}); -export type TemplateSettingsPatch = typeof TemplateSettingsPatch.infer; - -/** Builds and validates the settings patch a freshly instantiated - * template writes onto its workbench. Throws on a malformed id or - * connections list rather than sending bad data to the wire. */ -export function templateSettingsPatch( - templateId: string, - pendingConnections: readonly string[], -): TemplateSettingsPatch { - const patch = { - "template/id": templateId, - "template/pendingConnections": [...pendingConnections], - }; - const parsed = TemplateSettingsPatch(patch); - if (parsed instanceof type.errors) { - throw new Error(`invalid template settings patch: ${parsed.summary}`); - } - return parsed; -} - -/** - * The `github` connect card's own settled state (CL-6345): once a - * person has picked which repos to review, `./connect-github-setup.ts` - * writes both keys in the same patch — `pendingConnections` with - * `"github"` removed (this template needs nothing else, so that leaves - * it empty) and `selectedRepos` naming exactly what got a live webhook - * trigger. This patch rides the same `chat/*` settings PATCH route - * every other `template/*` write does; the card itself refetches its - * state as the direct consequence of the `start-reviewing` call that - * triggers this patch, same as it does for its own PAT submit - * (`ConnectGithubBlockContainer`) — never a fold off this stream event. - */ -export const TemplateReposSettingsPatch = type({ - "template/pendingConnections": "string[]", - "template/selectedRepos": "string[]", -}); -export type TemplateReposSettingsPatch = - typeof TemplateReposSettingsPatch.infer; - -/** Builds and validates the settings patch that records which repos a - * room's GitHub connect card started reviewing. Throws on a malformed - * repo id list rather than sending bad data to the wire. */ -export function templateReposSettingsPatch( - pendingConnections: readonly string[], - selectedRepoIds: readonly string[], -): TemplateReposSettingsPatch { - const patch = { - "template/pendingConnections": [...pendingConnections], - "template/selectedRepos": [...selectedRepoIds], - }; - const parsed = TemplateReposSettingsPatch(patch); - if (parsed instanceof type.errors) { - throw new Error(`invalid template repos settings patch: ${parsed.summary}`); - } - return parsed; -} +// Re-exported from `@corbits/connections` — the `template/*` settings +// vocabulary is generic (an arktype schema plus a builder), not +// product-specific data, so it lives with the connections package that +// actually consumes it (`connect-github-routes.ts`'s repo-review +// settling). Kept here too so nothing that already imports +// `@workbench/templates/settings` has to change. +export { + TemplateReposSettingsPatch, + TemplateSettingsPatch, + templateReposSettingsPatch, + templateSettingsPatch, +} from "@corbits/connections/template-settings"; From ee935eb44ec2f53d154b1fe492a0b6b7cdfa2510 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 18:38:55 -0700 Subject: [PATCH 2/2] Bump connections-tools after the connections import rename (CL-7384) --- packages/connections-tools/package.json | 2 +- workflows/assistant/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/connections-tools/package.json b/packages/connections-tools/package.json index 6a3169a24..2af166bcd 100644 --- a/packages/connections-tools/package.json +++ b/packages/connections-tools/package.json @@ -2,7 +2,7 @@ "name": "@corbits/connections-tools", "private": true, "description": "The list_connections and request_connection tools as an @intx/agent tool bundle: an agent sees which third-party connections this workbench already has live and hands the human a deep link to connect one that isn't, reusing the existing connections plane and never inventing new state or completing OAuth itself", - "version": "0.0.7", + "version": "0.0.8", "license": "LGPL-2.1-or-later", "type": "module", "exports": { diff --git a/workflows/assistant/src/index.ts b/workflows/assistant/src/index.ts index 4f6c76782..6f2d4bb5b 100644 --- a/workflows/assistant/src/index.ts +++ b/workflows/assistant/src/index.ts @@ -48,7 +48,7 @@ export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ { name: "@corbits/capability-tools", version: "0.0.5" }, { name: "@corbits/routines-tools", version: "0.0.10" }, { name: "@corbits/agent-directory-tools", version: "0.0.6" }, - { name: "@corbits/connections-tools", version: "0.0.7" }, + { name: "@corbits/connections-tools", version: "0.0.8" }, { name: "@corbits/catalog-tools", version: "0.0.2" }, { name: "@corbits/skills-tools", version: "0.0.6" }, { name: "@corbits/mcp-tools", version: "0.0.11" },