From 79a896b49aeb6bb357bafaa3ca76f2949e559278 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 20:35:58 -0700 Subject: [PATCH 1/7] Add tests for the GitHub MCP token preset --- apps/web/test/connect-service-actions.test.ts | 109 ++++++++++++++++++ .../connections/src/mcp-oauth-routes.test.ts | 18 +++ packages/connections/src/mcp-presets.test.ts | 29 ++++- .../connections/src/mcp-server-routes.test.ts | 49 ++++++++ packages/mcp-tools/src/allowance.test.ts | 62 ++++++++++ .../plugins-ui/test/mcp-preset-cards.test.tsx | 97 +++++++++++++++- 6 files changed, 359 insertions(+), 5 deletions(-) create mode 100644 apps/web/test/connect-service-actions.test.ts diff --git a/apps/web/test/connect-service-actions.test.ts b/apps/web/test/connect-service-actions.test.ts new file mode 100644 index 000000000..d9bb03934 --- /dev/null +++ b/apps/web/test/connect-service-actions.test.ts @@ -0,0 +1,109 @@ +// The chat connect card's host port for MCP presets: a token preset +// (GitHub MCP) resolves to the key-paste affordance with its docs link, +// and a submitted key rides the preset connect route — never the +// fixed-registry credential route. + +import { afterEach, describe, expect, test } from "bun:test"; + +import { createChatConnectServiceActions } from "../src/connect-service-actions"; + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +type RecordedCall = { + readonly path: string; + readonly method: string; + readonly body?: unknown; +}; + +function stubFetch(respond: (path: string, method: string) => Response) { + const calls: RecordedCall[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const path = typeof input === "string" ? input : String(input); + const method = init?.method ?? "GET"; + calls.push({ + path, + method, + ...(typeof init?.body === "string" + ? { body: JSON.parse(init.body) as unknown } + : {}), + }); + return respond(path, method); + }) as unknown as typeof fetch; + return calls; +} + +const PRESET_LIST = { + data: [ + { + 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", + connected: false, + }, + ], +}; + +describe("createChatConnectServiceActions with a token preset", () => { + test("getConnectState maps a disconnected token preset to key-paste with its docs link", async () => { + stubFetch(() => new Response(JSON.stringify(PRESET_LIST))); + const actions = createChatConnectServiceActions("tnt_1", "/bench"); + + const state = await actions.getConnectState("github-mcp"); + + expect(state).toEqual({ + kind: "disconnected", + affordance: "api-key", + docsUrl: "https://github.com/settings/tokens", + }); + }); + + test("submitKey connects the preset with the pasted token via the mcp-servers route", async () => { + const calls = stubFetch((path, method) => { + if (method === "POST") { + return new Response( + JSON.stringify({ + slug: "github-mcp", + name: "GitHub MCP", + url: "https://api.githubcopilot.com/mcp/", + toolCount: 40, + }), + ); + } + return new Response(JSON.stringify(PRESET_LIST)); + }); + const actions = createChatConnectServiceActions("tnt_1", "/bench"); + + const result = await actions.submitKey("github-mcp", "ghp_pasted"); + + expect(result).toEqual({ ok: true }); + const post = calls.find((call) => call.method === "POST"); + expect(post?.path).toBe("/api/tenants/tnt_1/mcp-servers"); + expect(post?.body).toMatchObject({ + presetSlug: "github-mcp", + token: "ghp_pasted", + }); + }); + + test("a connected token preset reads back connected", async () => { + stubFetch( + () => + new Response( + JSON.stringify({ + data: [{ ...PRESET_LIST.data[0], connected: true }], + }), + ), + ); + const actions = createChatConnectServiceActions("tnt_1", "/bench"); + + expect(await actions.getConnectState("github-mcp")).toEqual({ + kind: "connected", + }); + }); +}); diff --git a/packages/connections/src/mcp-oauth-routes.test.ts b/packages/connections/src/mcp-oauth-routes.test.ts index 5dd3d5802..58b26f532 100644 --- a/packages/connections/src/mcp-oauth-routes.test.ts +++ b/packages/connections/src/mcp-oauth-routes.test.ts @@ -457,6 +457,24 @@ describe("MCP OAuth connect flow", () => { expect(response.status).toBe(404); }); + test("a preset that doesn't connect with OAuth is refused at start, not mid-dance", async () => { + const hub = fakeHub(); + const routes = createMcpOAuthRoutes({ + hubUrl: "http://hub.test", + requireGrant: allowAll, + log: () => {}, + credentialCipher: createNoopCredentialCipher(), + apiCall: hub.apiCall, + }); + const app = mountAs(routes); + // github-mcp is a token preset (GitHub offers no dynamic client + // registration) and exa is keyless — neither has an OAuth dance. + for (const slug of ["github-mcp", "exa"]) { + const response = await app.request(`/${slug}/start`); + expect(response.status).toBe(400); + } + }); + test("CL-6371: a provider that echoes state back completes the round trip", async () => { const as = startStubAuthorizationServer({ echoState: true }); try { diff --git a/packages/connections/src/mcp-presets.test.ts b/packages/connections/src/mcp-presets.test.ts index 82a0213ab..338a2ca56 100644 --- a/packages/connections/src/mcp-presets.test.ts +++ b/packages/connections/src/mcp-presets.test.ts @@ -14,6 +14,7 @@ describe("MCP_PRESETS", () => { [ "attio", "exa", + "github-mcp", "granola", "linear", "notion", @@ -29,15 +30,34 @@ describe("MCP_PRESETS", () => { } }); - test("Exa is keyless and every account-backed preset uses OAuth", () => { - const exa = mcpPresetBySlug("exa"); - expect(exa?.connectionMode).toBe("keyless"); + 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") continue; + 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"); @@ -51,6 +71,7 @@ describe("MCP_PRESETS", () => { ).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", diff --git a/packages/connections/src/mcp-server-routes.test.ts b/packages/connections/src/mcp-server-routes.test.ts index 432fae6c6..2e62d8ce4 100644 --- a/packages/connections/src/mcp-server-routes.test.ts +++ b/packages/connections/src/mcp-server-routes.test.ts @@ -443,6 +443,55 @@ describe("POST / with presetSlug", () => { expect(hub.credentials[0]?.secret).toBe("unauthenticated-mcp-server"); }); + test("connects GitHub MCP with a pasted token stored as the bearer credential", async () => { + const hub = fakeHub({}); + let probedUrl: string | undefined; + let probedToken: string | undefined; + const app = buildApp({ + apiCall: hub.apiCall, + probe: async (url, token) => { + probedUrl = url; + probedToken = token; + return { ok: true, toolCount: 40 }; + }, + }); + + const response = await app.request("/", { + method: "POST", + body: JSON.stringify({ presetSlug: "github-mcp", token: "ghp_test" }), + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { slug: string; url: string }; + expect(body.slug).toBe("github-mcp"); + expect(body.url).toBe("https://api.githubcopilot.com/mcp/"); + expect(probedUrl).toBe("https://api.githubcopilot.com/mcp/"); + expect(probedToken).toBe("ghp_test"); + expect(hub.providers[0]?.name).toBe("mcp:github-mcp"); + expect(hub.credentials[0]?.secret).toBe("ghp_test"); + }); + + test("a token preset without a token is a 400 that never probes", async () => { + const hub = fakeHub({}); + let probed = false; + const app = buildApp({ + apiCall: hub.apiCall, + probe: async () => { + probed = true; + return { ok: true, toolCount: 1 }; + }, + }); + + const response = await app.request("/", { + method: "POST", + body: JSON.stringify({ presetSlug: "github-mcp" }), + }); + + expect(response.status).toBe(400); + expect(probed).toBe(false); + expect(hub.providers).toHaveLength(0); + }); + test("an unknown presetSlug is a 400, never touching storage", async () => { const hub = fakeHub({}); const app = buildApp({ apiCall: hub.apiCall }); diff --git a/packages/mcp-tools/src/allowance.test.ts b/packages/mcp-tools/src/allowance.test.ts index fb0922ef8..a9dae0bb9 100644 --- a/packages/mcp-tools/src/allowance.test.ts +++ b/packages/mcp-tools/src/allowance.test.ts @@ -53,3 +53,65 @@ describe("createMcpCallClassifier", () => { }); }); }); + +// The GitHub MCP preset promises nothing beyond this live check: reads +// (search, get file, list PRs) ride a grant only when GitHub's own +// tools/list marks them read-only; writes (create issue, merge) and +// unannotated tools always stay parked. +describe("GitHub MCP server classification", () => { + const githubTools: readonly McpToolInfo[] = [ + { + name: "search_code", + description: "Search code across repositories", + inputSchema: { type: "object" }, + annotations: { readOnlyHint: true }, + }, + { + name: "get_file_contents", + description: "Get file contents", + inputSchema: { type: "object" }, + annotations: { readOnlyHint: true }, + }, + { + name: "list_pull_requests", + description: "List pull requests", + inputSchema: { type: "object" }, + annotations: { readOnlyHint: true }, + }, + { + name: "create_issue", + description: "Create an issue", + inputSchema: { type: "object" }, + annotations: { readOnlyHint: false }, + }, + { + name: "merge_pull_request", + description: "Merge a pull request", + inputSchema: { type: "object" }, + }, + ]; + + const classifyGithub = createMcpCallClassifier((_tenantId, slug) => + Promise.resolve(slug === "github-mcp" ? githubTools : null), + ); + + test("server-annotated reads classify read-only on the github-mcp resource", async () => { + for (const tool of [ + "search_code", + "get_file_contents", + "list_pull_requests", + ]) { + expect( + await classifyGithub("tenant_1", { server: "github-mcp", tool }), + ).toEqual({ readOnly: true, resource: mcpServerResource("github-mcp") }); + } + }); + + test("writes and unannotated tools never classify read-only", async () => { + for (const tool of ["create_issue", "merge_pull_request"]) { + expect( + await classifyGithub("tenant_1", { server: "github-mcp", tool }), + ).toEqual({ readOnly: false }); + } + }); +}); diff --git a/packages/plugins-ui/test/mcp-preset-cards.test.tsx b/packages/plugins-ui/test/mcp-preset-cards.test.tsx index 57087734d..31ab109cd 100644 --- a/packages/plugins-ui/test/mcp-preset-cards.test.tsx +++ b/packages/plugins-ui/test/mcp-preset-cards.test.tsx @@ -51,6 +51,20 @@ const PRESETS = [ docsUrl: "https://exa.ai", connected: false, }, + { + 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.", + "Give it the repo scope.", + "Paste it below.", + ], + connected: false, + }, ]; describe("McpPresetCardsSection", () => { @@ -69,7 +83,7 @@ describe("McpPresetCardsSection", () => { "Search the web (Exa) — no key needed.", ); expect(container.textContent).toContain("Not connected"); - expect(container.querySelectorAll("[data-plugin-slug]")).toHaveLength(2); + expect(container.querySelectorAll("[data-plugin-slug]")).toHaveLength(3); expect( container .querySelector('[data-plugin-slug="exa"] svg') @@ -126,6 +140,87 @@ describe("McpPresetCardsSection", () => { expect(container.textContent).toContain("4 tools"); }); + test("a token preset opens step-by-step guidance and posts the pasted token", async () => { + const calls: { url: string; init?: RequestInit }[] = []; + let connected = false; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + calls.push({ url, ...(init !== undefined ? { init } : {}) }); + if (init?.method === "POST") { + connected = true; + return new Response( + JSON.stringify({ + slug: "github-mcp", + name: "GitHub MCP", + url: "https://api.githubcopilot.com/mcp/", + toolCount: 40, + }), + ); + } + return new Response( + JSON.stringify({ + data: PRESETS.map((p) => + p.slug === "github-mcp" ? { ...p, connected } : p, + ), + }), + ); + }) as unknown as typeof fetch; + + const container = mountSection(); + await settle(); + + const card = container.querySelector( + '[data-plugin-slug="github-mcp"]', + ) as HTMLElement; + const connectButton = [...card.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Connect"), + ) as HTMLButtonElement; + + await act(async () => { + connectButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + // Opening the form is not a connect — no POST yet, steps visible. + expect(calls.find((call) => call.init?.method === "POST")).toBeUndefined(); + expect(card.textContent).toContain( + "Open github.com/settings/tokens and generate a new token.", + ); + expect(card.textContent).toContain("Give it the repo scope."); + expect( + card.querySelector('a[href="https://github.com/settings/tokens"]'), + ).not.toBeNull(); + + const field = card.querySelector( + "#mcp-preset-token-github-mcp", + ) as HTMLInputElement; + expect(field).not.toBeNull(); + await act(async () => { + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set; + setter?.call(field, "ghp_pasted"); + field.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const submitButton = [...card.querySelectorAll("button")].find( + (button) => button.textContent === "Connect", + ) as HTMLButtonElement; + await act(async () => { + submitButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + const connectCall = calls.find((call) => call.init?.method === "POST"); + expect(connectCall?.url).toBe("/api/tenants/tenant_test/mcp-servers"); + const body: unknown = JSON.parse(connectCall?.init?.body as string); + expect(body).toMatchObject({ + presetSlug: "github-mcp", + token: "ghp_pasted", + }); + expect(container.textContent).toContain("40 tools"); + }); + test("disconnect calls DELETE on the preset's slug", async () => { const calls: { url: string; init?: RequestInit }[] = []; let deleted = false; From 72602923011174325ccd32953c5e2c95e1c9e667 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 20:38:30 -0700 Subject: [PATCH 2/7] GitHub MCP preset: token connect riding the existing preset routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub's remote MCP server (api.githubcopilot.com/mcp/) advertises OAuth discovery and PKCE but no dynamic client registration, so the SDK-driven OAuth dance cannot complete against it. It does accept a personal access token as a bearer, and the preset connect route already stores one — this adds a 'token' connection mode expressing that: the preset card opens a step-by-step token walkthrough and posts the pasted token through the same POST /mcp-servers preset path every other connect uses. The preset is slugged github-mcp and displayed 'GitHub MCP' (GitHub's own product name) so it never shadows the native github REST connector in preset-by-name resolution or the galleries. The OAuth start route now refuses non-OAuth presets up front instead of failing mid-dance, and a token preset without a token is a 400 that never probes. --- apps/web/src/connect-service-actions.ts | 34 +++- packages/connections/src/mcp-oauth-routes.ts | 17 ++ packages/connections/src/mcp-presets.ts | 37 +++- packages/connections/src/mcp-server-routes.ts | 15 ++ packages/plugins-ui/src/mcp-preset-cards.tsx | 165 +++++++++++++----- packages/plugins-ui/src/mcp-servers-api.ts | 6 +- 6 files changed, 218 insertions(+), 56 deletions(-) diff --git a/apps/web/src/connect-service-actions.ts b/apps/web/src/connect-service-actions.ts index f5d8c433d..bb927527d 100644 --- a/apps/web/src/connect-service-actions.ts +++ b/apps/web/src/connect-service-actions.ts @@ -23,7 +23,11 @@ import { listProviders, oauthStartHref, } from "@corbits/settings-ui"; -import { connectMcpPreset, listMcpPresets } from "@corbits/plugins-ui"; +import { + connectMcpPreset, + listMcpPresets, + McpServersApiError, +} from "@corbits/plugins-ui"; import { CONNECTOR_REGISTRY } from "@workbench/connections/registry"; import { mcpPresetBySlug } from "@workbench/connections/mcp-presets"; @@ -50,9 +54,17 @@ export function createChatConnectServiceActions( const presets = await listMcpPresets(tenantId); const listed = presets.find((entry) => entry.slug === preset.slug); if (listed?.connected === true) return { kind: "connected" }; - const affordance: ConnectAffordance = - preset.connectionMode === "keyless" ? "keyless" : "oauth"; - return { kind: "disconnected", affordance }; + if (preset.connectionMode === "keyless") { + return { kind: "disconnected", affordance: "keyless" }; + } + if (preset.connectionMode === "token") { + return { + kind: "disconnected", + affordance: "api-key", + docsUrl: preset.docsUrl, + }; + } + return { kind: "disconnected", affordance: "oauth" }; } const descriptor = CONNECTOR_REGISTRY[slug]; @@ -123,6 +135,20 @@ export function createChatConnectServiceActions( }, async submitKey(connectorId, key) { const slug = bareConnectorId(connectorId); + const preset = mcpPresetBySlug(slug); + if (preset !== undefined && preset.connectionMode === "token") { + try { + await connectMcpPreset(tenantId, preset.slug, key); + } catch (cause) { + const message = + cause instanceof McpServersApiError + ? cause.message + : "Couldn't connect with that token. Try again."; + return { ok: false, message }; + } + fanOut(connectorId, { kind: "connected" }); + return { ok: true }; + } try { await completeConnectorCredential(tenantId, slug, key); } catch (cause) { diff --git a/packages/connections/src/mcp-oauth-routes.ts b/packages/connections/src/mcp-oauth-routes.ts index b2b8081ef..39ae0514c 100644 --- a/packages/connections/src/mcp-oauth-routes.ts +++ b/packages/connections/src/mcp-oauth-routes.ts @@ -164,6 +164,23 @@ export function createMcpOAuthRoutes( 404, ); } + const queryUrl = c.req.query("url"); + const preset = + queryUrl === undefined || queryUrl.length === 0 + ? mcpPresetBySlug(slugParam) + : undefined; + if (preset !== undefined && preset.connectionMode !== "oauth") { + // A keyless or token preset has no OAuth dance to start — refuse + // here rather than failing mid-dance at the provider (GitHub's + // MCP server, for one, offers no dynamic client registration). + return c.json( + ErrorEnvelope( + "bad_request", + `${preset.displayName} doesn't connect with a sign-in here — connect it from Plugins instead.`, + ), + 400, + ); + } const principal = c.get("principal"); const callbackUrl = new URL( diff --git a/packages/connections/src/mcp-presets.ts b/packages/connections/src/mcp-presets.ts index 26075eed8..72aefc2e3 100644 --- a/packages/connections/src/mcp-presets.ts +++ b/packages/connections/src/mcp-presets.ts @@ -1,6 +1,12 @@ -import { siNotion, siPosthog, siRailway, siSentry } from "simple-icons"; +import { + siGithub, + siNotion, + siPosthog, + siRailway, + siSentry, +} from "simple-icons"; -export type McpPresetConnectionMode = "oauth" | "keyless"; +export type McpPresetConnectionMode = "oauth" | "keyless" | "token"; export type McpPreset = { readonly slug: string; @@ -11,13 +17,20 @@ export type McpPreset = { readonly docsUrl: string; readonly icon?: { readonly path: string; readonly hex: string }; readonly nativeConnectorId?: string; + /** Token presets only: the numbered walkthrough the connect card + * renders above the paste field — each step one action a person can + * take, ending with what happens to the token. */ + readonly tokenSteps?: 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. + * 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[] = [ { @@ -47,6 +60,24 @@ export const MCP_PRESETS: readonly McpPreset[] = [ 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", diff --git a/packages/connections/src/mcp-server-routes.ts b/packages/connections/src/mcp-server-routes.ts index 8b0ab9785..48b4e0527 100644 --- a/packages/connections/src/mcp-server-routes.ts +++ b/packages/connections/src/mcp-server-routes.ts @@ -213,6 +213,9 @@ export function createMcpServerRoutes( connectionMode: preset.connectionMode, docsUrl: preset.docsUrl, ...(preset.icon === undefined ? {} : { icon: preset.icon }), + ...(preset.tokenSteps === undefined + ? {} + : { tokenSteps: preset.tokenSteps }), connected: credential !== undefined, }; }); @@ -244,6 +247,18 @@ export function createMcpServerRoutes( 400, ); } + if ( + preset?.connectionMode === "token" && + (parsed.token === undefined || parsed.token.length === 0) + ) { + return c.json( + ErrorEnvelope( + "token_required", + `${preset.displayName} needs an access token — create one at ${preset.docsUrl} and paste it in.`, + ), + 400, + ); + } const name = preset?.displayName ?? parsed.name; const url = preset?.url ?? parsed.url; if (name === undefined || url === undefined) { diff --git a/packages/plugins-ui/src/mcp-preset-cards.tsx b/packages/plugins-ui/src/mcp-preset-cards.tsx index 56aed2bc3..e14a8bd31 100644 --- a/packages/plugins-ui/src/mcp-preset-cards.tsx +++ b/packages/plugins-ui/src/mcp-preset-cards.tsx @@ -2,7 +2,7 @@ // get the catalog's one-click installation path. Presets and previously // connected custom servers share the same server-side store. -import { Button, ConfirmButton, toast } from "@corbits/react-ui"; +import { Button, ConfirmButton, Input, toast } from "@corbits/react-ui"; import { CONNECTOR_REGISTRY } from "@workbench/connections/registry"; import { MCP_PRESETS } from "@workbench/connections/mcp-presets"; import { useEffect, useState } from "react"; @@ -33,25 +33,37 @@ function McpPresetCard({ }) { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [tokenFieldOpen, setTokenFieldOpen] = useState(false); + const [token, setToken] = useState(""); - function handleConnect() { - if (preset.connectionMode === "oauth") { - window.location.href = mcpOAuthStartPath(tenantId, preset.slug); - return; - } + function submitConnect(pastedToken: string | undefined) { setBusy(true); setError(null); - connectMcpPreset(tenantId, preset.slug, undefined) + connectMcpPreset(tenantId, preset.slug, pastedToken) .then((result) => { toast( `Connected — ${result.toolCount} tool${result.toolCount === 1 ? "" : "s"} available.`, ); + setTokenFieldOpen(false); + setToken(""); onChanged(result.toolCount); }) .catch((cause: unknown) => setError(messageOf(cause))) .finally(() => setBusy(false)); } + function handleConnect() { + if (preset.connectionMode === "oauth") { + window.location.href = mcpOAuthStartPath(tenantId, preset.slug); + return; + } + if (preset.connectionMode === "token") { + setTokenFieldOpen(true); + return; + } + submitConnect(undefined); + } + function handleDisconnect() { setBusy(true); setError(null); @@ -77,52 +89,111 @@ function McpPresetCard({ : `${toolCount} tool${toolCount === 1 ? "" : "s"}` : "Not connected"; + const tokenFieldId = `mcp-preset-token-${preset.slug}`; + return (
- -
- - {preset.displayName} - - - {preset.description} - - {error !== null ? ( - - {error} +
+ +
+ + {preset.displayName} - ) : null} + + {preset.description} + + {error !== null ? ( + + {error} + + ) : null} +
+ + {status} + + {preset.connected ? ( + + {busy ? "Disconnecting…" : "Disconnect"} + + ) : tokenFieldOpen ? null : ( + + )}
- - {status} - - {preset.connected ? ( - - {busy ? "Disconnecting…" : "Disconnect"} - - ) : ( - - )} + {tokenFieldOpen && !preset.connected ? ( +
+
    + {(preset.tokenSteps ?? []).map((step) => ( +
  1. {step}
  2. + ))} +
+ + Create your token + + + { + setToken(event.target.value); + }} + /> +
+ + +
+
+ ) : null}
); } diff --git a/packages/plugins-ui/src/mcp-servers-api.ts b/packages/plugins-ui/src/mcp-servers-api.ts index 59220dae6..4bed6c754 100644 --- a/packages/plugins-ui/src/mcp-servers-api.ts +++ b/packages/plugins-ui/src/mcp-servers-api.ts @@ -51,9 +51,10 @@ export type McpPreset = { readonly displayName: string; readonly description: string; readonly url: string; - readonly connectionMode: "oauth" | "keyless"; + readonly connectionMode: "oauth" | "keyless" | "token"; readonly docsUrl: string; readonly icon?: { readonly path: string; readonly hex: string }; + readonly tokenSteps?: readonly string[]; readonly connected: boolean; }; @@ -62,9 +63,10 @@ const McpPresetSchema = type({ displayName: "string", description: "string", url: "string", - connectionMode: "'oauth' | 'keyless'", + connectionMode: "'oauth' | 'keyless' | 'token'", docsUrl: "string", "icon?": { path: "string", hex: "string" }, + "tokenSteps?": "string[]", connected: "boolean", }); From 65e9fb6b1f653436396aa9ee3d25e14b7a3d3ec2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 20:39:29 -0700 Subject: [PATCH 3/7] Add tests for the GitHub connect walkthrough copy --- .../test/connect-github-block.test.tsx | 39 +++++++++++++------ .../test/connect-service-block.test.tsx | 4 ++ 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/chat-ui/test/connect-github-block.test.tsx b/packages/chat-ui/test/connect-github-block.test.tsx index f783d8a3a..a31063355 100644 --- a/packages/chat-ui/test/connect-github-block.test.tsx +++ b/packages/chat-ui/test/connect-github-block.test.tsx @@ -110,9 +110,15 @@ describe("connect GitHub card — 2a disconnected", () => { onSubmitAccessToken: () => Promise.resolve({ ok: true }), }); + // Honest PAT-first framing — there is no hosted GitHub sign-in in + // this card, so it never claims an app install it can't do. expect(el.textContent).toContain( - "Install the Workbench app on your GitHub account. Two clicks — you pick the repos on the next step, and nothing is read until you do.", + "Connect GitHub with a personal access token — three quick steps, about a minute.", ); + expect(el.textContent).toContain( + "stored encrypted, only your agents use it, and you can remove it any time", + ); + expect(el.textContent).not.toContain("Install the Workbench app"); const connect = [...el.querySelectorAll("button")].find( (button) => button.textContent === "Connect GitHub", @@ -125,26 +131,35 @@ describe("connect GitHub card — 2a disconnected", () => { expect(connected).toBe(true); }); - test("the token link sits inside the trust sentence, verbatim, and opens the inline field", async () => { + test("connect opens the numbered token walkthrough with the settings link and the field", async () => { const el = await mount({ kind: "disconnected", onConnect: () => undefined, onSubmitAccessToken: () => Promise.resolve({ ok: true }), }); - expect(el.textContent).toContain( - "On a server without GitHub sign-in? Use an access token instead — a token carries whatever access it was made with, so the app install is the safer path when you have the choice.", - ); - - const tokenLink = [...el.querySelectorAll("button")].find( - (button) => button.textContent === "Use an access token instead", + const connect = [...el.querySelectorAll("button")].find( + (button) => button.textContent === "Connect GitHub", ) as HTMLButtonElement | undefined; - expect(tokenLink).not.toBeUndefined(); + expect(connect).not.toBeUndefined(); await act(async () => { - tokenLink?.click(); + connect?.click(); }); + const steps = [...el.querySelectorAll("ol li")].map( + (item) => item.textContent, + ); + expect(steps).toHaveLength(3); + expect(steps[0]).toContain( + "Open github.com/settings/tokens and generate a new token.", + ); + expect(steps[1]).toContain("repo scope"); + expect(steps[2]).toContain("Paste it here"); + expect( + el.querySelector('a[href="https://github.com/settings/tokens"]'), + ).not.toBeNull(); + const field = el.querySelector("#connect-github-token"); expect(field).not.toBeNull(); }); @@ -161,7 +176,7 @@ describe("connect GitHub card — 2a disconnected", () => { }); const openLink = [...el.querySelectorAll("button")].find( - (button) => button.textContent === "Use an access token instead", + (button) => button.textContent === "Connect GitHub", ) as HTMLButtonElement; await act(async () => { openLink.click(); @@ -196,7 +211,7 @@ describe("connect GitHub card — 2a disconnected", () => { }); const openLink = [...el.querySelectorAll("button")].find( - (button) => button.textContent === "Use an access token instead", + (button) => button.textContent === "Connect GitHub", ) as HTMLButtonElement; await act(async () => { openLink.click(); diff --git a/packages/chat-ui/test/connect-service-block.test.tsx b/packages/chat-ui/test/connect-service-block.test.tsx index bbec52ace..0503de229 100644 --- a/packages/chat-ui/test/connect-service-block.test.tsx +++ b/packages/chat-ui/test/connect-service-block.test.tsx @@ -73,6 +73,10 @@ describe("ConnectServiceBlockView oauth arm", () => { }); expect(host.textContent).toContain("Connect Gmail"); expect(host.textContent).toContain(REASON); + // The helper walks through what happens, not just "two clicks". + expect(host.textContent).toContain( + "You'll be sent to Gmail to approve access, then land right back here connected — nothing is shared until you approve.", + ); buttonByText(host, "Connect Gmail").click(); expect(connected).toBe(1); expect(host.querySelector("input")).toBeNull(); From fa8c19f99d20d5ab14287571c5bfcdd37f38de91 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 20:40:35 -0700 Subject: [PATCH 4/7] GitHub connect guidance: honest PAT walkthrough and OAuth what-happens helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GitHub connect card no longer claims an app install it cannot do — it leads with a numbered personal-access-token walkthrough (create at github.com/settings/tokens, give it the repo scope, paste it here) and says plainly what happens to the token: stored encrypted, used only by your agents, removable any time. The generic connect card's OAuth helper now walks through the dance too: you're sent to the service to approve, then land back here connected. --- .../src/blocks/connect-github-block.tsx | 20 ++++++++++++++----- packages/chat-ui/src/strings.ts | 17 ++++++++++------ packages/chat-ui/src/styles.css | 9 +++++++++ .../test/connect-github-block.test.tsx | 5 +++-- 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/packages/chat-ui/src/blocks/connect-github-block.tsx b/packages/chat-ui/src/blocks/connect-github-block.tsx index b4cc4ba91..a8b25af6c 100644 --- a/packages/chat-ui/src/blocks/connect-github-block.tsx +++ b/packages/chat-ui/src/blocks/connect-github-block.tsx @@ -104,6 +104,20 @@ function DisconnectedBody({

{CHAT_STRINGS.blockConnectGithubIntro}

+
    + {CHAT_STRINGS.blockConnectGithubTokenSteps.map((step) => ( +
  1. {step}
  2. + ))} +
+

+ + {CHAT_STRINGS.blockConnectGithubTokenSettingsLink} + +

- {CHAT_STRINGS.blockConnectGithubTokenPrompt}{" "} - - {CHAT_STRINGS.blockConnectGithubTokenTrust} + {CHAT_STRINGS.blockConnectGithubTokenHelper}

); diff --git a/packages/chat-ui/src/strings.ts b/packages/chat-ui/src/strings.ts index 1da738a91..6be7c836a 100644 --- a/packages/chat-ui/src/strings.ts +++ b/packages/chat-ui/src/strings.ts @@ -187,12 +187,17 @@ export const CHAT_STRINGS = { blockConnectGithubHeadline: "Connect GitHub", blockConnectGithubPickHeadline: "Pick your repos", blockConnectGithubIntro: - "Install the Workbench app on your GitHub account. Two clicks — you pick the repos on the next step, and nothing is read until you do.", + "Connect GitHub with a personal access token — three quick steps, about a minute.", blockConnectGithubAction: "Connect GitHub", - blockConnectGithubTokenPrompt: "On a server without GitHub sign-in?", - blockConnectGithubTokenLink: "Use an access token instead", - blockConnectGithubTokenTrust: - " — a token carries whatever access it was made with, so the app install is the safer path when you have the choice.", + blockConnectGithubTokenSteps: [ + "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 here. It's stored encrypted, only your agents use it, and you can remove it any time.", + ] as readonly string[], + blockConnectGithubTokenSettingsUrl: "https://github.com/settings/tokens", + blockConnectGithubTokenSettingsLink: "Open github.com/settings/tokens", + blockConnectGithubTokenHelper: + "Your token is stored encrypted, only your agents use it, and you can remove it any time.", blockConnectGithubConnectedAs: (org: string) => `Connected to GitHub as ${org}`, blockConnectGithubChange: "change", @@ -216,7 +221,7 @@ export const CHAT_STRINGS = { blockConnectServiceHeadline: (name: string) => `Connect ${name}`, blockConnectServiceAction: (name: string) => `Connect ${name}`, blockConnectServiceOAuthHelper: (name: string) => - `Two clicks — you approve in ${name}'s own window, and nothing is touched until you do.`, + `You'll be sent to ${name} to approve access, then land right back here connected — nothing is shared until you approve.`, blockConnectServiceKeylessHelper: "One click — no account keys needed.", blockConnectServiceKeyHelper: (name: string) => `You'll paste a ${name} API key — it stays in your workspace and you can disconnect any time.`, diff --git a/packages/chat-ui/src/styles.css b/packages/chat-ui/src/styles.css index 170206058..9f5f436d3 100644 --- a/packages/chat-ui/src/styles.css +++ b/packages/chat-ui/src/styles.css @@ -1663,6 +1663,15 @@ /* GitHub connect card (CL-6342 screen 2). The row's checkbox control is `@corbits/react-ui`'s own `Checkbox`; only the row layout around it (name left, open-PR count right) is workbench-specific. */ +.chat-block-connect-steps { + margin: 0; + padding-left: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.25rem; + list-style: decimal; +} + .chat-block-connect-helper { margin-top: 0.55rem; } diff --git a/packages/chat-ui/test/connect-github-block.test.tsx b/packages/chat-ui/test/connect-github-block.test.tsx index a31063355..f60816fe7 100644 --- a/packages/chat-ui/test/connect-github-block.test.tsx +++ b/packages/chat-ui/test/connect-github-block.test.tsx @@ -416,7 +416,7 @@ describe("connect GitHub card — accessibility", () => { expect(document.activeElement).toBe(firstCheckbox); }); - test("the disconnected state's primary and quiet actions are both real buttons, not divs", async () => { + test("the disconnected state's actions are real buttons, not divs", async () => { const el = await mount({ kind: "disconnected", onConnect: () => undefined, @@ -424,7 +424,8 @@ describe("connect GitHub card — accessibility", () => { }); const buttons = el.querySelectorAll("button"); - expect(buttons.length).toBeGreaterThanOrEqual(2); + // One clear primary action closed; submit + cancel once opened. + expect(buttons.length).toBeGreaterThanOrEqual(1); for (const button of buttons) { expect(button.getAttribute("type")).toBe("button"); } From dac01a158314b8b90efe7063bf50608466fd5411 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 20:49:31 -0700 Subject: [PATCH 5/7] Format: prettier fixes (includes drift already on main) --- VENDORED.md | 16 ++++++++-------- apps/web/test/routes.test.tsx | 4 +--- .../plugins-ui/test/mcp-preset-cards.test.tsx | 4 ++-- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/VENDORED.md b/VENDORED.md index ac4a18141..e5e9dd2a4 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -22,14 +22,14 @@ never a convenience. ## Ledger -| Vendored path | What was copied | Upstream repo @ commit | Why not a published package | Owner | Kill date | Kill-date test | -| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------- | ----------------- | -| `apps/sidecar` | Derived from upstream's own `apps/sidecar`: 11 shared modules, of which `signing-keypair.ts` is near-verbatim and the rest (`index.ts`, `config.ts`, `tool-materialization.ts`, `workflow-run-pack-client.ts`, …) are substantially rewritten, plus workbench-only modules. A living fork, not a frozen copy, so this row carries no tree hash. | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | An app is never npm-published, so no publish can cover the execution host; retired by consuming an upstream-published host, or by renewing this row deliberately | sawyer | 2026-09-19 | `check:killdates` | -| `vendor/intx/db` | `@intx/db` source (`src/`, `migrations/`, drizzle config, manifest, tsconfigs) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the `wire_projection` column/loader delta (CL-6324); retired when upstream absorbs the delta | sawyer | 2026-09-19 | `check:killdates` | -| `vendor/intx/hub-api` | `@intx/hub-api` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the `needs-you` approval-route reservation or the exported null-principal `resolveApproval` (CL-6345); retired when upstream absorbs the deltas | sawyer | 2026-09-19 | `check:killdates` | -| `vendor/intx/hub-sessions` | `@intx/hub-sessions` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the usage forward (CL-5879), pack-acceptance fixes, adopted deploy front, wire-projection writer, event-collector serialization, or anchor ordering | sawyer | 2026-09-19 | `check:killdates` | -| `vendor/intx/workflow` | `@intx/workflow` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the `onBodyFailure` trigger policy and its projection (CL-6326, CL-6324); retired when upstream absorbs the delta | sawyer | 2026-09-19 | `check:killdates` | -| `vendor/intx/workflow-deploy` | `@intx/workflow-deploy` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | Carries no delta of its own, but must bind against the vendored `@intx/workflow` (whose `onBodyFailure` field flows through the projection it hashes); retired with the workflow delta | sawyer | 2026-09-19 | `check:killdates` | +| Vendored path | What was copied | Upstream repo @ commit | Why not a published package | Owner | Kill date | Kill-date test | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | ---------- | ----------------- | +| `apps/sidecar` | Derived from upstream's own `apps/sidecar`: 11 shared modules, of which `signing-keypair.ts` is near-verbatim and the rest (`index.ts`, `config.ts`, `tool-materialization.ts`, `workflow-run-pack-client.ts`, …) are substantially rewritten, plus workbench-only modules. A living fork, not a frozen copy, so this row carries no tree hash. | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | An app is never npm-published, so no publish can cover the execution host; retired by consuming an upstream-published host, or by renewing this row deliberately | sawyer | 2026-09-19 | `check:killdates` | +| `vendor/intx/db` | `@intx/db` source (`src/`, `migrations/`, drizzle config, manifest, tsconfigs) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the `wire_projection` column/loader delta (CL-6324); retired when upstream absorbs the delta | sawyer | 2026-09-19 | `check:killdates` | +| `vendor/intx/hub-api` | `@intx/hub-api` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the `needs-you` approval-route reservation or the exported null-principal `resolveApproval` (CL-6345); retired when upstream absorbs the deltas | sawyer | 2026-09-19 | `check:killdates` | +| `vendor/intx/hub-sessions` | `@intx/hub-sessions` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the usage forward (CL-5879), pack-acceptance fixes, adopted deploy front, wire-projection writer, event-collector serialization, or anchor ordering | sawyer | 2026-09-19 | `check:killdates` | +| `vendor/intx/workflow` | `@intx/workflow` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the `onBodyFailure` trigger policy and its projection (CL-6326, CL-6324); retired when upstream absorbs the delta | sawyer | 2026-09-19 | `check:killdates` | +| `vendor/intx/workflow-deploy` | `@intx/workflow-deploy` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | Carries no delta of its own, but must bind against the vendored `@intx/workflow` (whose `onBodyFailure` field flows through the projection it hashes); retired with the workflow delta | sawyer | 2026-09-19 | `check:killdates` | | `vendor/intx/workflow-host` | `@intx/workflow-host` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the empty-mail drop (CL-6164), the action/loop runtime bind (CL-6325; its adapters live in `packages/workflow-host-actions` since CL-6435), or the body-spawn authorize/credential threading (CL-6448); retired when upstream absorbs the deltas | sawyer | 2026-09-19 | `check:killdates` | The pinned commit `b5580a02` is upstream's `v0.3.0` release tag, 16 commits diff --git a/apps/web/test/routes.test.tsx b/apps/web/test/routes.test.tsx index c7625484e..7d04807a1 100644 --- a/apps/web/test/routes.test.tsx +++ b/apps/web/test/routes.test.tsx @@ -364,9 +364,7 @@ describe("routes render", () => { expect(activeFooterLabel(markup)).toBe("Skills"); }); - test.each([ - ["/plugins/linear", "linear", "Plugins"], - ])( + test.each([["/plugins/linear", "linear", "Plugins"]])( "%s titles the detail placeholder %s with its roster row lit", async (path, slug, footerLabel) => { const markup = await renderApp(path); diff --git a/packages/plugins-ui/test/mcp-preset-cards.test.tsx b/packages/plugins-ui/test/mcp-preset-cards.test.tsx index 31ab109cd..2baa2d5d9 100644 --- a/packages/plugins-ui/test/mcp-preset-cards.test.tsx +++ b/packages/plugins-ui/test/mcp-preset-cards.test.tsx @@ -171,8 +171,8 @@ describe("McpPresetCardsSection", () => { const card = container.querySelector( '[data-plugin-slug="github-mcp"]', ) as HTMLElement; - const connectButton = [...card.querySelectorAll("button")].find( - (button) => button.textContent?.includes("Connect"), + const connectButton = [...card.querySelectorAll("button")].find((button) => + button.textContent?.includes("Connect"), ) as HTMLButtonElement; await act(async () => { From 0f9b0c9d0f649b1799fbfade7af69aacb10afba4 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 21:08:38 -0700 Subject: [PATCH 6/7] mcp-tools: bump version for the src tree change The tenant seeding freshness gate keys tool packages on name@version and refuses a changed src/ under an unchanged version. --- apps/web/src/connect-service-actions.ts | 1 - apps/web/test/connect-service-actions.test.ts | 2 +- packages/mcp-tools/package.json | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/web/src/connect-service-actions.ts b/apps/web/src/connect-service-actions.ts index bb927527d..e66e604b9 100644 --- a/apps/web/src/connect-service-actions.ts +++ b/apps/web/src/connect-service-actions.ts @@ -12,7 +12,6 @@ // hub's connect-settling hook posts the in-room resume message — so no // client-side event fold is needed for the flip. import type { - ConnectAffordance, ConnectServiceActions, ConnectServiceQuery, } from "@corbits/chat-ui"; diff --git a/apps/web/test/connect-service-actions.test.ts b/apps/web/test/connect-service-actions.test.ts index d9bb03934..4f1e43d20 100644 --- a/apps/web/test/connect-service-actions.test.ts +++ b/apps/web/test/connect-service-actions.test.ts @@ -65,7 +65,7 @@ describe("createChatConnectServiceActions with a token preset", () => { }); test("submitKey connects the preset with the pasted token via the mcp-servers route", async () => { - const calls = stubFetch((path, method) => { + const calls = stubFetch((_path, method) => { if (method === "POST") { return new Response( JSON.stringify({ diff --git a/packages/mcp-tools/package.json b/packages/mcp-tools/package.json index 0b1a9880b..8964f3e4f 100644 --- a/packages/mcp-tools/package.json +++ b/packages/mcp-tools/package.json @@ -2,7 +2,7 @@ "name": "@corbits/mcp-tools", "private": true, "description": "Generic MCP server integration: connect any Streamable HTTP MCP server through Plugins and its tools become reachable by any agent via mcp_list_servers/mcp_list_tools/mcp_call", - "version": "0.0.7", + "version": "0.0.8", "license": "LGPL-2.1-or-later", "type": "module", "exports": { From a5c326c835e72183ff63676156fbcbf6648f9ff4 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 21:15:44 -0700 Subject: [PATCH 7/7] Ripple the mcp-tools 0.0.8 pin and the GitHub MCP preset roster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workflow definitions pin tool packages by exact version, and the assistant's connections roster lists every preset — both follow the preset addition and the mcp-tools version bump. --- workflows/assistant/src/index.ts | 2 +- workflows/assistant/test/manager-tools-scenario.test.ts | 2 +- workflows/attio-task-agent/src/index.ts | 2 +- workflows/attio-task-agent/test/definition.test.ts | 2 +- workflows/exa-topic-watch/src/index.ts | 2 +- workflows/exa-topic-watch/test/definition.test.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/workflows/assistant/src/index.ts b/workflows/assistant/src/index.ts index 718e85562..29fa6885d 100644 --- a/workflows/assistant/src/index.ts +++ b/workflows/assistant/src/index.ts @@ -49,7 +49,7 @@ export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ { name: "@corbits/connections-tools", version: "0.0.5" }, { name: "@corbits/catalog-tools", version: "0.0.1" }, { name: "@corbits/skills-tools", version: "0.0.6" }, - { name: "@corbits/mcp-tools", version: "0.0.7" }, + { name: "@corbits/mcp-tools", version: "0.0.8" }, { name: "@corbits/interaction-tools", version: "0.0.2" }, ]; diff --git a/workflows/assistant/test/manager-tools-scenario.test.ts b/workflows/assistant/test/manager-tools-scenario.test.ts index e4c7c8e6a..da0ab297f 100644 --- a/workflows/assistant/test/manager-tools-scenario.test.ts +++ b/workflows/assistant/test/manager-tools-scenario.test.ts @@ -332,7 +332,7 @@ async function runScenario( new AbortController().signal, ); expect(String(afterConnect.content)).toContain( - "Not connected: Notion, Sentry, Attio, Railway, PostHog, Sumble.", + "Not connected: GitHub MCP, Notion, Sentry, Attio, Railway, PostHog, Sumble.", ); expect(String(afterConnect.content)).toContain( "Connected: Granola, Exa, Linear.", diff --git a/workflows/attio-task-agent/src/index.ts b/workflows/attio-task-agent/src/index.ts index 3993623b3..905a6fb87 100644 --- a/workflows/attio-task-agent/src/index.ts +++ b/workflows/attio-task-agent/src/index.ts @@ -91,7 +91,7 @@ export const ATTIO_TASK_AGENT_SYSTEM_PROMPT = buildAttioTaskAgentSystemPrompt({ * deploy of this package itself. */ export const ATTIO_TASK_AGENT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ - { name: "@corbits/mcp-tools", version: "0.0.7" }, + { name: "@corbits/mcp-tools", version: "0.0.8" }, ]; /** diff --git a/workflows/attio-task-agent/test/definition.test.ts b/workflows/attio-task-agent/test/definition.test.ts index 0f53c9a06..d96ae8fcb 100644 --- a/workflows/attio-task-agent/test/definition.test.ts +++ b/workflows/attio-task-agent/test/definition.test.ts @@ -70,7 +70,7 @@ test("one MCP pin covers the CRM, past calls, and the web — the OG needed thre const agent = workStep(buildAttioTaskAgentWorkflow(INPUT)).agent; expect(agent.toolPackagePins).toEqual(ATTIO_TASK_AGENT_TOOL_PACKAGE_PINS); expect(ATTIO_TASK_AGENT_TOOL_PACKAGE_PINS).toEqual([ - { name: "@corbits/mcp-tools", version: "0.0.7" }, + { name: "@corbits/mcp-tools", version: "0.0.8" }, ]); }); diff --git a/workflows/exa-topic-watch/src/index.ts b/workflows/exa-topic-watch/src/index.ts index cf1c12f1d..332c9370b 100644 --- a/workflows/exa-topic-watch/src/index.ts +++ b/workflows/exa-topic-watch/src/index.ts @@ -110,7 +110,7 @@ export const EXA_TOPIC_WATCH_SYSTEM_PROMPT = [ * travels with the deploy of this package itself. */ export const EXA_TOPIC_WATCH_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ - { name: "@corbits/mcp-tools", version: "0.0.7" }, + { name: "@corbits/mcp-tools", version: "0.0.8" }, ]; /** diff --git a/workflows/exa-topic-watch/test/definition.test.ts b/workflows/exa-topic-watch/test/definition.test.ts index a917e449a..990612546 100644 --- a/workflows/exa-topic-watch/test/definition.test.ts +++ b/workflows/exa-topic-watch/test/definition.test.ts @@ -71,7 +71,7 @@ test("the step pins the MCP tools bundle, the one package a deploy here can reso const agent = digestStep(buildExaTopicWatchWorkflow(INPUT)).agent; expect(agent.toolPackagePins).toEqual(EXA_TOPIC_WATCH_TOOL_PACKAGE_PINS); expect(EXA_TOPIC_WATCH_TOOL_PACKAGE_PINS).toEqual([ - { name: "@corbits/mcp-tools", version: "0.0.7" }, + { name: "@corbits/mcp-tools", version: "0.0.8" }, ]); });